mapshaper 0.7.27 → 0.7.29

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.
Files changed (3) hide show
  1. package/mapshaper.js +2327 -642
  2. package/package.json +3 -3
  3. package/www/mapshaper.js +2327 -642
package/www/mapshaper.js CHANGED
@@ -31748,10 +31748,6 @@ ${svg}
31748
31748
  .option('tolerance', {
31749
31749
  describe: 'acceptable error when buffering lines and polygons (default is 1%)'
31750
31750
  })
31751
- // .option('circle-quality', {
31752
- // // segments per circle in joins and caps
31753
- // type: 'integer'
31754
- // })
31755
31751
  .option('cap-style', {
31756
31752
  describe: 'flat or round (default is round)'
31757
31753
  })
@@ -31764,6 +31760,14 @@ ${svg}
31764
31760
  describe: '[polygons] buffer unshared boundaries without covering source polygon areas',
31765
31761
  type: 'flag'
31766
31762
  })
31763
+ .option('fill-gaps', {
31764
+ describe: '[polygons] fill enclosed holes and inlets whose opening is narrower than the buffer distance, without growing the outer boundary',
31765
+ type: 'flag'
31766
+ })
31767
+ .option('max-widening', {
31768
+ describe: '[with fill-gaps] fill interior gaps up to this multiple of the buffer distance wide (default 5); wider gaps are kept open',
31769
+ type: 'number'
31770
+ })
31767
31771
  .option('geodesic', {
31768
31772
  describe: '[projected data] buffer using geodesic distances',
31769
31773
  type: 'flag'
@@ -31777,7 +31781,6 @@ ${svg}
31777
31781
  type: 'integer'
31778
31782
  })
31779
31783
  .option('quad-segs', {
31780
- // .option('arc-quality', {
31781
31784
  describe: 'segments per quarter-circle in joins and caps (default is 8)',
31782
31785
  type: 'integer'
31783
31786
  })
@@ -31785,10 +31788,18 @@ ${svg}
31785
31788
  // generate initial buffer shapes but don't dissolve them
31786
31789
  type: 'flag'
31787
31790
  })
31788
- .option('debug-winding', {
31791
+ .option('debug-mosaic', {
31789
31792
  type: 'flag'
31790
31793
  })
31791
- .option('debug-mosaic', {
31794
+ .option('debug-voronoi', {
31795
+ // output the inter-feature medial-axis (Voronoi) cut-lines used to
31796
+ // partition contested space in a topological polygon buffer
31797
+ type: 'flag'
31798
+ })
31799
+ .option('debug-delaunay', {
31800
+ // output the medial-construction triangles (Delaunay triangles bridging
31801
+ // two features within buffer reach) used to build the medial axis in a
31802
+ // topological polygon buffer
31792
31803
  type: 'flag'
31793
31804
  })
31794
31805
  .option('left', {
@@ -31816,19 +31827,25 @@ ${svg}
31816
31827
  })
31817
31828
  .option('no-loop-removal', {
31818
31829
  // Loop removal (collapsing self-overlap loops from two-sided line buffers
31819
- // before the dissolve) is on by default; this opts out. Undocumented: it
31820
- // is an internal construction optimization whose output matches the
31821
- // un-optimized buffer within the error tolerance.
31830
+ // before the dissolve) is on by default; this opts out.
31831
+ type: 'flag'
31832
+ })
31833
+ .option('loop-removal-turn-gate', {
31834
+ // Undocumented: for two-sided open-path buffers, use the source-turn-gate
31835
+ // loop-removal method instead of the default crossing-direction method.
31836
+ // Kept as an alternative for A/B comparison and as a conservative fallback.
31822
31837
  type: 'flag'
31823
31838
  })
31824
- .option('sector-band', {
31825
- // Undocumented escape hatch: build buffers with the older sector-band
31826
- // construction (per-segment offset bands + join-sector rings + a
31827
- // band-coverage audit, unioned by a boundary flood) instead of the
31828
- // default winding-fill construction. Applies to line buffers (one- and
31829
- // two-sided) and all polygon buffers (positive, negative, topological).
31830
- // Kept as a slower-but-conservative fallback and a debugging aid in case
31831
- // the winding-fill construction is found to mishandle some input.
31839
+ .option('band-method', {
31840
+ // Undocumented escape hatch: build buffers with the older band (sector-
31841
+ // band) construction -- per-segment offset bands + join-sector rings + a
31842
+ // band-coverage audit, unioned by a boundary flood -- instead of the
31843
+ // default construction (the clean-outline construction for polygon grow,
31844
+ // the winding-fill construction otherwise). Applies to line buffers (one-
31845
+ // and two-sided) and all polygon buffers (positive, negative,
31846
+ // topological); where a path's default already is the band construction,
31847
+ // this is a no-op. Kept as a slower-but-conservative fallback and a
31848
+ // debugging aid in case the default construction mishandles some input.
31832
31849
  type: 'flag'
31833
31850
  })
31834
31851
  .option('no-cleanup', {
@@ -40670,7 +40687,7 @@ ${svg}
40670
40687
  i2y = y;
40671
40688
  }
40672
40689
 
40673
- const center = circumcenter(i0x, i0y, i1x, i1y, i2x, i2y);
40690
+ const center = circumcenter$1(i0x, i0y, i1x, i1y, i2x, i2y);
40674
40691
  this._cx = center.x;
40675
40692
  this._cy = center.y;
40676
40693
 
@@ -40940,7 +40957,7 @@ ${svg}
40940
40957
  return x * x + y * y;
40941
40958
  }
40942
40959
 
40943
- function circumcenter(ax, ay, bx, by, cx, cy) {
40960
+ function circumcenter$1(ax, ay, bx, by, cx, cy) {
40944
40961
  const dx = bx - ax;
40945
40962
  const dy = by - ay;
40946
40963
  const ex = cx - ax;
@@ -41123,19 +41140,6 @@ ${svg}
41123
41140
  {flat: false, no_holes: false, per_part_holes: !!opts.per_part_holes});
41124
41141
 
41125
41142
  // rewindPolygonParts(lyr, nodes);
41126
- if (opts.debug_winding) {
41127
- lyr.shapes = lyr.shapes.map(function(shp, i) {
41128
- var tiles = mosaicIndex.getTilesByShapeIds([i]);
41129
- if (!tiles.length) return null;
41130
- var parts = [];
41131
- tiles.forEach(function(tile) {
41132
- parts.push.apply(parts, tile);
41133
- });
41134
- return parts;
41135
- });
41136
- return;
41137
- }
41138
-
41139
41143
  if (opts.debug_mosaic) {
41140
41144
  tmp = composeMosaicLayer(lyr, mosaicIndex.mosaic);
41141
41145
  lyr.shapes = tmp.shapes;
@@ -41346,10 +41350,17 @@ ${svg}
41346
41350
 
41347
41351
  // Returns {ring, srcPos}: ring is the closed coordinate ring (first point
41348
41352
  // repeated as last); srcPos is the parallel source-position array.
41349
- self.done = function() {
41353
+ // @allowDegenerate: return null instead of erroring when the ring collapsed
41354
+ // to fewer than 3 points (an offset loop whose source ring shrank away, e.g.
41355
+ // a hole smaller than the buffer radius) -- a normal outcome, not a bug.
41356
+ self.done = function(allowDegenerate) {
41350
41357
  var ring = path.slice().reverse().concat(buffer);
41351
41358
  var srcPos = pathPos.slice().reverse().concat(bufferPos);
41352
41359
  if (ring.length < 3) {
41360
+ if (allowDegenerate) {
41361
+ init();
41362
+ return null;
41363
+ }
41353
41364
  error('Defective buffer ring:', ring);
41354
41365
  }
41355
41366
  ring.push(ring[0].concat());
@@ -41401,9 +41412,7 @@ ${svg}
41401
41412
  // Max source-path turn (degrees) a collapsible loop may span. Below this the
41402
41413
  // path is too straight to have enclosed an uncovered region, so the crossing is
41403
41414
  // a covered overshoot; above it the loop may be a real buffer hole and is left
41404
- // for the dissolve. Empirically, shallow-concavity artifacts span well under
41405
- // 150 degrees while the tightest observed real hole spans ~210, so this sits in
41406
- // the gap with margin on both sides. A fully enclosing loop turns ~360.
41415
+ // for the dissolve.
41407
41416
  var BUFFER_LOOP_MAX_TURN = 150;
41408
41417
 
41409
41418
  // ring: closed ring (first point repeated as last) of [x, y] points.
@@ -41419,75 +41428,173 @@ ${svg}
41419
41428
  var gated = !!(srcPos && turnPrefix);
41420
41429
  var n = ring.length - 1; // distinct points (the last repeats the first)
41421
41430
  // Compact retained points into `out` instead of splicing collapsed spans out
41422
- // of one array: every splice shifts the whole tail (O(tail) per collapse,
41423
- // O(n^2) over the ring), whereas the scan only ever commits a growing prefix
41424
- // and looks a bounded window forward, so it can append kept points and drop a
41425
- // collapsed span by advancing the read cursor -- O(1) per collapse.
41426
- //
41427
- // `a` is the current anchor (the last committed point, out[last]); `b` is the
41428
- // point after it (a ring vertex, or a synthetic crossing after a collapse);
41429
- // `r` indexes the ring vertex following `b`. This mirrors the in-place scan's
41430
- // anchor i with a = ring[i], b = ring[i+1], r = i+2.
41431
+ // of one array. A collapse drops a span by advancing `nextRingIndex`, so the
41432
+ // ring tail is never shifted.
41431
41433
  var out = [ring[0]];
41432
41434
  var outPos = gated ? [srcPos[0]] : null;
41433
- var b = ring[1];
41434
- var bpos = gated ? srcPos[1] : 0;
41435
- var r = 2;
41435
+ var segmentEnd = ring[1];
41436
+ var segmentEndPos = gated ? srcPos[1] : 0;
41437
+ var nextRingIndex = 2; // first ring vertex after segmentEnd
41438
+
41436
41439
  while (true) {
41437
- var a = out[out.length - 1];
41438
- var ax = a[0], ay = a[1], bx = b[0], by = b[1];
41439
- var apos = gated ? outPos[outPos.length - 1] : 0;
41440
- // forward segment t maps to the in-place scan's j = (anchor index) + 2 + t
41441
- var maxT = Math.min(maxGap - 2, n - 2 - r);
41442
- var collapsed = false;
41443
- for (var t = 0; t <= maxT; t++) {
41444
- var c = ring[r + t], d = ring[r + t + 1];
41440
+ var anchor = out[out.length - 1];
41441
+ var anchorPos = gated ? outPos[outPos.length - 1] : 0;
41442
+ var ax = anchor[0], ay = anchor[1];
41443
+ var bx = segmentEnd[0], by = segmentEnd[1];
41444
+ var lastScanIndex = Math.min(nextRingIndex + maxGap - 2, n - 2);
41445
+ var crossing = null;
41446
+ var crossingEndIndex = 0;
41447
+
41448
+ for (var scanIndex = nextRingIndex; scanIndex <= lastScanIndex; scanIndex++) {
41449
+ var c = ring[scanIndex], d = ring[scanIndex + 1];
41445
41450
  var hit = segHit(ax, ay, bx, by, c[0], c[1], d[0], d[1]);
41446
41451
  if (!hit) continue;
41447
41452
  // Collapse only covered overshoots (small source turn). A larger source
41448
41453
  // turn means the span may enclose a real buffer hole, which must be left
41449
41454
  // for the dissolve -- whatever the pocket's winding orientation.
41450
- if (gated &&
41451
- !spanIsCovered(apos, bpos, srcPos, r, r + t + 1, turnPrefix, maxTurn)) {
41455
+ var spanTurn = gated ? getSpanTurn(anchorPos, segmentEndPos, srcPos,
41456
+ nextRingIndex, scanIndex + 1, turnPrefix) : null;
41457
+ if (gated && spanTurn >= maxTurn) {
41452
41458
  continue;
41453
41459
  }
41454
- // Replace the span a..ring[r+t] with the crossing: the anchor stays and is
41455
- // re-scanned (its new segment may cross again), so keep `a`, set b = hit,
41456
- // and advance the cursor past the collapsed vertices.
41457
- b = hit;
41458
- if (gated) bpos = apos; // collapsed point inherits the anchor's source pos
41459
- r = r + t + 1;
41460
- collapsed = true;
41460
+ crossing = hit;
41461
+ crossingEndIndex = scanIndex + 1;
41461
41462
  break;
41462
41463
  }
41463
- if (collapsed) continue;
41464
- out.push(b); // the anchor's successor can no longer collapse; commit it
41465
- if (gated) outPos.push(bpos);
41466
- if (r > n - 1) break; // no ring vertex left to become the next b
41467
- b = ring[r];
41468
- if (gated) bpos = srcPos[r];
41469
- r++;
41464
+
41465
+ if (crossing) {
41466
+ // Replace the collapsed span with the crossing. Keep the same anchor and
41467
+ // rescan because the new segment may cross another nearby segment.
41468
+ segmentEnd = crossing;
41469
+ if (gated) segmentEndPos = anchorPos;
41470
+ nextRingIndex = crossingEndIndex;
41471
+ continue;
41472
+ }
41473
+
41474
+ out.push(segmentEnd); // safe: anchor -> segmentEnd found no collapsible loop
41475
+ if (gated) outPos.push(segmentEndPos);
41476
+ if (nextRingIndex > n - 1) break; // no vertex left to become segmentEnd
41477
+ segmentEnd = ring[nextRingIndex];
41478
+ if (gated) segmentEndPos = srcPos[nextRingIndex];
41479
+ nextRingIndex++;
41470
41480
  }
41471
41481
  if (out.length < 4) return ring; // collapsed away; keep original
41472
41482
  out.push(out[0].concat());
41473
41483
  return out;
41474
41484
  }
41475
41485
 
41476
- // True when the source-path span feeding a collapsed pocket turns by less than
41477
- // maxTurn (a covered overshoot, safe to collapse). The span covers the anchor
41478
- // position `apos`, its successor `bpos`, and ring positions srcPos[lo..hi]. A
41479
- // pocket touching a cap (NaN position) is never treated as a covered overshoot.
41480
- function spanIsCovered(apos, bpos, srcPos, lo, hi, turnPrefix, maxTurn) {
41481
- if (apos !== apos || bpos !== bpos) return false; // NaN cap
41486
+ // Signed area (x2) of the sub-loop X -> segmentEnd -> ring[lo..hi] -> X.
41487
+ function loopAreaSign(xx, xy, segEndX, segEndY, ring, lo, hi) {
41488
+ var s = (xx * segEndY - segEndX * xy);
41489
+ var px = segEndX, py = segEndY;
41490
+ for (var k = lo; k <= hi; k++) {
41491
+ var q = ring[k];
41492
+ s += px * q[1] - q[0] * py;
41493
+ px = q[0]; py = q[1];
41494
+ }
41495
+ s += px * xy - xx * py;
41496
+ return s; // >0 CCW, <0 CW
41497
+ }
41498
+ function ringSignedArea(ring) {
41499
+ var s = 0;
41500
+ for (var i = 0; i < ring.length - 1; i++) {
41501
+ s += ring[i][0] * ring[i + 1][1] - ring[i + 1][0] * ring[i][1];
41502
+ }
41503
+ return s;
41504
+ }
41505
+
41506
+ // Collapse self-overlap loops using the crossing-direction signal instead of the
41507
+ // source-turn gate (removeBufferRingLoops). Where a constructed offset ring has a
41508
+ // consistent +/-1 base winding -- the two-sided outline of an OPEN path -- the
41509
+ // winding sense of each minimal self-crossing loop classifies it exactly: a
41510
+ // sub-loop wound the SAME way as its parent ring is a covered fold-back overlap
41511
+ // (collapse it; the dissolve would only fill it), while a sub-loop wound the
41512
+ // OPPOSITE way bounds a winding-0 pocket the dissolve must keep as a hole.
41513
+ //
41514
+ // This is more precise than the turn-gate's source-turn heuristic and needs no
41515
+ // source-path provenance (srcPos/turnPrefix), only the ring geometry. It does
41516
+ // NOT apply to the winding-fill construction used for closed rings / polygons,
41517
+ // where the base winding is not a constant +/-1, so a local loop's winding sense
41518
+ // does not determine the absolute (hole vs covered) winding of its interior.
41519
+ //
41520
+ // Pass 1 marks every vertex inside an opposite-wound (hole) loop so the collapse
41521
+ // pass never eats a hole, including an overlap loop that happens to wrap one.
41522
+ function removeBufferRingLoopsByDirection(ring, maxGap) {
41523
+ if (!ring || ring.length < 6) return ring;
41524
+ var n = ring.length - 1;
41525
+ var parentCCW = (ringSignedArea(ring) >= 0);
41526
+ // Pass 1: a sub-loop wound OPPOSITE to its parent ring encloses a winding-0
41527
+ // hole the dissolve must keep; mark its vertices so the collapse pass never
41528
+ // eats it (covers an overlap loop that happens to wrap a hole).
41529
+ var holeVertex = new Uint8Array(n);
41530
+ for (var i = 0; i < n - 1; i++) {
41531
+ var a = ring[i], b = ring[i + 1];
41532
+ var jMax = Math.min(i + maxGap, n - 1);
41533
+ for (var j = i + 2; j <= jMax; j++) {
41534
+ if (i === 0 && j === n - 1) continue;
41535
+ var c = ring[j], d = ring[j + 1];
41536
+ var hit = segHit(a[0], a[1], b[0], b[1], c[0], c[1], d[0], d[1]);
41537
+ if (!hit) continue;
41538
+ var loopCCW = loopAreaSign(hit[0], hit[1], b[0], b[1], ring, i + 2, j) >= 0;
41539
+ if (loopCCW !== parentCCW) {
41540
+ for (var k = i + 1; k <= j; k++) holeVertex[k] = 1;
41541
+ }
41542
+ }
41543
+ }
41544
+ // Pass 2: collapse sub-loops wound the SAME way as the parent (covered
41545
+ // fold-back overlaps) unless they would eat a hole vertex.
41546
+ var out = [ring[0]];
41547
+ var segmentEnd = ring[1];
41548
+ var nextRingIndex = 2;
41549
+ while (true) {
41550
+ var anchor = out[out.length - 1];
41551
+ var ax = anchor[0], ay = anchor[1], bx = segmentEnd[0], by = segmentEnd[1];
41552
+ var lastScanIndex = Math.min(nextRingIndex + maxGap - 2, n - 2);
41553
+ var crossing = null, crossingEndIndex = 0;
41554
+ for (var s = nextRingIndex; s <= lastScanIndex; s++) {
41555
+ var cc = ring[s], dd = ring[s + 1];
41556
+ var hit2 = segHit(ax, ay, bx, by, cc[0], cc[1], dd[0], dd[1]);
41557
+ if (!hit2) continue;
41558
+ var ovCCW = loopAreaSign(hit2[0], hit2[1], bx, by, ring, nextRingIndex, s) >= 0;
41559
+ if (ovCCW !== parentCCW) continue; // opposite-wound loop is a hole: keep
41560
+ var wrapsHole = false;
41561
+ for (var k2 = nextRingIndex; k2 <= s; k2++) {
41562
+ if (holeVertex[k2]) { wrapsHole = true; break; }
41563
+ }
41564
+ if (wrapsHole) continue;
41565
+ crossing = hit2;
41566
+ crossingEndIndex = s + 1;
41567
+ break;
41568
+ }
41569
+ if (crossing) {
41570
+ segmentEnd = crossing;
41571
+ nextRingIndex = crossingEndIndex;
41572
+ continue;
41573
+ }
41574
+ out.push(segmentEnd);
41575
+ if (nextRingIndex > n - 1) break;
41576
+ segmentEnd = ring[nextRingIndex];
41577
+ nextRingIndex++;
41578
+ }
41579
+ if (out.length < 4) return ring;
41580
+ out.push(out[0].concat());
41581
+ return out;
41582
+ }
41583
+
41584
+ // Absolute source turn spanned by a crossing candidate. The span covers the
41585
+ // anchor position `apos`, its successor `bpos`, and ring positions srcPos[lo..hi].
41586
+ // A pocket touching a cap (NaN position) is never treated as a covered overshoot.
41587
+ function getSpanTurn(apos, bpos, srcPos, lo, hi, turnPrefix) {
41588
+ if (apos !== apos || bpos !== bpos) return Infinity; // NaN cap
41482
41589
  var posLo = apos < bpos ? apos : bpos;
41483
41590
  var posHi = apos > bpos ? apos : bpos;
41484
41591
  for (var k = lo; k <= hi; k++) {
41485
41592
  var p = srcPos[k];
41486
- if (p !== p) return false; // NaN
41593
+ if (p !== p) return Infinity; // NaN
41487
41594
  if (p < posLo) posLo = p;
41488
41595
  if (p > posHi) posHi = p;
41489
41596
  }
41490
- return (turnPrefix[posHi] - turnPrefix[posLo]) < maxTurn;
41597
+ return turnPrefix[posHi] - turnPrefix[posLo];
41491
41598
  }
41492
41599
 
41493
41600
  // Fast strict-interior segment crossing; returns [x, y] or null. Buffer join
@@ -42333,6 +42440,18 @@ ${svg}
42333
42440
 
42334
42441
  // each path may be converted into multiple buffer rings, which later
42335
42442
  // need to be dissolved
42443
+ // Re-anchor a closed ring [v0, v1, ..., v0] to the midpoint of its first edge:
42444
+ // returns [m, v1, ..., v0, m] where m = midpoint(v0, v1). The offset then
42445
+ // starts/ends mid-edge (a collinear seam) and v0 becomes an interior join.
42446
+ function startRingAtEdgeMidpoint(verts) {
42447
+ var a = verts[0], b = verts[1];
42448
+ var m = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
42449
+ var out = [m];
42450
+ for (var i = 1; i < verts.length; i++) out.push(verts[i]);
42451
+ out.push(m.concat());
42452
+ return out;
42453
+ }
42454
+
42336
42455
  function makeSinglePathRings(pathArcs, dist) {
42337
42456
  var rings = [];
42338
42457
  var pathSideVerts = collectPathVertices(pathArcs);
@@ -42340,18 +42459,23 @@ ${svg}
42340
42459
  if (simplifyIntervalFn) {
42341
42460
  verts = presimplifyPathVerts(verts, simplifyIntervalFn(dist), dist);
42342
42461
  }
42343
- if (!oneSidedBuffer && !opts.sector_band && pathIsOpen(verts)) {
42462
+ if (!oneSidedBuffer && !opts.band_method && pathIsOpen(verts)) {
42344
42463
  // Fast path for ordinary two-sided line buffers: emit one closed
42345
42464
  // outline instead of many per-segment bands that must be dissolved.
42346
- // The sector-band escape hatch skips it to fall through to the
42465
+ // The band-method escape hatch skips it to fall through to the
42347
42466
  // per-segment band construction (makeLeftBufferRings, no winding fill).
42348
42467
  var built = makeTwoSidedOutlineRing(verts, dist);
42349
42468
  if (opts.no_loop_removal) return [built.ring];
42350
- // Default: strip self-overlap loops (the dissolve would fill them anyway)
42351
- // so it has fewer segments and self-intersections to resolve; only loops
42352
- // spanning a small source-path turn are removed, so real holes are kept
42353
- return [removeBufferRingLoops(built.ring, BUFFER_LOOP_WINDOW,
42354
- built.srcPos, getSourceTurnPrefix(verts))];
42469
+ // Strip self-overlap loops (the dissolve would fill them anyway) so it has
42470
+ // fewer segments and self-intersections to resolve, while keeping real
42471
+ // holes. The two-sided outline of an open path has a consistent +/-1 base
42472
+ // winding, so the crossing-direction method classifies loops exactly from
42473
+ // the ring geometry alone; the source-turn gate is kept as an alternative.
42474
+ if (opts.loop_removal_turn_gate) {
42475
+ return [removeBufferRingLoops(built.ring, BUFFER_LOOP_WINDOW,
42476
+ built.srcPos, getSourceTurnPrefix(verts))];
42477
+ }
42478
+ return [removeBufferRingLoopsByDirection(built.ring, BUFFER_LOOP_WINDOW)];
42355
42479
  }
42356
42480
  if (!opts.right || opts.left) {
42357
42481
  rings = rings.concat(buildOneSidedRings(verts));
@@ -42369,19 +42493,43 @@ ${svg}
42369
42493
  // (no winding_fill) and the source-path edge get no provenance and pass
42370
42494
  // through unchanged.
42371
42495
  //
42372
- // Restricted to the winding-fill construction on closed source rings: a
42373
- // closed ring's offset overshoots are doubly-covered (safe to collapse), but
42374
- // an OPEN one-sided arc (e.g. a topological polygon's unbuffered-boundary
42375
- // remnant, buffered with caps) can have a concave-join dent that is its
42376
- // region's only coverage, which collapsing would cut away. Callers whose
42377
- // winding construction is not safe to collapse this way (the one-sided line
42378
- // buffer) opt out by passing no_loop_removal.
42496
+ // The source-turn gate is applied to both closed source rings AND open
42497
+ // one-sided arcs (e.g. a topological polygon's unbuffered-boundary remnant,
42498
+ // buffered with caps). An open arc can have a concave-join dent that is its
42499
+ // region's only coverage, which a purely geometric remover would cut away --
42500
+ // but the source-turn gate keeps such coverage via per-ring source-position
42501
+ // provenance, so it is safe here and lets the topological pipeline's
42502
+ // per-feature dissolve start from far cleaner rings (its dominant cost).
42503
+ // Callers whose winding construction is not safe to collapse this way (the
42504
+ // one-sided line buffer) opt out by passing no_loop_removal.
42379
42505
  function buildOneSidedRings(sideVerts) {
42506
+ // Outline mode offsets a closed source ring to a single self-contained
42507
+ // loop with no source-path edge to close it, so the loop must close on
42508
+ // itself at the start vertex. Starting at a corner leaves that seam
42509
+ // unjoined (the raw first/last offset endpoints sit off the boundary,
42510
+ // breaking inward elbow closures and the loop remover's first-vertex
42511
+ // assumption). Restart at the midpoint of the first edge: the seam then
42512
+ // falls mid-edge (collinear -- no join needed) and the original start
42513
+ // corner becomes an ordinary interior join.
42514
+ if (opts.outline && sideVerts.length > 2 && !pathIsOpen(sideVerts)) {
42515
+ sideVerts = startRingAtEdgeMidpoint(sideVerts);
42516
+ }
42380
42517
  var built = makeLeftBufferRings(sideVerts, dist,
42381
42518
  oneSidedBuffer ? pathSideVerts : null);
42382
- if (opts.no_loop_removal || !opts.winding_fill || pathIsOpen(sideVerts)) {
42519
+ if (opts.no_loop_removal || !opts.winding_fill) {
42383
42520
  return built.rings;
42384
42521
  }
42522
+ if (opts.outline) {
42523
+ // Outline mode rings are clean offset-only loops (no source-path edge),
42524
+ // so each has a consistent +/-1 base winding and the crossing-direction
42525
+ // remover classifies overshoot loops exactly from the ring geometry --
42526
+ // the same condition that makes it safe for the open-path two-sided
42527
+ // outline. (The band-ribbon rings of the default construction do not,
42528
+ // which is why they use the source-turn gate below.)
42529
+ return built.rings.map(function(ring) {
42530
+ return removeBufferRingLoopsByDirection(ring, BUFFER_LOOP_WINDOW);
42531
+ });
42532
+ }
42385
42533
  var turnPrefix = getSourceTurnPrefix(sideVerts);
42386
42534
  return built.rings.map(function(ring, i) {
42387
42535
  var srcPos = built.srcPositions[i];
@@ -42395,8 +42543,7 @@ ${svg}
42395
42543
  var rings = [];
42396
42544
  // Parallel to rings[]: each entry is the source-position array for the
42397
42545
  // corresponding ring (from builder.done()), or null for rings with no
42398
- // single-path provenance (join sectors, band patches). Used by the
42399
- // winding-fill caller to gate loop removal.
42546
+ // single-path provenance (join sectors, band patches).
42400
42547
  var ringsSrcPos = [];
42401
42548
  var openPath = pathIsOpen(verts);
42402
42549
  var x0, y0, x1, y1, x2, y2; // path traversal coords
@@ -42409,7 +42556,8 @@ ${svg}
42409
42556
  var segId;
42410
42557
 
42411
42558
  function flushRing() {
42412
- var d = builder.done();
42559
+ var d = builder.done(!!opts.outline);
42560
+ if (!d) return; // outline loop collapsed (e.g. hole smaller than radius)
42413
42561
  rings.push(d.ring);
42414
42562
  ringsSrcPos.push(d.srcPos);
42415
42563
  }
@@ -42421,7 +42569,9 @@ ${svg}
42421
42569
  if (verts.length > 0) {
42422
42570
  x0 = x2 = verts[0][0];
42423
42571
  y0 = y2 = verts[0][1];
42424
- addPathStart(verts[0]);
42572
+ // Outline mode emits the offset polyline only (no source-path band edge),
42573
+ // so the ring is a single self-contained offset loop (see buildOneSidedRings).
42574
+ if (!opts.outline) addPathStart(verts[0]);
42425
42575
  }
42426
42576
 
42427
42577
  for (segId = 0; segId < verts.length - 1; segId++) {
@@ -42444,9 +42594,8 @@ ${svg}
42444
42594
 
42445
42595
  // various connections between current offset segment and prev segment.
42446
42596
  // Offset ("buffer") vertices are tagged with the source segment id
42447
- // (segId) so loop removal can gate self-crossings by source-path turn;
42448
- // the slight imprecision of tagging a previous-segment endpoint (p2Prev)
42449
- // with the current segId is harmless for the smooth turn gate.
42597
+ // (segId); the slight imprecision of tagging a previous-segment endpoint
42598
+ // (p2Prev) with the current segId is harmless for provenance tracking.
42450
42599
  if (segId === 0) {
42451
42600
  // first extruded segment - no previous segment to join to - add
42452
42601
  // first endpoint to the buffer
@@ -42508,11 +42657,11 @@ ${svg}
42508
42657
  // the two adjacent buffer sections; without it, the dissolved buffer
42509
42658
  // is pinched at the bend vertex.
42510
42659
  pushAuxRing(makeJoinSectorRing(x1, y1, bearing - 90, -joinAngle, dist, p1, p2Prev));
42511
- addPathStart(verts[segId]);
42660
+ if (!opts.outline) addPathStart(verts[segId]);
42512
42661
  builder.addBufferVertex(p1, segId);
42513
42662
  }
42514
42663
 
42515
- addPathSegment(verts[segId], verts[segId + 1], pathSideVerts);
42664
+ if (!opts.outline) addPathSegment(verts[segId], verts[segId + 1], pathSideVerts);
42516
42665
  // in v4, offset direction (bearing) is the same for both segment
42517
42666
  // endpoints, because we are projecting lat/lon coords to Mercator and
42518
42667
  // using planar geometry for all datasets
@@ -42521,14 +42670,22 @@ ${svg}
42521
42670
  p2Prev = p2;
42522
42671
  }
42523
42672
 
42673
+ var closedPath = (x2 == x0 && y2 == y0);
42674
+
42524
42675
  // TODO: add this to cap and join code below
42525
- if (p2Prev) {
42676
+ if (p2Prev && !(opts.outline && closedPath)) {
42526
42677
  // add final offset segment endpoint (the last path segment is never
42527
- // suppressed, so the buffer section in progress ends normally)
42678
+ // suppressed, so the buffer section in progress ends normally).
42679
+ // Outline closed rings skip it: the midpoint-restart seam is collinear,
42680
+ // so p2Prev (the last segment's recomputed endpoint offset) is a ~1 ULP
42681
+ // duplicate of the first offset vertex p1First. The ring must close by
42682
+ // duplicating p1First exactly (done() below), not on a recalculated
42683
+ // final point -- the sub-ULP gap leaves a sliver the winding dissolve
42684
+ // cannot resolve, collapsing the whole buffer in some JS engines.
42528
42685
  builder.addBufferVertex(p2Prev, segId - 1);
42529
42686
  }
42530
42687
 
42531
- if (x2 == x0 && y2 == y0) { // closed path
42688
+ if (opts.outline && closedPath) ; else if (closedPath) { // closed path
42532
42689
  // add join to finish closed path
42533
42690
  // TODO - figure out which bearing to use
42534
42691
  joinAngle = getJoinAngle(bearing, firstBearing);
@@ -43389,7 +43546,7 @@ ${svg}
43389
43546
  time('buffer');
43390
43547
  var spherical = isLatLngCRS(getDatasetCRS(dataset));
43391
43548
  var oneSided = !!opts.left !== !!opts.right;
43392
- var debug = opts.debug_offset || opts.debug_winding || opts.debug_mosaic;
43549
+ var debug = opts.debug_offset || opts.debug_mosaic;
43393
43550
  // One-sided buffers use a winding-number fill, run per-feature so each source
43394
43551
  // path's mosaic stays small. The construction is one-sided by design (offset
43395
43552
  // curve + end caps + the source path as the inner edge), so the winding fill
@@ -43404,12 +43561,12 @@ ${svg}
43404
43561
  // outline + boundary-flood dissolve + artifact-hole filter, which is faster
43405
43562
  // and has no wrong side to clean.
43406
43563
  //
43407
- // The undocumented 'sector-band' option forces the older non-winding
43564
+ // The undocumented 'band-method' option forces the older non-winding
43408
43565
  // construction here too (the per-feature pipeline below handles one-sided
43409
43566
  // buffers via its winding_fill:false coverage test), as a conservative
43410
43567
  // fallback.
43411
43568
  var useWinding = oneSided && !debug && opts.winding_fill !== false &&
43412
- !opts.sector_band;
43569
+ !opts.band_method;
43413
43570
  if (useWinding) {
43414
43571
  // no_loop_removal: the one-sided construction's overshoot loops can be a
43415
43572
  // concave bend's only band coverage, so collapsing them would cut holes in
@@ -43424,19 +43581,19 @@ ${svg}
43424
43581
  }
43425
43582
  var dataset2;
43426
43583
  if (debug) {
43427
- // Debug visualizations (raw offset rings, winding tiles, mosaic) want the
43428
- // whole layer's geometry/topology in one dataset; keep the original global
43429
- // dissolve for them (no artifact-hole filter runs in debug mode anyway).
43430
- // Mirror the real pipeline's construction so the debug view reflects what
43431
- // the buffer actually builds: an all-closed-ring two-sided layer uses the
43432
- // winding-fill + loop-removal construction (and a winding-number dissolve),
43433
- // so debug-offset shows the loop-removed offset rings and the no-loop-removal
43584
+ // Debug visualizations (raw offset rings, mosaic) want the whole layer's
43585
+ // geometry/topology in one dataset; keep the original global dissolve for
43586
+ // them (no artifact-hole filter runs in debug mode anyway). Mirror the real
43587
+ // pipeline's construction so the debug view reflects what the buffer
43588
+ // actually builds: an all-closed-ring two-sided layer uses the winding-fill
43589
+ // + loop-removal construction (and a winding-number dissolve), so
43590
+ // debug-offset shows the loop-removed offset rings and the no-loop-removal
43434
43591
  // flag has a visible effect. (See makePolylineBufferTwoSidedPerFeature.)
43435
- var debugWinding = !oneSided && layerIsAllClosed(lyr, dataset.arcs);
43436
- var debugMakerOpts = debugWinding ?
43592
+ var useWindingConstruction = !oneSided && layerIsAllClosed(lyr, dataset.arcs);
43593
+ var debugMakerOpts = useWindingConstruction ?
43437
43594
  Object.assign({}, opts, {winding_fill: true}) : opts;
43438
43595
  var debugDissolveOpts = Object.assign({}, opts, {per_part_holes: true},
43439
- debugWinding ? {winding_fill: true} : null);
43596
+ useWindingConstruction ? {winding_fill: true} : null);
43440
43597
  dataset2 = importGeoJSON(makeShapeBufferGeoJSON(lyr, dataset, debugMakerOpts), {});
43441
43598
  dissolveBufferDataset2(dataset2, debugDissolveOpts);
43442
43599
  } else {
@@ -43843,7 +44000,7 @@ ${svg}
43843
44000
  }
43844
44001
 
43845
44002
  function useArtifactHoleFilter(opts) {
43846
- return !opts.debug_offset && !opts.debug_winding && !opts.debug_mosaic;
44003
+ return !opts.debug_offset && !opts.debug_mosaic;
43847
44004
  }
43848
44005
 
43849
44006
  // Remove artifact rings left by dissolving the self-intersecting outline rings
@@ -44402,136 +44559,1198 @@ ${svg}
44402
44559
  return lng;
44403
44560
  }
44404
44561
 
44405
- // Remove small-area polygon rings (very simple implementation of sliver removal)
44406
- // TODO: more sophisticated sliver detection (e.g. could consider ratio of area to perimeter)
44407
- // TODO: consider merging slivers into adjacent polygons to prevent gaps from forming
44408
- // TODO: consider separate gap removal function as an alternative to merging slivers
44562
+ function MaxHeap() {
44563
+ return new Heap('max');
44564
+ }
44565
+
44566
+ // A heap data structure used for computing Visvalingam simplification data.
44567
+ // type: 'max' or 'min' (min is default)
44409
44568
  //
44410
- cmd.filterSlivers = function(lyr, dataset, opts) {
44411
- if (lyr.geometry_type != 'polygon') {
44412
- return 0;
44413
- }
44414
- return filterSlivers(lyr, dataset, opts);
44415
- };
44569
+ function Heap(type) {
44570
+ var heapBuf = utils.expandoBuffer(Int32Array),
44571
+ indexBuf = utils.expandoBuffer(Int32Array),
44572
+ heavierThan = type == 'max' ? lessThan : greaterThan,
44573
+ itemsInHeap = 0,
44574
+ dataArr,
44575
+ heapArr,
44576
+ indexArr;
44416
44577
 
44417
- function filterSlivers(lyr, dataset, optsArg) {
44418
- var opts = utils.extend({sliver_control: 1}, optsArg);
44419
- var filterData = getSliverFilter(lyr, dataset, opts);
44420
- var ringTest = filterData.filter;
44421
- var removed = 0;
44422
- var pathFilter = function(path, i, paths) {
44423
- if (ringTest(path, i, paths)) {
44424
- removed++;
44425
- return null;
44578
+ this.init = function(values) {
44579
+ var i;
44580
+ dataArr = values;
44581
+ itemsInHeap = values.length;
44582
+ heapArr = heapBuf(itemsInHeap);
44583
+ indexArr = indexBuf(itemsInHeap);
44584
+ for (i=0; i<itemsInHeap; i++) {
44585
+ insertValue(i, i);
44586
+ }
44587
+ // place non-leaf items
44588
+ for (i=(itemsInHeap-2) >> 1; i >= 0; i--) {
44589
+ downHeap(i);
44426
44590
  }
44427
44591
  };
44428
44592
 
44429
- noteLayerWillChange(lyr, {operation: 'filter-slivers', unit: 'shapes'});
44430
- editShapes(lyr.shapes, pathFilter);
44431
- markLayerChanged(lyr, {operation: 'filter-slivers', unit: 'shapes'});
44432
- message(utils.format("Removed %'d sliver%s using %s", removed, utils.pluralSuffix(removed), filterData.label));
44433
-
44434
- // Remove null shapes (likely removed by clipping/erasing, although possibly already present)
44435
- if (opts.remove_empty) {
44436
- cmd.filterFeatures(lyr, dataset.arcs, {remove_empty: true, verbose: false});
44437
- }
44438
- return removed;
44439
- }
44593
+ this.size = function() {
44594
+ return itemsInHeap;
44595
+ };
44440
44596
 
44441
- function filterClipSlivers(lyr, clipLyr, arcs) {
44442
- var threshold = getDefaultSliverThreshold(lyr, arcs);
44443
- // message('Using variable sliver threshold (based on ' + (threshold / 1e6) + ' sqkm)');
44444
- var ringTest = getSliverTest(arcs, threshold, 1);
44445
- var flags = new Uint8Array(arcs.size());
44446
- var removed = 0;
44447
- var pathFilter = function(path) {
44448
- var prevArcs = 0,
44449
- newArcs = 0;
44450
- for (var i=0, n=path && path.length || 0; i<n; i++) {
44451
- if (flags[absArcId(path[i])] > 0) {
44452
- newArcs++;
44453
- } else {
44454
- prevArcs++;
44455
- }
44456
- }
44457
- // filter paths that contain arcs from both original and clip/erase layers
44458
- // and are small
44459
- if (newArcs > 0 && prevArcs > 0 && ringTest(path)) {
44460
- removed++;
44461
- return null;
44597
+ // Update a single value and re-heap
44598
+ this.updateValue = function(valIdx, val) {
44599
+ var heapIdx = indexArr[valIdx];
44600
+ dataArr[valIdx] = val;
44601
+ if (!(heapIdx >= 0 && heapIdx < itemsInHeap)) {
44602
+ error("Out-of-range heap index.");
44462
44603
  }
44604
+ downHeap(upHeap(heapIdx));
44463
44605
  };
44464
44606
 
44465
- countArcsInShapes(clipLyr.shapes, flags);
44466
- noteLayerWillChange(lyr, {operation: 'filter-clip-slivers', unit: 'shapes'});
44467
- editShapes(lyr.shapes, pathFilter);
44468
- markLayerChanged(lyr, {operation: 'filter-clip-slivers', unit: 'shapes'});
44469
- return removed;
44470
- }
44607
+ this.popValue = function() {
44608
+ return dataArr[this.pop()];
44609
+ };
44471
44610
 
44472
- // Assumes: Arcs have been divided
44473
- //
44474
- function clipPolylines(targetShapes, clipShapes, nodes, type) {
44475
- var index = new PathIndex(clipShapes, nodes.arcs);
44611
+ this.getValue = function(idx) {
44612
+ return dataArr[idx];
44613
+ };
44476
44614
 
44477
- return targetShapes.map(function(shp) {
44478
- return clipPolyline(shp);
44479
- });
44615
+ this.peek = function() {
44616
+ return heapArr[0];
44617
+ };
44480
44618
 
44481
- function clipPolyline(shp) {
44482
- var clipped = null;
44483
- if (shp) clipped = shp.reduce(clipPath, []);
44484
- return clipped && clipped.length > 0 ? clipped : null;
44485
- }
44619
+ this.peekValue = function() {
44620
+ return dataArr[heapArr[0]];
44621
+ };
44486
44622
 
44487
- function clipPath(memo, path) {
44488
- var clippedPath = null,
44489
- arcId, enclosed;
44490
- for (var i=0; i<path.length; i++) {
44491
- arcId = path[i];
44492
- enclosed = index.arcIsEnclosed(arcId);
44493
- if (enclosed && type == 'clip' || !enclosed && type == 'erase') {
44494
- if (!clippedPath) {
44495
- memo.push(clippedPath = []);
44496
- }
44497
- clippedPath.push(arcId);
44498
- } else {
44499
- clippedPath = null;
44623
+ // Return the idx of the lowest-value item in the heap
44624
+ this.pop = function() {
44625
+ var popIdx;
44626
+ if (itemsInHeap <= 0) {
44627
+ error("Tried to pop from an empty heap.");
44628
+ }
44629
+ popIdx = heapArr[0];
44630
+ insertValue(0, heapArr[--itemsInHeap]); // move last item in heap into root position
44631
+ downHeap(0);
44632
+ return popIdx;
44633
+ };
44634
+
44635
+ function upHeap(idx) {
44636
+ var parentIdx;
44637
+ // Move item up in the heap until it's at the top or is not lighter than its parent
44638
+ while (idx > 0) {
44639
+ parentIdx = (idx - 1) >> 1;
44640
+ if (heavierThan(idx, parentIdx)) {
44641
+ break;
44500
44642
  }
44643
+ swapItems(idx, parentIdx);
44644
+ idx = parentIdx;
44501
44645
  }
44502
- return memo;
44646
+ return idx;
44503
44647
  }
44504
- }
44505
44648
 
44506
- var PolylineClipping = /*#__PURE__*/Object.freeze({
44507
- __proto__: null,
44508
- clipPolylines: clipPolylines
44509
- });
44649
+ // Swap item at @idx with any lighter children
44650
+ function downHeap(idx) {
44651
+ var minIdx = compareDown(idx);
44510
44652
 
44511
- // TODO: to prevent invalid holes,
44512
- // could erase the holes from the space-enclosing rings.
44513
- function appendHolesToRings(cw, ccw) {
44514
- for (var i=0, n=ccw.length; i<n; i++) {
44515
- cw.push(ccw[i]);
44653
+ while (minIdx > idx) {
44654
+ swapItems(idx, minIdx);
44655
+ idx = minIdx; // descend in the heap
44656
+ minIdx = compareDown(idx);
44657
+ }
44516
44658
  }
44517
- return cw;
44518
- }
44519
44659
 
44520
- function getPolygonDissolver(nodes, spherical) {
44521
- spherical = spherical && !nodes.arcs.isPlanar();
44522
- var flags = new Uint8Array(nodes.arcs.size());
44523
- var divide = getHoleDivider(nodes, spherical);
44524
- var pathfind = getRingIntersector(nodes, flags);
44660
+ function swapItems(a, b) {
44661
+ var i = heapArr[a];
44662
+ insertValue(a, heapArr[b]);
44663
+ insertValue(b, i);
44664
+ }
44525
44665
 
44526
- return function(shp) {
44527
- if (!shp) return null;
44528
- var cw = [],
44529
- ccw = [];
44666
+ // Associate a heap idx with the index of a value in data arr
44667
+ function insertValue(heapIdx, valId) {
44668
+ indexArr[valId] = heapIdx;
44669
+ heapArr[heapIdx] = valId;
44670
+ }
44530
44671
 
44531
- divide(shp, cw, ccw);
44532
- cw = pathfind(cw, 'flatten');
44533
- ccw.forEach(reversePath);
44534
- ccw = pathfind(ccw, 'flatten');
44672
+ // comparator for Visvalingam min heap
44673
+ // @a, @b: Indexes in @heapArr
44674
+ function greaterThan(a, b) {
44675
+ var idx1 = heapArr[a],
44676
+ idx2 = heapArr[b],
44677
+ val1 = dataArr[idx1],
44678
+ val2 = dataArr[idx2];
44679
+ // If values are equal, compare array indexes.
44680
+ // This is not a requirement of the Visvalingam algorithm,
44681
+ // but it generates output that matches Mahes Visvalingam's
44682
+ // reference implementation.
44683
+ // See https://hydra.hull.ac.uk/assets/hull:10874/content
44684
+ return (val1 > val2 || val1 === val2 && idx1 > idx2);
44685
+ }
44686
+
44687
+ // comparator for max heap
44688
+ function lessThan(a, b) {
44689
+ var idx1 = heapArr[a],
44690
+ idx2 = heapArr[b];
44691
+ return dataArr[idx1] < dataArr[idx2];
44692
+ }
44693
+
44694
+ function compareDown(idx) {
44695
+ var a = 2 * idx + 1,
44696
+ b = a + 1,
44697
+ n = itemsInHeap;
44698
+ if (a < n && heavierThan(idx, a)) {
44699
+ idx = a;
44700
+ }
44701
+ if (b < n && heavierThan(idx, b)) {
44702
+ idx = b;
44703
+ }
44704
+ return idx;
44705
+ }
44706
+ }
44707
+
44708
+ var Visvalingam = {};
44709
+
44710
+ Visvalingam.getArcCalculator = function(metric, is3D) {
44711
+ var heap = new Heap(),
44712
+ prevBuf = utils.expandoBuffer(Int32Array),
44713
+ nextBuf = utils.expandoBuffer(Int32Array),
44714
+ calc = is3D ?
44715
+ function(b, c, d, xx, yy, zz) {
44716
+ return metric(xx[b], yy[b], zz[b], xx[c], yy[c], zz[c], xx[d], yy[d], zz[d]);
44717
+ } :
44718
+ function(b, c, d, xx, yy) {
44719
+ return metric(xx[b], yy[b], xx[c], yy[c], xx[d], yy[d]);
44720
+ };
44721
+
44722
+ // Calculate Visvalingam simplification data for an arc
44723
+ // @kk (Float64Array|Array) Receives calculated simplification thresholds
44724
+ // @xx, @yy, (@zz) Buffers containing vertex coordinates
44725
+ return function calcVisvalingam(kk, xx, yy, zz) {
44726
+ var arcLen = kk.length,
44727
+ prevArr = prevBuf(arcLen),
44728
+ nextArr = nextBuf(arcLen),
44729
+ val, maxVal = -Infinity,
44730
+ b, c, d; // indexes of points along arc
44731
+
44732
+ if (zz && !is3D) {
44733
+ error("[visvalingam] Received z-axis data for 2D simplification");
44734
+ } else if (!zz && is3D) {
44735
+ error("[visvalingam] Missing z-axis data for 3D simplification");
44736
+ } else if (kk.length > xx.length) {
44737
+ error("[visvalingam] Incompatible data arrays:", kk.length, xx.length);
44738
+ }
44739
+
44740
+ // Initialize Visvalingam "effective area" values and references to
44741
+ // prev/next points for each point in arc.
44742
+ for (c=0; c<arcLen; c++) {
44743
+ b = c-1;
44744
+ d = c+1;
44745
+ if (b < 0 || d >= arcLen) {
44746
+ val = Infinity; // endpoint maxVals
44747
+ } else {
44748
+ val = calc(b, c, d, xx, yy, zz);
44749
+ }
44750
+ kk[c] = val;
44751
+ nextArr[c] = d;
44752
+ prevArr[c] = b;
44753
+ }
44754
+ heap.init(kk);
44755
+
44756
+ // Calculate removal thresholds for each internal point in the arc
44757
+ //
44758
+ while (heap.size() > 0) {
44759
+ c = heap.pop(); // Remove the point with the least effective area.
44760
+ val = kk[c];
44761
+ if (val === Infinity) {
44762
+ break;
44763
+ }
44764
+ if (val < maxVal) {
44765
+ // don't assign current point a lesser value than the last removed vertex
44766
+ kk[c] = maxVal;
44767
+ } else {
44768
+ maxVal = val;
44769
+ }
44770
+
44771
+ // Recompute effective area of neighbors of the removed point.
44772
+ b = prevArr[c];
44773
+ d = nextArr[c];
44774
+ if (b > 0) {
44775
+ val = calc(prevArr[b], b, d, xx, yy, zz);
44776
+ heap.updateValue(b, val);
44777
+ }
44778
+ if (d < arcLen-1) {
44779
+ val = calc(b, d, nextArr[d], xx, yy, zz);
44780
+ heap.updateValue(d, val);
44781
+ }
44782
+ nextArr[b] = d;
44783
+ prevArr[d] = b;
44784
+ }
44785
+ };
44786
+ };
44787
+
44788
+ Visvalingam.standardMetric = geom.triangleArea;
44789
+ Visvalingam.standardMetric3D = geom.triangleArea3D;
44790
+
44791
+ Visvalingam.getWeightedMetric = function(opts) {
44792
+ var weight = Visvalingam.getWeightFunction(opts);
44793
+ return function(ax, ay, bx, by, cx, cy) {
44794
+ var area = geom.triangleArea(ax, ay, bx, by, cx, cy),
44795
+ cos = geom.cosine(ax, ay, bx, by, cx, cy);
44796
+ return weight(cos) * area;
44797
+ };
44798
+ };
44799
+
44800
+ Visvalingam.getWeightedMetric3D = function(opts) {
44801
+ var weight = Visvalingam.getWeightFunction(opts);
44802
+ return function(ax, ay, az, bx, by, bz, cx, cy, cz) {
44803
+ var area = geom.triangleArea3D(ax, ay, az, bx, by, bz, cx, cy, cz),
44804
+ cos = geom.cosine3D(ax, ay, az, bx, by, bz, cx, cy, cz);
44805
+ return weight(cos) * area;
44806
+ };
44807
+ };
44808
+
44809
+ Visvalingam.getWeightCoefficient = function(opts) {
44810
+ return opts && utils.isNumber(opts && opts.weighting) ? opts.weighting : 0.7;
44811
+ };
44812
+
44813
+ // Get a parameterized version of Visvalingam.weight()
44814
+ Visvalingam.getWeightFunction = function(opts) {
44815
+ var k = Visvalingam.getWeightCoefficient(opts);
44816
+ return function(cos) {
44817
+ return -cos * k + 1;
44818
+ };
44819
+ };
44820
+
44821
+ // Weight triangle area by inverse cosine
44822
+ // Standard weighting favors 90-deg angles; this curve peaks at 120 deg.
44823
+ Visvalingam.weight = function(cos) {
44824
+ var k = 0.7;
44825
+ return -cos * k + 1;
44826
+ };
44827
+
44828
+ Visvalingam.getEffectiveAreaSimplifier = function(use3D) {
44829
+ var metric = use3D ? Visvalingam.standardMetric3D : Visvalingam.standardMetric;
44830
+ return Visvalingam.getPathSimplifier(metric, use3D);
44831
+ };
44832
+
44833
+ Visvalingam.getWeightedSimplifier = function(opts, use3D) {
44834
+ var metric = use3D ? Visvalingam.getWeightedMetric3D(opts) : Visvalingam.getWeightedMetric(opts);
44835
+ return Visvalingam.getPathSimplifier(metric, use3D);
44836
+ };
44837
+
44838
+ Visvalingam.getPathSimplifier = function(metric, use3D) {
44839
+ return Visvalingam.scaledSimplify(Visvalingam.getArcCalculator(metric, use3D));
44840
+ };
44841
+
44842
+
44843
+ Visvalingam.scaledSimplify = function(f) {
44844
+ return function(kk, xx, yy, zz) {
44845
+ f(kk, xx, yy, zz);
44846
+ for (var i=1, n=kk.length - 1; i<n; i++) {
44847
+ // convert area metric to a linear equivalent
44848
+ kk[i] = Math.sqrt(kk[i]) * 0.65;
44849
+ }
44850
+ };
44851
+ };
44852
+
44853
+ // Build approximate inter-feature Voronoi (medial-axis) cut lines for the
44854
+ // topological polygon buffer. Where two features' buffers overlap, the
44855
+ // contested space should be partitioned by proximity to the source polygons;
44856
+ // the locus of points equidistant from two sources is a generalized Voronoi
44857
+ // boundary. We approximate it by sampling points along the source rings (one
44858
+ // label per feature) and emitting the dual Voronoi edges that separate sites of
44859
+ // different features.
44860
+ //
44861
+ // The returned lines are injected into the buffer mosaic as cut-lines: they
44862
+ // subdivide each contested tile along the equidistant boundary, and any portion
44863
+ // lying outside the buffers is pruned by the mosaic builder (detachAcyclicArcs).
44864
+ // Only the boundary between two features' final regions survives the per-feature
44865
+ // tile dissolve.
44866
+ //
44867
+ // @coordDistances: per-feature buffer distance in source-coordinate units (the
44868
+ // caller converts from meters via getCoordinateDistance), used both as the
44869
+ // densification scale and as the proximity prune (two sites can only be jointly
44870
+ // contested if they are within the sum of their features' radii).
44871
+
44872
+ // Baseline cap used to derive the spacing floor totalLen/MAX_SITES. On small
44873
+ // inputs this floor sits well below the buffer distance and adaptive sampling
44874
+ // works; it is the floor that keeps simple mosaics stable.
44875
+ var MAX_SITES = 60000;
44876
+
44877
+ // The spacing floor is also capped at FLOOR_DISTANCE_FRACTION of the buffer
44878
+ // distance. On a large mosaic totalLen/MAX_SITES grows until it approaches (or
44879
+ // exceeds) the buffer distance, which flattens the floor onto maxSpacing and
44880
+ // disables adaptive densification -- so the narrowest channels (a river between
44881
+ // two states) zig-zag. Capping the floor at a fraction of the buffer distance
44882
+ // guarantees adaptive headroom regardless of input size. The value matches the
44883
+ // floor/maxSpacing ratio at which small mosaics already sample cleanly.
44884
+ var FLOOR_DISTANCE_FRACTION = 0.1;
44885
+
44886
+ // Soft target total site count. coarsen scales the gap-proportional spacing up
44887
+ // until the predicted total falls under this, keeping the Delaunay bounded on
44888
+ // dense shared-border mosaics while leaving sparse inputs at coarsen=1 (fully
44889
+ // adaptive). Set above the site counts of typical large inputs so those stay
44890
+ // fully resolved.
44891
+ var SITE_BUDGET = 800000;
44892
+
44893
+ function buildInterFeatureMedialLines(shapes, coordDistances, arcs, opts) {
44894
+ opts = opts || {};
44895
+ profileStart('medial:collectSites');
44896
+ var sites = collectSites(shapes, coordDistances, arcs);
44897
+ profileEnd('medial:collectSites');
44898
+ if (!sites || sites.coords.length < 3) return null;
44899
+ if (profileEnabled()) {
44900
+ message('[medial] sample sites: ' + sites.coords.length);
44901
+ }
44902
+ profileStart('medial:computeSegments');
44903
+ var medial = computeMedialSegments(sites, coordDistances, sites.grid);
44904
+ profileEnd('medial:computeSegments');
44905
+ if (medial.segments.length === 0) return null;
44906
+ // Stitch the individual Voronoi edges (2-point segments that meet at shared
44907
+ // circumcenters) into maximal polylines so the medial axis can be simplified
44908
+ // and injected as connected paths rather than a swarm of tiny stubs.
44909
+ profileStart('medial:assembleChains');
44910
+ var chains = assembleChains(medial.segments, medial.coords);
44911
+ profileEnd('medial:assembleChains');
44912
+ if (opts.simplifyInterval > 0) {
44913
+ profileStart('medial:simplify');
44914
+ chains = chains.map(function(chain) {
44915
+ return simplifyChain(chain, opts.simplifyInterval);
44916
+ });
44917
+ profileEnd('medial:simplify');
44918
+ }
44919
+ return {
44920
+ type: 'MultiLineString',
44921
+ coordinates: chains
44922
+ };
44923
+ }
44924
+
44925
+ // Build the medial-construction triangles for the -buffer debug-delaunay option
44926
+ // as a GeometryCollection of triangle polygons. collectSites returns only the
44927
+ // contested sites, so the Delaunay is already the per-region mesh from which the
44928
+ // medial axis is derived; this keeps the triangles whose circumcenter is an
44929
+ // actual medial vertex. A triangle qualifies when it has a cross-feature edge
44930
+ // within buffer reach AND its circumcenter lies inside the overlap
44931
+ // (circumradius <= reach). The second test drops the long, thin triangles that
44932
+ // span a ribbon's concave bends: their circumcenters are wild and the medial
44933
+ // computation discards their segments, so showing them would just add spurious
44934
+ // spans. Returns null when nothing bridges two features.
44935
+ function buildInterFeatureDelaunay(shapes, coordDistances, arcs) {
44936
+ var sites = collectSites(shapes, coordDistances, arcs);
44937
+ if (!sites || sites.coords.length < 3) return null;
44938
+ var coords = sites.coords;
44939
+ var owner = sites.owner;
44940
+ var triangles = Delaunator.from(coords).triangles;
44941
+ var geometries = [];
44942
+ for (var i = 0; i < triangles.length; i += 3) {
44943
+ var ia = triangles[i], ib = triangles[i + 1], ic = triangles[i + 2];
44944
+ var reach = Math.max(
44945
+ bridgingReach(ia, ib, coords, owner, coordDistances),
44946
+ bridgingReach(ib, ic, coords, owner, coordDistances),
44947
+ bridgingReach(ic, ia, coords, owner, coordDistances));
44948
+ if (reach <= 0) continue; // no contested edge
44949
+ var a = coords[ia], b = coords[ib], c = coords[ic];
44950
+ var cc = circumcenter(a, b, c);
44951
+ if (!cc) continue; // degenerate (near-collinear)
44952
+ var rx = cc[0] - a[0], ry = cc[1] - a[1];
44953
+ if (rx * rx + ry * ry > reach * reach) continue; // wild circumcenter
44954
+ geometries.push({
44955
+ type: 'Polygon',
44956
+ coordinates: [[
44957
+ [a[0], a[1]], [b[0], b[1]], [c[0], c[1]], [a[0], a[1]]
44958
+ ]]
44959
+ });
44960
+ }
44961
+ if (geometries.length === 0) return null;
44962
+ return {type: 'GeometryCollection', geometries: geometries};
44963
+ }
44964
+
44965
+ // Buffer reach (sum of the two source radii) of edge (i, j) if its endpoints are
44966
+ // different features and close enough for their buffers to overlap -- the same
44967
+ // test computeMedialSegments uses to decide whether an edge's bisector is a
44968
+ // contested medial edge. Returns 0 when the edge does not bridge features.
44969
+ function bridgingReach(i, j, coords, owner, coordDistances) {
44970
+ if (owner[i] === owner[j]) return 0;
44971
+ var dx = coords[i][0] - coords[j][0];
44972
+ var dy = coords[i][1] - coords[j][1];
44973
+ var reach = coordDistances[owner[i]] + coordDistances[owner[j]];
44974
+ return Math.sqrt(dx * dx + dy * dy) <= reach ? reach : 0;
44975
+ }
44976
+
44977
+ // Stitch 2-point medial segments into maximal polylines. The medial network is
44978
+ // a graph whose vertices are the Delaunay triangles' circumcenters: every
44979
+ // segment endpoint is a vertex id indexing @coords, so adjacent edges that meet
44980
+ // at a shared circumcenter share an id directly -- no coordinate hashing needed.
44981
+ // Degree-2 vertices lie mid-path; degree-1 (hull-ray ends) and degree-3+ (where
44982
+ // 3+ features meet) vertices are junctions. Each returned chain runs between two
44983
+ // junctions (or around an isolated loop).
44984
+ function assembleChains(segments, coords) {
44985
+ var nodes = new Array(coords.length);
44986
+ function getNode(id) {
44987
+ var n = nodes[id];
44988
+ if (!n) { n = nodes[id] = {coord: coords[id], edges: []}; }
44989
+ return n;
44990
+ }
44991
+ var edges = segments.map(function(seg) {
44992
+ var a = getNode(seg[0]);
44993
+ var b = getNode(seg[1]);
44994
+ var edge = {a: a, b: b, used: false};
44995
+ a.edges.push(edge);
44996
+ b.edges.push(edge);
44997
+ return edge;
44998
+ });
44999
+ function other(edge, node) {
45000
+ return edge.a === node ? edge.b : edge.a;
45001
+ }
45002
+ function walk(start, firstEdge) {
45003
+ var chain = [start.coord];
45004
+ var node = start;
45005
+ var edge = firstEdge;
45006
+ while (true) {
45007
+ edge.used = true;
45008
+ node = other(edge, node);
45009
+ chain.push(node.coord);
45010
+ if (node.edges.length !== 2) break; // junction or dangling end
45011
+ var next = node.edges[0] === edge ? node.edges[1] : node.edges[0];
45012
+ if (next.used) break; // closed loop back to start
45013
+ edge = next;
45014
+ }
45015
+ return chain;
45016
+ }
45017
+ var chains = [];
45018
+ // Chains anchored at junctions / dangling ends first...
45019
+ for (var id = 0; id < nodes.length; id++) {
45020
+ var node = nodes[id];
45021
+ if (!node || node.edges.length === 2) continue;
45022
+ node.edges.forEach(function(edge) {
45023
+ if (!edge.used) chains.push(walk(node, edge));
45024
+ });
45025
+ }
45026
+ // ...then any remaining all-degree-2 loops.
45027
+ edges.forEach(function(edge) {
45028
+ if (!edge.used) chains.push(walk(edge.a, edge));
45029
+ });
45030
+ return chains;
45031
+ }
45032
+
45033
+ // Weighted-Visvalingam simplifier shared across chains. Its internal heap and
45034
+ // scratch buffers are reused per call, so a single instance is safe for the
45035
+ // sequential per-chain calls below.
45036
+ var medialSimplifier = Visvalingam.getWeightedSimplifier({}, false);
45037
+
45038
+ // Smooth a medial polyline with weighted Visvalingam, dropping vertices whose
45039
+ // effective area (expressed as a linear-equivalent by scaledSimplify) falls
45040
+ // below @interval. Endpoints carry an Infinity threshold and are always kept.
45041
+ function simplifyChain(points, interval) {
45042
+ var n = points.length;
45043
+ if (n < 3) return points;
45044
+ var xx = new Float64Array(n);
45045
+ var yy = new Float64Array(n);
45046
+ var kk = new Float64Array(n);
45047
+ for (var i = 0; i < n; i++) {
45048
+ xx[i] = points[i][0];
45049
+ yy[i] = points[i][1];
45050
+ }
45051
+ medialSimplifier(kk, xx, yy);
45052
+ var out = [];
45053
+ for (i = 0; i < n; i++) {
45054
+ if (kk[i] >= interval) out.push(points[i]);
45055
+ }
45056
+ return out.length >= 2 ? out : [points[0], points[n - 1]];
45057
+ }
45058
+
45059
+ // Boundary sample spacing as a fraction of the local gap width: smaller gives a
45060
+ // smoother medial axis (more sites) in narrow channels. 0.5 keeps the spacing
45061
+ // at most half the gap, so a channel of width w has at least two samples per
45062
+ // bank across it.
45063
+ var GAP_FACTOR = 0.5;
45064
+
45065
+ // Gaps narrower than this fraction of the buffer distance are treated as
45066
+ // "touching" (at or below the buffer's positional tolerance, ~1%): no medial is
45067
+ // densified there, since the shared source boundary already partitions the
45068
+ // overlap. This keeps coincident mosaic borders from flooding the triangulation
45069
+ // with collinear sites while leaving real channels (the Columbia is ~3% of the
45070
+ // buffer distance) fully sampled.
45071
+ var TOUCHING_GAP_FRACTION = 0.002;
45072
+
45073
+ // Boundary arcs that could bound a gap, found from the layer topology: in a
45074
+ // shared-arc polygon mosaic an interior border between two features is one arc
45075
+ // used once forward and once reversed, so the source boundary already
45076
+ // partitions any buffer overlap there and it needs no medial. An arc used in
45077
+ // only one direction is an external boundary, an inlet edge, or a hole edge --
45078
+ // the only places a gap can be. Pruning the shared borders here drops the bulk
45079
+ // of a dense mosaic (most county/state borders are shared) before any distance
45080
+ // work; keptSites' distance test still separates the real gaps from the open
45081
+ // external boundary, so inputs whose coincident borders are NOT shared arcs
45082
+ // (each polygon carries its own copy) still come out correct, just less pruned.
45083
+ // Returns one open path per candidate arc, tagged with its owner feature.
45084
+ function collectCandidateArcPaths(shapes, coordDistances, arcs) {
45085
+ var n = arcs.size();
45086
+ var fwd = new Int32Array(n).fill(-1);
45087
+ var rev = new Int32Array(n).fill(-1);
45088
+ for (var s = 0; s < shapes.length; s++) {
45089
+ var shape = shapes[s];
45090
+ if (!shape || !(coordDistances[s] > 0)) continue;
45091
+ for (var p = 0; p < shape.length; p++) {
45092
+ var ids = shape[p];
45093
+ for (var k = 0; k < ids.length; k++) {
45094
+ var id = ids[k];
45095
+ if (id < 0) { if (rev[~id] === -1) rev[~id] = s; }
45096
+ else if (fwd[id] === -1) fwd[id] = s;
45097
+ }
45098
+ }
45099
+ }
45100
+ var paths = [];
45101
+ for (var i = 0; i < n; i++) {
45102
+ var f = fwd[i], r = rev[i];
45103
+ if (f === -1 && r === -1) continue; // arc not used by a buffered feature
45104
+ if (f !== -1 && r !== -1) continue; // shared interior border -- not a gap
45105
+ var pts = arcCoords(arcs, i);
45106
+ if (pts.length >= 2) paths.push({owner: f !== -1 ? f : r, points: pts});
45107
+ }
45108
+ return paths;
45109
+ }
45110
+
45111
+ function arcCoords(arcs, arcId) {
45112
+ var iter = arcs.getArcIter(arcId);
45113
+ var pts = [];
45114
+ while (iter.hasNext()) pts.push([iter.x, iter.y]);
45115
+ return pts;
45116
+ }
45117
+
45118
+ // Gather labeled Voronoi sites from the gap-candidate boundary arcs (see
45119
+ // collectCandidateArcPaths) of the buffered features.
45120
+ //
45121
+ // Those arcs are sampled adaptively: where two features run close together
45122
+ // (e.g. the opposite banks of a narrow river) the boundary is sampled finely so
45123
+ // the medial axis tracks the channel centerline instead of zigzagging between
45124
+ // the banks; where features are far apart the spacing relaxes to the buffer
45125
+ // distance. The local gap width is measured directly to the candidate boundary
45126
+ // segments (computeVertexGaps), driving the densification in a single pass.
45127
+ function collectSites(shapes, coordDistances, arcs) {
45128
+ if (!arcs) return null;
45129
+ var paths = collectCandidateArcPaths(shapes, coordDistances, arcs);
45130
+ if (paths.length < 2) return null;
45131
+
45132
+ // Assign every candidate vertex a stable id (vid) so a per-vertex gap can be
45133
+ // looked up while densifying its segments.
45134
+ var verts = buildVertexLayout(paths);
45135
+ if (verts.count < 2) return null;
45136
+ var totalLen = ringsLength(paths.map(function(p) { return p.points; }));
45137
+ // Spacing floor: totalLen/MAX_SITES is the simple-mosaic floor (keeps small
45138
+ // inputs stable and near-coincident borders from over-sampling), but it is
45139
+ // capped at a fraction of the buffer distance so a large mosaic keeps adaptive
45140
+ // headroom instead of flattening onto maxSpacing. When the cap binds, fitCoarsen
45141
+ // is what bounds the actual site total.
45142
+ var maxDistance = 0;
45143
+ for (var ci = 0; ci < coordDistances.length; ci++) {
45144
+ if (coordDistances[ci] > maxDistance) maxDistance = coordDistances[ci];
45145
+ }
45146
+ var spacingFloor = Math.min(totalLen / MAX_SITES,
45147
+ maxDistance * FLOOR_DISTANCE_FRACTION);
45148
+
45149
+ // Sample spacing is proportional to the local gap (gap * GAP_FACTOR * coarsen),
45150
+ // floored at spacingFloor and capped at the buffer distance, so the narrowest
45151
+ // channels (a river between two states) get the finest sampling and wide
45152
+ // overlaps the coarsest. coarsen scales the whole distribution up just enough
45153
+ // to fit the site budget on dense mosaics (counties), keeping narrow channels
45154
+ // proportionally finer than wide ones; on sparse inputs it stays 1 (fully
45155
+ // adaptive). The per-vertex gap is computed directly from the boundary geometry
45156
+ // in a single pass, then we densify once.
45157
+ var grid = buildSegmentGrid(verts, coordDistances);
45158
+ var gaps = computeVertexGaps(grid, verts, coordDistances);
45159
+ var coarsen = fitCoarsen(verts, gaps, coordDistances, spacingFloor);
45160
+ var sites = densifyVertices(verts, gaps, coordDistances, spacingFloor, coarsen);
45161
+ // Triangulate only the sites bordering a real gap. Its medial segments come
45162
+ // exclusively from cross-feature edges, and the well-shaped triangles that
45163
+ // bridge a gap have their apex within reach too (a far apex makes a thin
45164
+ // triangle whose wild circumcenter is filtered out, or a hull edge that is
45165
+ // extrapolated as an outward ray). Dropping the touching interior borders and
45166
+ // the no-feature coastline shrinks the one remaining Delaunay and avoids
45167
+ // building a redundant medial where the source boundary already partitions.
45168
+ var kept = keptSites(sites, grid, coordDistances);
45169
+ // Keep the segment grid with the sites so computeMedialSegments can re-measure
45170
+ // the true source gap when the sample-pair proximity test is too coarse.
45171
+ kept.grid = grid;
45172
+ return kept;
45173
+ }
45174
+
45175
+ // Bucket every boundary segment into a uniform grid so the nearest cross-feature
45176
+ // segment to an arbitrary point can be found by probing its 3x3 cell
45177
+ // neighborhood. The cell equals the maximum reach (sum of the two largest buffer
45178
+ // distances), so any in-reach segment is guaranteed to fall in that 3x3 window.
45179
+ // Returns null when there is no positive reach. Reused for both the per-vertex
45180
+ // gap (drives adaptive sampling) and the per-site keep test (gapAtPoint).
45181
+ function buildSegmentGrid(verts, coordDistances) {
45182
+ var paths = verts.paths;
45183
+ var maxDist = 0;
45184
+ for (var d = 0; d < coordDistances.length; d++) {
45185
+ if (coordDistances[d] > maxDist) maxDist = coordDistances[d];
45186
+ }
45187
+ var cell = 2 * maxDist; // upper bound on any pair's reach
45188
+ if (!(cell > 0)) return null;
45189
+ var xmin = Infinity, ymin = Infinity, ymax = -Infinity;
45190
+ paths.forEach(function(path) {
45191
+ var pts = path.points;
45192
+ for (var i = 0; i < pts.length; i++) {
45193
+ if (pts[i][0] < xmin) xmin = pts[i][0];
45194
+ if (pts[i][1] < ymin) ymin = pts[i][1];
45195
+ if (pts[i][1] > ymax) ymax = pts[i][1];
45196
+ }
45197
+ });
45198
+ // +1 cell index shift keeps probed -1 neighbors non-negative; rowSpan packs
45199
+ // (col, row) into a collision-free integer key.
45200
+ var rowSpan = Math.floor((ymax - ymin) / cell) + 3;
45201
+ function cellKey(cx, cy) { return (cx + 1) * rowSpan + (cy + 1); }
45202
+ function colOf(x) { return Math.floor((x - xmin) / cell); }
45203
+ function rowOf(y) { return Math.floor((y - ymin) / cell); }
45204
+ var seg = {x0: [], y0: [], x1: [], y1: [], feat: [], reach: []};
45205
+ var grid = new Map();
45206
+ paths.forEach(function(path) {
45207
+ var pts = path.points;
45208
+ var feat = path.owner;
45209
+ var reach = coordDistances[feat];
45210
+ for (var k = 0; k + 1 < pts.length; k++) {
45211
+ var ax = pts[k][0], ay = pts[k][1], bx = pts[k + 1][0], by = pts[k + 1][1];
45212
+ var idx = seg.feat.length;
45213
+ seg.x0.push(ax); seg.y0.push(ay); seg.x1.push(bx); seg.y1.push(by);
45214
+ seg.feat.push(feat); seg.reach.push(reach);
45215
+ var cxa = colOf(Math.min(ax, bx)), cxb = colOf(Math.max(ax, bx));
45216
+ var cya = rowOf(Math.min(ay, by)), cyb = rowOf(Math.max(ay, by));
45217
+ for (var gx = cxa; gx <= cxb; gx++) {
45218
+ for (var gy = cya; gy <= cyb; gy++) {
45219
+ var key = cellKey(gx, gy);
45220
+ var bucket = grid.get(key);
45221
+ if (bucket) bucket.push(idx); else grid.set(key, [idx]);
45222
+ }
45223
+ }
45224
+ }
45225
+ });
45226
+ return {seg: seg, grid: grid, cellKey: cellKey, colOf: colOf, rowOf: rowOf};
45227
+ }
45228
+
45229
+ // The channel width at (x, y): distance to the nearest different-feature segment
45230
+ // within their combined reach, or Infinity if none. Works for any point, not
45231
+ // just original vertices, so a long edge whose endpoints are out of reach but
45232
+ // whose middle crosses a gap is still measured correctly at the interior sites.
45233
+ function gapAtPoint(ctx, x, y, feat, reachF) {
45234
+ return nearestCrossFeatureSegmentDist(x, y, feat, reachF, ctx.seg, ctx.grid,
45235
+ ctx.cellKey, ctx.colOf, ctx.rowOf, Infinity);
45236
+ }
45237
+
45238
+ // Local gap at each original vertex (drives adaptive sampling, see
45239
+ // segmentSpacing). Measuring straight to the boundary segments yields the true
45240
+ // gap in a single pass, replacing the old triangulate -> estimate-gap ->
45241
+ // re-densify refinement loop that existed only because a coarse sampling can't
45242
+ // see a narrow gap (its nearest cross-feature SAMPLE is far).
45243
+ function computeVertexGaps(ctx, verts, coordDistances) {
45244
+ profileStart('medial:segmentGaps');
45245
+ var gaps = filledArray(verts.count, Infinity);
45246
+ if (ctx) {
45247
+ verts.paths.forEach(function(path) {
45248
+ var pts = path.points;
45249
+ var vids = path.vids;
45250
+ var feat = path.owner;
45251
+ var reachF = coordDistances[feat];
45252
+ for (var k = 0; k < vids.length; k++) {
45253
+ var g = gapAtPoint(ctx, pts[k][0], pts[k][1], feat, reachF);
45254
+ if (g < gaps[vids[k]]) gaps[vids[k]] = g;
45255
+ }
45256
+ });
45257
+ }
45258
+ profileEnd('medial:segmentGaps');
45259
+ return gaps;
45260
+ }
45261
+
45262
+ // Distance from (vx, vy) (owned by @feat, reach @reachF) to the nearest
45263
+ // different-feature segment within their combined reach, or @best if none is
45264
+ // closer. Probes the 3x3 grid-cell neighborhood (cell == max reach).
45265
+ function nearestCrossFeatureSegmentDist(vx, vy, feat, reachF, seg, grid, cellKey,
45266
+ colOf, rowOf, best) {
45267
+ var cx = colOf(vx), cy = rowOf(vy);
45268
+ for (var gx = cx - 1; gx <= cx + 1; gx++) {
45269
+ for (var gy = cy - 1; gy <= cy + 1; gy++) {
45270
+ var bucket = grid.get(cellKey(gx, gy));
45271
+ if (!bucket) continue;
45272
+ for (var b = 0; b < bucket.length; b++) {
45273
+ var s = bucket[b];
45274
+ if (seg.feat[s] === feat) continue;
45275
+ var reach = reachF + seg.reach[s];
45276
+ var dsq = pointSegDistSq2(vx, vy, seg.x0[s], seg.y0[s], seg.x1[s], seg.y1[s]);
45277
+ if (dsq <= reach * reach) {
45278
+ var dist = Math.sqrt(dsq);
45279
+ if (dist < best) best = dist;
45280
+ }
45281
+ }
45282
+ }
45283
+ }
45284
+ return best;
45285
+ }
45286
+
45287
+ // True when medial vertex c lies in the buffer overlap of features fp and fq:
45288
+ // within fp's radius of an fp-owned source segment AND within fq's radius of an
45289
+ // fq-owned source segment. Measured against the actual source segments via the
45290
+ // grid, so it is correct regardless of how coarsely the banks were sampled --
45291
+ // unlike the sample-pair distance, which overestimates the gap when the nearest
45292
+ // samples on opposite banks are staggered or far from the true closest approach.
45293
+ // Slack on each reach when rescuing a cross-feature edge whose sample endpoints
45294
+ // fell outside the cheap proximity test. It absorbs the discretization of the
45295
+ // medial graph near a pinch point: the connecting Voronoi edge is bounded by the
45296
+ // site spacing (capped at the buffer distance), so a genuinely contested edge can
45297
+ // run up to ~1.5x reach and its medial vertices can land a similar fraction
45298
+ // outside the overlap. 1.3 covers the worst real case observed (~1.18) with
45299
+ // headroom, while spurious edges between sites contested with *other* features
45300
+ // miss by far more (>=1.5 or have no nearby source segment) and stay pruned.
45301
+ var MEDIAL_OVERLAP_SLACK = 1.3;
45302
+
45303
+ function medialVertexInOverlap(ctx, c, fp, fq, rp, rq) {
45304
+ var sp = rp * MEDIAL_OVERLAP_SLACK, sq = rq * MEDIAL_OVERLAP_SLACK;
45305
+ return pointFeatureDistSq(ctx, c[0], c[1], fp) <= sp * sp &&
45306
+ pointFeatureDistSq(ctx, c[0], c[1], fq) <= sq * sq;
45307
+ }
45308
+
45309
+ // Squared distance from (x, y) to the nearest segment owned by feature @feat,
45310
+ // probing the 3x3 grid-cell neighborhood (cell == max reach, so any segment
45311
+ // within a single feature's radius is in the window). Infinity if none.
45312
+ function pointFeatureDistSq(ctx, x, y, feat) {
45313
+ var seg = ctx.seg, grid = ctx.grid;
45314
+ var cx = ctx.colOf(x), cy = ctx.rowOf(y);
45315
+ var best = Infinity;
45316
+ for (var gx = cx - 1; gx <= cx + 1; gx++) {
45317
+ for (var gy = cy - 1; gy <= cy + 1; gy++) {
45318
+ var bucket = grid.get(ctx.cellKey(gx, gy));
45319
+ if (!bucket) continue;
45320
+ for (var b = 0; b < bucket.length; b++) {
45321
+ var s = bucket[b];
45322
+ if (seg.feat[s] !== feat) continue;
45323
+ var d2 = pointSegDistSq2(x, y, seg.x0[s], seg.y0[s], seg.x1[s], seg.y1[s]);
45324
+ if (d2 < best) best = d2;
45325
+ }
45326
+ }
45327
+ }
45328
+ return best;
45329
+ }
45330
+
45331
+ // Keep only the sites that border a real gap: a different feature within reach
45332
+ // (finite gap) but farther than the touching threshold. These are the only sites
45333
+ // that can shape the medial axis. Touching/coincident interior borders (gap ~ 0,
45334
+ // the shared source boundary already partitions them) and the no-feature
45335
+ // coastline (gap = Infinity) are dropped, so the Delaunay covers just the
45336
+ // genuine gaps -- no triangulation is wasted on borders that need no medial.
45337
+ // The gap is measured per site (not per vertex) so a long edge whose endpoints
45338
+ // fall out of reach but whose middle crosses a gap keeps its interior points.
45339
+ function keptSites(sites, ctx, coordDistances) {
45340
+ profileStart('medial:contested');
45341
+ var result = {coords: [], owner: [], origin: []};
45342
+ if (!ctx) {
45343
+ profileEnd('medial:contested');
45344
+ return result;
45345
+ }
45346
+ var coords = sites.coords, owner = sites.owner, origin = sites.origin;
45347
+ for (var i = 0; i < coords.length; i++) {
45348
+ var feat = owner[i];
45349
+ var reach = coordDistances[feat];
45350
+ var g = gapAtPoint(ctx, coords[i][0], coords[i][1], feat, reach);
45351
+ if (isFinite(g) && g > reach * TOUCHING_GAP_FRACTION) {
45352
+ result.coords.push(coords[i]);
45353
+ result.owner.push(feat);
45354
+ result.origin.push(origin[i]);
45355
+ }
45356
+ }
45357
+ profileEnd('medial:contested');
45358
+ return result;
45359
+ }
45360
+
45361
+ function ringsLength(rings) {
45362
+ var sum = 0;
45363
+ rings.forEach(function(points) {
45364
+ for (var i = 1; i < points.length; i++) {
45365
+ var dx = points[i][0] - points[i - 1][0];
45366
+ var dy = points[i][1] - points[i - 1][1];
45367
+ sum += Math.sqrt(dx * dx + dy * dy);
45368
+ }
45369
+ });
45370
+ return sum;
45371
+ }
45372
+
45373
+ function filledArray(n, v) {
45374
+ var a = new Float64Array(n);
45375
+ a.fill(v);
45376
+ return a;
45377
+ }
45378
+
45379
+ // Flatten the candidate arc paths into a vertex layout: one entry per path
45380
+ // carrying its owner feature, its points, and a stable id (vid) for each vertex,
45381
+ // so densifyVertices can re-sample using a per-vid gap estimate. Each candidate
45382
+ // arc is an open polyline (every coordinate is a vertex, no wrap-around); a
45383
+ // closed ring made of a single arc arrives with its first point repeated at the
45384
+ // end, so treating it as open still covers the full loop.
45385
+ function buildVertexLayout(paths) {
45386
+ var layout = [];
45387
+ var count = 0;
45388
+ paths.forEach(function(path) {
45389
+ var points = path.points;
45390
+ var m = points.length;
45391
+ if (m < 2) return;
45392
+ var vids = [];
45393
+ for (var i = 0; i < m; i++) vids.push(count++);
45394
+ layout.push({owner: path.owner, points: points, vids: vids});
45395
+ });
45396
+ return {paths: layout, count: count};
45397
+ }
45398
+
45399
+ // The spacing for a path segment: the tighter of its two endpoints' gap-derived
45400
+ // spacings (so a segment straddling a narrowing gap samples at the finer rate).
45401
+ function segmentSpacing(path, k, gaps, maxSpacing, spacingFloor, coarsen) {
45402
+ var sA = spacingFromGap(gaps[path.vids[k]], maxSpacing, spacingFloor, coarsen);
45403
+ var sB = spacingFromGap(gaps[path.vids[k + 1]], maxSpacing, spacingFloor, coarsen);
45404
+ return Math.min(sA, sB);
45405
+ }
45406
+
45407
+ // Re-sample every candidate path: emit each original vertex (tagged with its
45408
+ // vid) plus interior points spaced by the local gap-derived spacing (see
45409
+ // segmentSpacing). Paths are open, so the last vertex has no following segment.
45410
+ // Long edges are densified even where their endpoints are out of reach, so a
45411
+ // contested middle is sampled; keptSites later prunes the points that turn out
45412
+ // not to border a real gap.
45413
+ function densifyVertices(verts, gaps, coordDistances, spacingFloor, coarsen) {
45414
+ var coords = [];
45415
+ var owner = [];
45416
+ var origin = []; // vid for original vertices, -1 for interpolated points
45417
+ verts.paths.forEach(function(path) {
45418
+ var maxSpacing = coordDistances[path.owner];
45419
+ var m = path.vids.length;
45420
+ for (var k = 0; k < m; k++) {
45421
+ var a = path.points[k];
45422
+ coords.push([a[0], a[1]]);
45423
+ owner.push(path.owner);
45424
+ origin.push(path.vids[k]);
45425
+ if (k + 1 >= m) continue; // open path: no segment past the last vertex
45426
+ var b = path.points[k + 1];
45427
+ var s = segmentSpacing(path, k, gaps, maxSpacing, spacingFloor, coarsen);
45428
+ var dx = b[0] - a[0], dy = b[1] - a[1];
45429
+ var len = Math.sqrt(dx * dx + dy * dy);
45430
+ if (s > 0 && len > s) {
45431
+ var steps = Math.floor(len / s);
45432
+ for (var t = 1; t <= steps; t++) {
45433
+ var f = t / (steps + 1);
45434
+ coords.push([a[0] + dx * f, a[1] + dy * f]);
45435
+ owner.push(path.owner);
45436
+ origin.push(-1);
45437
+ }
45438
+ }
45439
+ }
45440
+ });
45441
+ return {coords: coords, owner: owner, origin: origin};
45442
+ }
45443
+
45444
+ function spacingFromGap(gap, maxSpacing, spacingFloor, coarsen) {
45445
+ if (!isFinite(gap)) return maxSpacing;
45446
+ // A gap at or below the buffer's positional tolerance means the two features
45447
+ // effectively touch: there is no contested channel to run a medial down, and
45448
+ // the shared source boundary already partitions the overlap. Densifying it
45449
+ // would only flood a coincident border with collinear sites (millions of them
45450
+ // on a clean topological mosaic), so leave it at the coarse spacing.
45451
+ if (gap < maxSpacing * TOUCHING_GAP_FRACTION) return maxSpacing;
45452
+ var s = gap * GAP_FACTOR * coarsen;
45453
+ if (s > maxSpacing) s = maxSpacing;
45454
+ if (s < spacingFloor) s = spacingFloor;
45455
+ return s;
45456
+ }
45457
+
45458
+ // Predicted total site count for a given coarsen, matching densifyVertices'
45459
+ // emission rule exactly (one site per original vertex plus floor(len/spacing)
45460
+ // interior points per segment). Pure counting, no Delaunay -- cheap enough to
45461
+ // binary-search coarsen against. Counts pre-keep sites (the densification work),
45462
+ // which is what coarsen actually bounds.
45463
+ function predictSiteCount(verts, gaps, coordDistances, spacingFloor, coarsen) {
45464
+ var total = verts.count; // every original vertex is emitted
45465
+ verts.paths.forEach(function(path) {
45466
+ var maxSpacing = coordDistances[path.owner];
45467
+ var m = path.vids.length;
45468
+ for (var k = 0; k + 1 < m; k++) {
45469
+ var s = segmentSpacing(path, k, gaps, maxSpacing, spacingFloor, coarsen);
45470
+ var a = path.points[k];
45471
+ var b = path.points[k + 1];
45472
+ var dx = b[0] - a[0], dy = b[1] - a[1];
45473
+ var len = Math.sqrt(dx * dx + dy * dy);
45474
+ if (s > 0 && len > s) total += Math.floor(len / s);
45475
+ }
45476
+ });
45477
+ return total;
45478
+ }
45479
+
45480
+ // Smallest coarsen (>= 1) whose predicted site count fits SITE_BUDGET. Site
45481
+ // count decreases monotonically as coarsen grows (spacing widens), so binary
45482
+ // search converges; capped because near-coincident gaps (gap ~ 0) can't be
45483
+ // thinned by coarsen and are bounded by spacingFloor instead.
45484
+ function fitCoarsen(verts, gaps, coordDistances, spacingFloor) {
45485
+ if (predictSiteCount(verts, gaps, coordDistances, spacingFloor, 1) <= SITE_BUDGET) {
45486
+ return 1;
45487
+ }
45488
+ var lo = 1, hi = 1024;
45489
+ if (predictSiteCount(verts, gaps, coordDistances, spacingFloor, hi) > SITE_BUDGET) {
45490
+ return hi; // even fully coarsened we can't fit; accept the floor-bounded count
45491
+ }
45492
+ for (var i = 0; i < 20; i++) {
45493
+ var mid = (lo + hi) / 2;
45494
+ if (predictSiteCount(verts, gaps, coordDistances, spacingFloor, mid) > SITE_BUDGET) {
45495
+ lo = mid;
45496
+ } else {
45497
+ hi = mid;
45498
+ }
45499
+ }
45500
+ return hi;
45501
+ }
45502
+
45503
+ function nextHalfedge(e) {
45504
+ return e % 3 === 2 ? e - 2 : e + 1;
45505
+ }
45506
+
45507
+ function triangleOfEdge(e) {
45508
+ return Math.floor(e / 3);
45509
+ }
45510
+
45511
+ function computeMedialSegments(sites, coordDistances, ctx) {
45512
+ var coords = sites.coords;
45513
+ var owner = sites.owner;
45514
+ profileStart('medial:delaunay');
45515
+ var del = Delaunator.from(coords);
45516
+ profileEnd('medial:delaunay');
45517
+ var triangles = del.triangles;
45518
+ var halfedges = del.halfedges;
45519
+ var ntri = triangles.length / 3;
45520
+ // Medial-graph vertex coords, indexed by id. Triangle t's circumcenter is
45521
+ // vertex id t (so the three medial edges meeting at it share that id without
45522
+ // coordinate hashing); hull-ray ends are appended with fresh ids.
45523
+ var verts = new Array(ntri);
45524
+ var i;
45525
+ for (i = 0; i < ntri; i++) {
45526
+ verts[i] = circumcenter(
45527
+ coords[triangles[3 * i]],
45528
+ coords[triangles[3 * i + 1]],
45529
+ coords[triangles[3 * i + 2]]);
45530
+ }
45531
+ var segments = [];
45532
+ for (var e = 0; e < triangles.length; e++) {
45533
+ var opp = halfedges[e];
45534
+ var p = triangles[e];
45535
+ var q = triangles[nextHalfedge(e)];
45536
+ var fp = owner[p], fq = owner[q];
45537
+ if (fp === fq) continue;
45538
+ var dx = coords[p][0] - coords[q][0];
45539
+ var dy = coords[p][1] - coords[q][1];
45540
+ var siteDist = Math.sqrt(dx * dx + dy * dy);
45541
+ var rp = coordDistances[fp], rq = coordDistances[fq];
45542
+ var reach = rp + rq;
45543
+ var t1 = triangleOfEdge(e);
45544
+ var c1 = verts[t1];
45545
+ if (!c1) continue; // degenerate (near-collinear) triangle
45546
+ // Sites within the sum of their radii are accepted directly; this is the
45547
+ // common, cheap case. When they are farther apart, the bisector might still
45548
+ // be contested -- the nearest sample pair overestimates the true source gap
45549
+ // where banks are sampled coarsely or staggered. Re-measure the actual gap
45550
+ // at the medial vertex against the source segments (the grid) and rescue the
45551
+ // edge if it really lies in the buffer overlap. Without the rescue the medial
45552
+ // axis fragments at such spots, leaving the equidistant cut wall open so the
45553
+ // overlap face is never subdivided and a whole contested corridor is assigned
45554
+ // to one feature (a feature wrapping a neighbor's enclosed island).
45555
+ var near = siteDist <= reach;
45556
+ if (opp === -1) {
45557
+ if (!near && !(ctx && medialVertexInOverlap(ctx, c1, fp, fq, rp, rq))) continue;
45558
+ // Hull edge: the Voronoi edge here is an unbounded ray (the bisector of
45559
+ // two sites on the convex hull). Emit it as an outward ray from the
45560
+ // circumcenter so the medial line reaches and crosses the buffer
45561
+ // boundary -- otherwise an interior medial segment that ends at this
45562
+ // circumcenter would dangle inside a tile and be pruned, leaving no cut.
45563
+ // The excess outside the buffers is trimmed by detachAcyclicArcs.
45564
+ var third = coords[triangles[nextHalfedge(nextHalfedge(e))]];
45565
+ var end = outwardRayEnd(c1, coords[p], coords[q], third, reach);
45566
+ if (end) {
45567
+ var rayId = verts.length;
45568
+ verts.push(end);
45569
+ segments.push([t1, rayId]);
45570
+ }
45571
+ continue;
45572
+ }
45573
+ // interior edge: emit once (at the lower halfedge index)
45574
+ if (opp < e) continue;
45575
+ var t2 = triangleOfEdge(opp);
45576
+ var c2 = verts[t2];
45577
+ if (!c2) continue;
45578
+ if (!near && !(ctx &&
45579
+ (medialVertexInOverlap(ctx, c1, fp, fq, rp, rq) ||
45580
+ medialVertexInOverlap(ctx, c2, fp, fq, rp, rq)))) continue;
45581
+ var sx = c1[0] - c2[0], sy = c1[1] - c2[1];
45582
+ var segLen = Math.sqrt(sx * sx + sy * sy);
45583
+ // a real medial edge inside the overlap is short (on the order of the site
45584
+ // spacing plus the gap); a very long segment comes from a near-degenerate
45585
+ // triangle whose circumcenter is wild, so drop it
45586
+ if (segLen > 3 * (reach + siteDist)) continue;
45587
+ segments.push([t1, t2]);
45588
+ }
45589
+ return {segments: segments, coords: verts};
45590
+ }
45591
+
45592
+ // Endpoint of the outward Voronoi ray for a hull edge (p, q) whose triangle's
45593
+ // third vertex is @third: starts at the circumcenter @c, runs along the edge's
45594
+ // perpendicular bisector, away from @third (outward), a length proportional to
45595
+ // the buffer reach so it clears the buffer boundary.
45596
+ function outwardRayEnd(c, p, q, third, reach) {
45597
+ var ex = q[0] - p[0], ey = q[1] - p[1];
45598
+ var nx = -ey, ny = ex; // a normal to the edge
45599
+ var mx = (p[0] + q[0]) / 2, my = (p[1] + q[1]) / 2;
45600
+ // orient the normal away from the third vertex (outward from the hull)
45601
+ if (nx * (mx - third[0]) + ny * (my - third[1]) < 0) {
45602
+ nx = -nx;
45603
+ ny = -ny;
45604
+ }
45605
+ var len = Math.sqrt(nx * nx + ny * ny);
45606
+ if (len === 0 || !isFinite(len)) return null;
45607
+ var L = 3 * reach;
45608
+ return [c[0] + nx / len * L, c[1] + ny / len * L];
45609
+ }
45610
+
45611
+ function circumcenter(a, b, c) {
45612
+ var ax = a[0], ay = a[1], bx = b[0], by = b[1], cx = c[0], cy = c[1];
45613
+ var d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
45614
+ if (d === 0 || !isFinite(d)) return null;
45615
+ var a2 = ax * ax + ay * ay;
45616
+ var b2 = bx * bx + by * by;
45617
+ var c2 = cx * cx + cy * cy;
45618
+ var ux = (a2 * (by - cy) + b2 * (cy - ay) + c2 * (ay - by)) / d;
45619
+ var uy = (a2 * (cx - bx) + b2 * (ax - cx) + c2 * (bx - ax)) / d;
45620
+ if (!isFinite(ux) || !isFinite(uy)) return null;
45621
+ return [ux, uy];
45622
+ }
45623
+
45624
+ // Remove small-area polygon rings (very simple implementation of sliver removal)
45625
+ // TODO: more sophisticated sliver detection (e.g. could consider ratio of area to perimeter)
45626
+ // TODO: consider merging slivers into adjacent polygons to prevent gaps from forming
45627
+ // TODO: consider separate gap removal function as an alternative to merging slivers
45628
+ //
45629
+ cmd.filterSlivers = function(lyr, dataset, opts) {
45630
+ if (lyr.geometry_type != 'polygon') {
45631
+ return 0;
45632
+ }
45633
+ return filterSlivers(lyr, dataset, opts);
45634
+ };
45635
+
45636
+ function filterSlivers(lyr, dataset, optsArg) {
45637
+ var opts = utils.extend({sliver_control: 1}, optsArg);
45638
+ var filterData = getSliverFilter(lyr, dataset, opts);
45639
+ var ringTest = filterData.filter;
45640
+ var removed = 0;
45641
+ var pathFilter = function(path, i, paths) {
45642
+ if (ringTest(path, i, paths)) {
45643
+ removed++;
45644
+ return null;
45645
+ }
45646
+ };
45647
+
45648
+ noteLayerWillChange(lyr, {operation: 'filter-slivers', unit: 'shapes'});
45649
+ editShapes(lyr.shapes, pathFilter);
45650
+ markLayerChanged(lyr, {operation: 'filter-slivers', unit: 'shapes'});
45651
+ message(utils.format("Removed %'d sliver%s using %s", removed, utils.pluralSuffix(removed), filterData.label));
45652
+
45653
+ // Remove null shapes (likely removed by clipping/erasing, although possibly already present)
45654
+ if (opts.remove_empty) {
45655
+ cmd.filterFeatures(lyr, dataset.arcs, {remove_empty: true, verbose: false});
45656
+ }
45657
+ return removed;
45658
+ }
45659
+
45660
+ function filterClipSlivers(lyr, clipLyr, arcs) {
45661
+ var threshold = getDefaultSliverThreshold(lyr, arcs);
45662
+ // message('Using variable sliver threshold (based on ' + (threshold / 1e6) + ' sqkm)');
45663
+ var ringTest = getSliverTest(arcs, threshold, 1);
45664
+ var flags = new Uint8Array(arcs.size());
45665
+ var removed = 0;
45666
+ var pathFilter = function(path) {
45667
+ var prevArcs = 0,
45668
+ newArcs = 0;
45669
+ for (var i=0, n=path && path.length || 0; i<n; i++) {
45670
+ if (flags[absArcId(path[i])] > 0) {
45671
+ newArcs++;
45672
+ } else {
45673
+ prevArcs++;
45674
+ }
45675
+ }
45676
+ // filter paths that contain arcs from both original and clip/erase layers
45677
+ // and are small
45678
+ if (newArcs > 0 && prevArcs > 0 && ringTest(path)) {
45679
+ removed++;
45680
+ return null;
45681
+ }
45682
+ };
45683
+
45684
+ countArcsInShapes(clipLyr.shapes, flags);
45685
+ noteLayerWillChange(lyr, {operation: 'filter-clip-slivers', unit: 'shapes'});
45686
+ editShapes(lyr.shapes, pathFilter);
45687
+ markLayerChanged(lyr, {operation: 'filter-clip-slivers', unit: 'shapes'});
45688
+ return removed;
45689
+ }
45690
+
45691
+ // Assumes: Arcs have been divided
45692
+ //
45693
+ function clipPolylines(targetShapes, clipShapes, nodes, type) {
45694
+ var index = new PathIndex(clipShapes, nodes.arcs);
45695
+
45696
+ return targetShapes.map(function(shp) {
45697
+ return clipPolyline(shp);
45698
+ });
45699
+
45700
+ function clipPolyline(shp) {
45701
+ var clipped = null;
45702
+ if (shp) clipped = shp.reduce(clipPath, []);
45703
+ return clipped && clipped.length > 0 ? clipped : null;
45704
+ }
45705
+
45706
+ function clipPath(memo, path) {
45707
+ var clippedPath = null,
45708
+ arcId, enclosed;
45709
+ for (var i=0; i<path.length; i++) {
45710
+ arcId = path[i];
45711
+ enclosed = index.arcIsEnclosed(arcId);
45712
+ if (enclosed && type == 'clip' || !enclosed && type == 'erase') {
45713
+ if (!clippedPath) {
45714
+ memo.push(clippedPath = []);
45715
+ }
45716
+ clippedPath.push(arcId);
45717
+ } else {
45718
+ clippedPath = null;
45719
+ }
45720
+ }
45721
+ return memo;
45722
+ }
45723
+ }
45724
+
45725
+ var PolylineClipping = /*#__PURE__*/Object.freeze({
45726
+ __proto__: null,
45727
+ clipPolylines: clipPolylines
45728
+ });
45729
+
45730
+ // TODO: to prevent invalid holes,
45731
+ // could erase the holes from the space-enclosing rings.
45732
+ function appendHolesToRings(cw, ccw) {
45733
+ for (var i=0, n=ccw.length; i<n; i++) {
45734
+ cw.push(ccw[i]);
45735
+ }
45736
+ return cw;
45737
+ }
45738
+
45739
+ function getPolygonDissolver(nodes, spherical) {
45740
+ spherical = spherical && !nodes.arcs.isPlanar();
45741
+ var flags = new Uint8Array(nodes.arcs.size());
45742
+ var divide = getHoleDivider(nodes, spherical);
45743
+ var pathfind = getRingIntersector(nodes, flags);
45744
+
45745
+ return function(shp) {
45746
+ if (!shp) return null;
45747
+ var cw = [],
45748
+ ccw = [];
45749
+
45750
+ divide(shp, cw, ccw);
45751
+ cw = pathfind(cw, 'flatten');
45752
+ ccw.forEach(reversePath);
45753
+ ccw = pathfind(ccw, 'flatten');
44535
45754
  ccw.forEach(reversePath);
44536
45755
  var shp2 = appendHolesToRings(cw, ccw);
44537
45756
  var dissolved = pathfind(shp2, 'dissolve');
@@ -45382,40 +46601,530 @@ ${svg}
45382
46601
  };
45383
46602
  }
45384
46603
 
45385
- var ArcClassifier = /*#__PURE__*/Object.freeze({
45386
- __proto__: null,
45387
- getArcClassifier: getArcClassifier
45388
- });
45389
-
45390
- function makePolygonBuffer(lyr, dataset, opts) {
45391
- var spherical = isLatLngCRS(getDatasetCRS(dataset));
45392
- // The debug-offset/debug-winding/debug-mosaic visualizations are implemented
45393
- // only for line buffers. They have no handling in the polygon pipeline; left
45394
- // unstripped they leak into the per-shape dissolve and produce malformed
45395
- // output, so drop them and warn rather than silently mislead.
45396
- if (opts.debug_offset || opts.debug_winding || opts.debug_mosaic) {
45397
- warn('debug-offset/debug-winding/debug-mosaic are not implemented for polygon buffers; ignoring');
45398
- opts = Object.assign({}, opts, {
45399
- debug_offset: false,
45400
- debug_winding: false,
45401
- debug_mosaic: false
45402
- });
45403
- }
45404
- if (spherical && opts.polar) {
45405
- // Pole/antimeridian-sliced polygons (grow only): keep the seam edges at the
45406
- // extent and clip to the world rectangle instead of wrapping at the
45407
- // antimeridian (see makePolarPolygonBuffer).
45408
- return makePolarPolygonBuffer(lyr, dataset, opts);
45409
- }
45410
- var output = makePolygonBufferGeoJSON(lyr, dataset, opts);
45411
- var dataset2 = importGeoJSON(output.geojson, {type: 'polygon'});
45412
- if (spherical) {
45413
- splitAntimeridianBufferDataset(dataset2);
45414
- if (output.dissolveAfterSplit) {
45415
- dissolveBufferDataset2(dataset2, opts);
46604
+ var ArcClassifier = /*#__PURE__*/Object.freeze({
46605
+ __proto__: null,
46606
+ getArcClassifier: getArcClassifier
46607
+ });
46608
+
46609
+ function makePolygonBuffer(lyr, dataset, opts) {
46610
+ var spherical = isLatLngCRS(getDatasetCRS(dataset));
46611
+ // debug-mosaic is implemented only for line buffers; for polygons it has no
46612
+ // handling and would leak into the per-shape dissolve and corrupt output, so
46613
+ // drop it and warn rather than silently mislead.
46614
+ if (opts.debug_mosaic) {
46615
+ warn('debug-mosaic is not implemented for polygon buffers; ignoring');
46616
+ opts = Object.assign({}, opts, {debug_mosaic: false});
46617
+ }
46618
+ if (opts.fill_gaps) {
46619
+ // Fill enclosed holes and narrow-mouthed inlets without growing the outer
46620
+ // boundary -- a topology-aware morphological closing (see
46621
+ // makeGapFillPolygonBuffer). Taken ahead of the debug and polar/normal
46622
+ // branches: fill-gaps is inherently topological and builds its medial at the
46623
+ // fill radius R, so it owns the debug views too (it re-enters this function
46624
+ // at R to produce them) and takes precedence over an explicit topological.
46625
+ var fillResult = makeGapFillPolygonBuffer(lyr, dataset, opts);
46626
+ // fill-gaps may itself return a debug dataset (it owns the medial debug
46627
+ // views); never cull those.
46628
+ if (!bufferOutputIsDebug(opts)) {
46629
+ cullSubTolerancePolygonArtifacts(fillResult, lyr, dataset, opts);
46630
+ }
46631
+ return fillResult;
46632
+ }
46633
+ if (opts.debug_delaunay) {
46634
+ // Undocumented: emit the medial-construction triangles (the Delaunay
46635
+ // triangles that bridge two features within buffer reach), whose
46636
+ // circumcenters are the medial vertices -- the contested ribbon where the
46637
+ // medial axis is built, not the full hull triangulation.
46638
+ return makeDelaunayDebugDataset(lyr, dataset, opts);
46639
+ }
46640
+ if (opts.debug_voronoi) {
46641
+ // Undocumented: emit the inter-feature medial-axis (Voronoi) cut-lines that
46642
+ // the topological pipeline injects to partition contested space, before they
46643
+ // are cut into the mosaic and dissolved. Useful for inspecting the medial
46644
+ // construction (sampling density, centerline tracking, simplification).
46645
+ return makeVoronoiDebugDataset(lyr, dataset, opts);
46646
+ }
46647
+ if (opts.debug_offset) {
46648
+ // Raw offset rings, undissolved (as with the line debug-offset): show the
46649
+ // construction's offset loops before the winding/boundary dissolve. Honors
46650
+ // no-loop-removal (loop removal runs inside the maker) and band-method, and
46651
+ // is taken ahead of the polar branch so it shows the raw clamped offsets
46652
+ // rather than the clipped/dissolved polar result.
46653
+ var debugDataset = importGeoJSON(
46654
+ makePolygonDebugOffsetGeoJSON(lyr, dataset, opts), {type: 'polygon'});
46655
+ if (spherical && debugDataset.arcs) {
46656
+ splitAntimeridianBufferDataset(debugDataset);
46657
+ }
46658
+ return debugDataset;
46659
+ }
46660
+ if (spherical && opts.polar) {
46661
+ // Pole/antimeridian-sliced polygons (grow only): keep the seam edges at the
46662
+ // extent and clip to the world rectangle instead of wrapping at the
46663
+ // antimeridian (see makePolarPolygonBuffer).
46664
+ var polar = makePolarPolygonBuffer(lyr, dataset, opts);
46665
+ cullSubTolerancePolygonArtifacts(polar, lyr, dataset, opts);
46666
+ return polar;
46667
+ }
46668
+ var output = buildPolygonBufferOutput(lyr, dataset, opts);
46669
+ var dataset2 = importGeoJSON(output.geojson, {type: 'polygon'});
46670
+ if (spherical) {
46671
+ splitAntimeridianBufferDataset(dataset2);
46672
+ if (output.dissolveAfterSplit) {
46673
+ dissolveBufferDataset2(dataset2, opts);
46674
+ }
46675
+ }
46676
+ cullSubTolerancePolygonArtifacts(dataset2, lyr, dataset, opts);
46677
+ return dataset2;
46678
+ }
46679
+
46680
+ // True when the buffer is producing a debug view (raw offsets or medial
46681
+ // construction) rather than a real buffer; those must skip the artifact cull.
46682
+ function bufferOutputIsDebug(opts) {
46683
+ return !!(opts.debug_delaunay || opts.debug_voronoi || opts.debug_offset ||
46684
+ opts.debug_mosaic);
46685
+ }
46686
+
46687
+ // Sub-tolerance artifact cull. The discrete medial sampling and the fill-gaps
46688
+ // mask boolean leave a scatter of degenerate, near-zero-area positive sliver
46689
+ // parts (most egregiously in fill-gaps: e.g. ~160 spurious parts on six
46690
+ // counties, most of them literally zero area). A part smaller than a tol x tol
46691
+ // square -- the buffer's own positional accuracy -- is below the noise floor and
46692
+ // is dropped. The smallest legitimate buffer part (the grow of a point-like
46693
+ // feature, ~pi*d^2) is ~4 orders of magnitude larger, so the threshold never
46694
+ // touches real geometry, and a genuinely thin-but-long fill (area =
46695
+ // tol*length >> tol^2) is kept. Holes are intentionally left alone: a small hole
46696
+ // can be a neighbor's preserved territory (see removePositiveBufferArtifactHoles,
46697
+ // which is territory-aware) and must not be filled here. Disabled when tolerance
46698
+ // is turned off (tolerance=0), matching the medial-simplify contract.
46699
+ function cullSubTolerancePolygonArtifacts(outDataset, srcLyr, srcDataset, opts) {
46700
+ if (opts.tolerance === 0 || opts.tolerance == '0' || opts.tolerance == '0%') return;
46701
+ if (!outDataset || !outDataset.arcs) return;
46702
+ var lyr = outDataset.layers.filter(function(l) {
46703
+ return l.geometry_type == 'polygon';
46704
+ })[0];
46705
+ if (!lyr || !lyr.shapes) return;
46706
+ var arcs = outDataset.arcs;
46707
+ var distanceFn = getBufferDistanceFunction(srcLyr, srcDataset, opts);
46708
+ var tolFn = getBufferToleranceFunction(srcDataset, opts);
46709
+ var crsArcs = srcDataset.arcs || arcs; // CRS-bearing arcs for the unit conversion
46710
+ lyr.shapes = lyr.shapes.map(function(shp, i) {
46711
+ if (!shp || shp.length === 0) return shp;
46712
+ var dist = distanceFn(i);
46713
+ if (!(dist > 0)) return shp;
46714
+ var tolCoord = getCoordinateDistance(tolFn(dist), crsArcs);
46715
+ var minArea = tolCoord * tolCoord;
46716
+ if (!(minArea > 0)) return shp;
46717
+ var kept = shp.filter(function(ring) {
46718
+ var area = getPlanarPathArea(ring, arcs);
46719
+ return !(area > 0 && area < minArea); // drop sub-tolerance positive parts
46720
+ });
46721
+ return kept.length > 0 ? kept : null;
46722
+ });
46723
+ }
46724
+
46725
+ // Build the per-shape GeoJSON offset output for a polygon buffer, choosing the
46726
+ // construction:
46727
+ // - The clean-outline construction is the default for ordinary polygon grow
46728
+ // (outer rings offset to a single self-contained loop; far fewer rings and
46729
+ // self-intersections into the winding dissolve than the band ribbon).
46730
+ // - The topological pipeline (which pre-dissolves per feature into a shared
46731
+ // mosaic) and the band-method escape hatch keep the band-ribbon construction.
46732
+ // - Negative buffers and hole shrink fall back to the band erode inside the
46733
+ // outline path itself.
46734
+ // Shared by makePolygonBuffer and makePolarPolygonBuffer so the polar option is
46735
+ // a true no-op on non-polar shapes (same construction as the plain buffer).
46736
+ function buildPolygonBufferOutput(lyr, dataset, opts) {
46737
+ var useOutline = !opts.band_method && !opts.topological;
46738
+ return useOutline ?
46739
+ makeOutlinePolygonBufferGeoJSON(lyr, dataset, opts) :
46740
+ makePolygonBufferGeoJSON(lyr, dataset, opts);
46741
+ }
46742
+
46743
+ // Short unit suffixes for re-emitting a scaled distance string (see
46744
+ // fillGapsRadiusStr); parseMeasure2 normalizes input units to these canonical
46745
+ // names. A null (unitless) value is re-emitted bare and interpreted like an
46746
+ // unsuffixed radius (meters for lat-long, CRS units for projected).
46747
+ var GAP_FILL_UNIT_SUFFIX = {
46748
+ meters: 'm',
46749
+ kilometers: 'km',
46750
+ feet: 'ft',
46751
+ miles: 'mi'
46752
+ };
46753
+
46754
+ // Default max-widening factor: an interior gap is kept open only if it is at
46755
+ // least this many times wider than the mouth size; narrower gaps are filled. Set
46756
+ // well above 1 so the closing does not leave a string of small holes along a
46757
+ // channel whose width fluctuates around the mouth size (e.g. the Columbia).
46758
+ var GAP_MAX_WIDENING_DEFAULT = 5;
46759
+
46760
+ // The mouth size for the gap fill is the buffer distance (the standard radius
46761
+ // option), which must be a positive constant.
46762
+ function parseFillGaps(opts) {
46763
+ var parsed = parseMeasure2(opts.radius);
46764
+ if (!(parsed.value > 0)) {
46765
+ stop$1('The fill-gaps option requires a positive buffer distance');
46766
+ }
46767
+ return parsed;
46768
+ }
46769
+
46770
+ // Build a radius string of (mouthSize/2 * factor) preserving the input units, so
46771
+ // the sub-buffers' own unit/CRS conversion applies (matching how -buffer radius
46772
+ // is parsed). factor 1 gives the mouth radius r = mouthSize/2 (two banks of a
46773
+ // channel narrower than the mouth size, 2r, meet under a grow of r); factor k
46774
+ // (the max-widening multiple) gives the larger fill radius R = k*mouthSize/2.
46775
+ function fillGapsRadiusStr(parsed, factor) {
46776
+ return String(parsed.value * factor / 2) +
46777
+ (GAP_FILL_UNIT_SUFFIX[parsed.units] || '');
46778
+ }
46779
+
46780
+ // max-widening: keep an interior gap open only if it is wider than this multiple
46781
+ // of the fill-gaps mouth size (default GAP_MAX_WIDENING_DEFAULT). Must be >= 1
46782
+ // (the threshold cannot be narrower than the mouth itself).
46783
+ function getGapMaxWideningFactor(opts) {
46784
+ if (opts.max_widening == null) return GAP_MAX_WIDENING_DEFAULT;
46785
+ var k = Number(opts.max_widening);
46786
+ if (!(k >= 1)) {
46787
+ stop$1('The max-widening option must be a number >= 1');
46788
+ }
46789
+ return k;
46790
+ }
46791
+
46792
+ // Fill enclosed holes and narrow-mouthed inlets of a polygon mosaic (e.g. a
46793
+ // river up to its mouth) without growing the outer boundary. This is a
46794
+ // topology-aware morphological closing of the mosaic with two thresholds:
46795
+ // - the mouth radius r = mouthSize/2 gates which gaps are sealed off from the
46796
+ // open coast (only openings narrower than the mouth size are closed);
46797
+ // - the fill radius R = k*mouthSize/2 (k = max-widening factor) controls how
46798
+ // far into a sealed inlet the fill reaches and how wide an interior gap must
46799
+ // be to stay open.
46800
+ // Steps:
46801
+ // - the topological buffer grows every feature by R and partitions the
46802
+ // contested space by nearest source (the per-feature dilation, T_R); this
46803
+ // covers every interior gap narrower than k*mouthSize and splits it down the
46804
+ // medial axis;
46805
+ // - the mask is the closing of the land by the mouth radius r: dilate the land
46806
+ // by r, union it (D_r), erode by r. The mask is the original land plus any
46807
+ // gap narrower than the mouth size, with the outward collar pulled back to
46808
+ // the source outline; its interior holes are the gaps wider than the mouth
46809
+ // that lie behind a narrow mouth (rivers' wide reaches, enclosed lakes);
46810
+ // - holes of the mask narrower than k*mouthSize are filled (their max
46811
+ // inscribed circle is smaller than R), so only genuinely large open bodies
46812
+ // (e.g. the Great Lakes) stay open;
46813
+ // - clipping T_R to the filled mask drops the R-wide collar and the kept-open
46814
+ // bodies but keeps each feature's original area plus its medial share of the
46815
+ // narrow-gap fill.
46816
+ // Feature order is preserved so source attributes stay aligned.
46817
+ function makeGapFillPolygonBuffer(lyr, dataset, opts) {
46818
+ var parsed = parseFillGaps(opts);
46819
+ var k = getGapMaxWideningFactor(opts);
46820
+ var mouthRadius = fillGapsRadiusStr(parsed, 1); // r = mouthSize/2
46821
+ var fillRadius = fillGapsRadiusStr(parsed, k); // R = k*mouthSize/2
46822
+ var baseOpts = Object.assign({}, opts, {fill_gaps: false, max_widening: null});
46823
+ // fill-gaps is inherently topological and builds its partition at the larger
46824
+ // fill radius R, so every sub-buffer overrides the radius (and turns on the
46825
+ // topological pipeline for the fill dilation) regardless of the flags the user
46826
+ // passed -- topological need not be given explicitly.
46827
+ var fillOpts = Object.assign({}, baseOpts, {radius: fillRadius, topological: true});
46828
+ // Fill + medial partition at R so interior pockets up to k*mouthSize wide are
46829
+ // covered and split by nearest source.
46830
+ var dilated = makePolygonBuffer(lyr, dataset, fillOpts);
46831
+ // The medial debug views (debug-delaunay/-voronoi) and debug-offset short-
46832
+ // circuit makePolygonBuffer and return their construction directly; since
46833
+ // fill-gaps builds at R, that early return IS the debug output we want, so
46834
+ // pass it straight through instead of trying to mask/clip a debug dataset.
46835
+ if (opts.debug_delaunay || opts.debug_voronoi || opts.debug_offset) {
46836
+ return dilated;
46837
+ }
46838
+ var dilatedLyr = dilated.layers[0];
46839
+ if (!dilatedLyr || !dilated.arcs) return dilated;
46840
+ // Mouth-gating mask: closing of the land by the mouth radius r (dilate by r,
46841
+ // union, erode by r). Built separately from the fill dilation because the
46842
+ // mouth threshold stays at the mouth size while the fill reaches further.
46843
+ var dilatedAtR = makePolygonBuffer(lyr, dataset,
46844
+ Object.assign({}, baseOpts, {radius: mouthRadius, topological: false}));
46845
+ var union = unionBufferDataset(dilatedAtR, baseOpts);
46846
+ if (!union || !union.arcs) return dilated;
46847
+ var closing = makePolygonBuffer(union.layers[0], union,
46848
+ Object.assign({}, baseOpts, {radius: '-' + mouthRadius, topological: false}));
46849
+ if (!closing || !closing.arcs) return dilated;
46850
+ // Fill the mask's interior gaps that are narrower than k*mouthSize (keep wider
46851
+ // ones open) so the clip below fills them too.
46852
+ var keepRadius = getCoordinateDistance(
46853
+ parseConstantBufferDistance(fillRadius, getDatasetCRS(dataset)) || 0,
46854
+ closing.arcs);
46855
+ fillNarrowMaskHoles(closing, keepRadius);
46856
+ // Carry the source attributes through the clip: clipping drops fully-empty
46857
+ // features, so attach a per-feature data table (aligned 1:1 with the dilation)
46858
+ // before clipping rather than relying on a post-hoc count-match copy.
46859
+ if (lyr.data && dilatedLyr.shapes &&
46860
+ dilatedLyr.shapes.length == lyr.data.size()) {
46861
+ dilatedLyr.data = lyr.data.clone();
46862
+ }
46863
+ // remove_slivers: the dilation-vs-mask clip carves thin slivers along its
46864
+ // boundary (a feature can pick up dozens of degenerate edge slivers); clip's
46865
+ // own sliver filter drops the rings that mix dilation and mask arcs and fail a
46866
+ // compactness-weighted area test, leaving the substantial fill regions intact.
46867
+ clipLayersInPlace(dilated.layers, closing, dilated, 'clip',
46868
+ {no_cleanup: true, no_warn: true, remove_slivers: true});
46869
+ return dilated;
46870
+ }
46871
+
46872
+ // Remove interior rings (holes) of the closing mask whose largest inscribed
46873
+ // circle has radius smaller than keepRadius, i.e. gaps narrower than 2*keepRadius
46874
+ // (= k*mouthSize). Dropping the hole ring makes the mask solid there, so the
46875
+ // clip fills the gap; wide gaps (large lakes) keep their ring and stay open. The
46876
+ // inscribed radius is estimated from the gap's pole of inaccessibility (the
46877
+ // anchor point), the same way label points are placed. Modifies the mask in
46878
+ // place; the clip rebuilds topology afterward, so dropped rings need no cleanup.
46879
+ function fillNarrowMaskHoles(maskDataset, keepRadius) {
46880
+ var arcs = maskDataset.arcs;
46881
+ var lyr = maskDataset.layers[0];
46882
+ if (!lyr || !arcs || !(keepRadius > 0)) return;
46883
+ lyr.shapes = (lyr.shapes || []).map(function(shape) {
46884
+ if (!shape) return shape;
46885
+ var kept = [];
46886
+ exportPathData(shape, arcs, 'polygon').pathData.forEach(function(path) {
46887
+ if (path.area >= 0) {
46888
+ kept.push(path.ids.concat()); // outer ring: always keep
46889
+ return;
46890
+ }
46891
+ // hole: keep only if wide enough to hold a disk of radius keepRadius
46892
+ var holeShape = [reversePath(path.ids.concat())];
46893
+ var anchor = findAnchorPoint(holeShape, arcs);
46894
+ var radius = anchor ?
46895
+ getPointToShapeDistance(anchor.x, anchor.y, holeShape, arcs) : 0;
46896
+ if (radius >= keepRadius) {
46897
+ kept.push(path.ids.concat());
46898
+ }
46899
+ });
46900
+ return kept.length > 0 ? kept : null;
46901
+ });
46902
+ }
46903
+
46904
+ // Dissolve every feature of a buffered dataset into a single union polygon (the
46905
+ // morphological dilation). The per-feature dilation tiles the union exactly, so
46906
+ // merging all rings into one shape and boundary-dissolving yields the union
46907
+ // outline (with any wide enclosed holes preserved). Returns null if empty.
46908
+ function unionBufferDataset(dataset, opts) {
46909
+ var lyr = dataset.layers[0];
46910
+ if (!lyr || !dataset.arcs) return null;
46911
+ var coords = [];
46912
+ (lyr.shapes || []).forEach(function(shp) {
46913
+ if (shp) coords = coords.concat(getPolygonMultiPolygonCoords(shp, dataset.arcs));
46914
+ });
46915
+ if (coords.length === 0) return null;
46916
+ var unionDataset = getBufferDataset(coords);
46917
+ if (!unionDataset.arcs) return null;
46918
+ dissolveBufferDataset2(unionDataset, Object.assign({}, opts, {winding_fill: false}));
46919
+ return unionDataset;
46920
+ }
46921
+
46922
+ // Raw (undissolved) offset rings for the polygon buffer's debug-offset view.
46923
+ // Drives the same per-ring makers the real construction uses, so the view shows
46924
+ // exactly what is built and 'no-loop-removal' has a visible effect (loop removal
46925
+ // runs inside the maker -- see buildOneSidedRings). Positive grow uses the
46926
+ // clean-outline maker by default (the band maker under band-method/topological);
46927
+ // holes are eroded with the band maker reversed to outer orientation (matching
46928
+ // makeOutlineBufferGeometry); negative (erode) buffers offset every ring inward
46929
+ // with the band maker.
46930
+ function makePolygonDebugOffsetGeoJSON(lyr, dataset, opts) {
46931
+ var distanceFn = getBufferDistanceFunction(lyr, dataset, opts);
46932
+ var useOutline = !opts.band_method && !opts.topological;
46933
+ var leftOpts = useOutline ? Object.assign({}, opts, {outline: true}) : opts;
46934
+ var leftMaker = getPolygonRingBufferMaker(dataset, leftOpts, 'left');
46935
+ var rightMaker = getPolygonRingBufferMaker(dataset,
46936
+ Object.assign({}, opts, {outline: false}), 'right');
46937
+ var geometries = lyr.shapes.map(function(shape, i) {
46938
+ var distance = distanceFn(i);
46939
+ if (!distance || !shape) return null;
46940
+ var coords;
46941
+ if (distance > 0) {
46942
+ var rings = splitShapeRingsByArea(shape, dataset.arcs);
46943
+ coords = getBufferMultiPolygonCoords(rings.outer, distance, leftMaker);
46944
+ if (rings.holes.length > 0) {
46945
+ coords = coords.concat(getBufferMultiPolygonCoords(
46946
+ rings.holes.map(reversePath), distance, rightMaker));
46947
+ }
46948
+ } else {
46949
+ coords = getBufferMultiPolygonCoords(shape, -distance, rightMaker);
46950
+ }
46951
+ return coords.length > 0 ? {type: 'MultiPolygon', coordinates: coords} : null;
46952
+ });
46953
+ return {type: 'GeometryCollection', geometries: geometries};
46954
+ }
46955
+
46956
+ // Clean-outline polygon buffer (the default polygon-grow construction): offset
46957
+ // each source ring to a single self-contained closed loop (no source-path band
46958
+ // edge), strip self-overlaps with the crossing-direction loop remover (safe
46959
+ // because a single offset loop has a consistent +/-1 base winding), then union
46960
+ // the loops by winding number. The loops carry far fewer rings and self-
46961
+ // intersections into the dissolve than the band-ribbon construction, and the
46962
+ // direction remover collapses more overshoot loops than the source-turn gate.
46963
+ //
46964
+ // Used for the positive (grow) buffer. The outer source rings are offset
46965
+ // outward with the fast clean-outline construction (a single self-contained loop
46966
+ // per ring, crossing-direction loop removal, winding union) -- this is where the
46967
+ // method pays off, since a large coastline's outer boundary dominates the
46968
+ // dissolve cost. Holes shrink, which is an INWARD offset; an inward outline
46969
+ // offset is fragile (its elbow insets self-cross on concave holes and invert on
46970
+ // over-shrink, and mapshaper's winding flood can't tell a collapsed contour from
46971
+ // a valid one because it normalizes orientation), so holes are shrunk with the
46972
+ // proven band-ribbon erode and then carved out of the grown outer (see
46973
+ // makeOutlineBufferGeometry). Negative (erode) buffers fall back to the band
46974
+ // construction entirely.
46975
+ function makeOutlinePolygonBufferGeoJSON(lyr, dataset, opts) {
46976
+ var distanceFn = getBufferDistanceFunction(lyr, dataset, opts);
46977
+ var hasPositiveDistance = false;
46978
+ var hasNegativeDistance = false;
46979
+ // Force the clean-outline construction on the outer maker (this is the
46980
+ // default polygon-grow path, so it must not depend on a command-line flag).
46981
+ var outlineOpts = Object.assign({}, opts, {outline: true});
46982
+ var outerMaker = getPolygonRingBufferMaker(dataset, outlineOpts, 'left');
46983
+ // Band maker for negative buffers and for shrinking holes.
46984
+ var bandOpts = Object.assign({}, opts, {outline: false});
46985
+ var bandRightMaker = getPolygonRingBufferMaker(dataset, bandOpts, 'right');
46986
+ // Left winding-fill maker for the nested-fill fallback (see below).
46987
+ var bandLeftMaker = getPolygonRingBufferMaker(dataset, bandOpts, 'left');
46988
+ var holeEroder = function(holeShape, dist) {
46989
+ return makeNegativePolygonBufferGeometry(holeShape, dist, dataset, bandOpts,
46990
+ bandRightMaker);
46991
+ };
46992
+ var geometries = lyr.shapes.map(function(shape, i) {
46993
+ var distance = distanceFn(i);
46994
+ if (!distance || !shape) return null;
46995
+ if (distance < 0) {
46996
+ hasNegativeDistance = true;
46997
+ return makeNegativePolygonBufferGeometry(shape, -distance, dataset,
46998
+ bandOpts, bandRightMaker);
46999
+ }
47000
+ hasPositiveDistance = true;
47001
+ var geom;
47002
+ if (shapeHasFillInsideHole(shape, dataset.arcs)) {
47003
+ // The clean-outline construction grows ALL of a shape's outer rings into
47004
+ // one solid fill before carving holes (makeOutlineBufferGeometry), which
47005
+ // absorbs a fill nested inside a hole: the nested fill's offset loop just
47006
+ // adds winding to the surrounding solid, and the later hole carve removes
47007
+ // the region where it lived. The winding-fill construction instead groups
47008
+ // rings by containment and buffers each group independently, so the nested
47009
+ // fill is grown on its own and survives. It is slower, so use it only for
47010
+ // the rare shapes that actually nest a fill inside a hole.
47011
+ geom = makePositivePolygonBufferGeometry(shape, distance, dataset, opts,
47012
+ bandLeftMaker);
47013
+ } else {
47014
+ geom = makeOutlineBufferGeometry(shape, dataset.arcs, distance, opts,
47015
+ outerMaker, holeEroder);
47016
+ }
47017
+ if (opts.polar && shapeReachesPole(shape, dataset.arcs)) {
47018
+ // A pole-touching ring collapses onto the pole line during Mercator
47019
+ // offset construction, so the outline keeps only the coastline; append
47020
+ // the source rings and let the dataset-level dissolve union them into the
47021
+ // full grown shape (same handling as the band construction).
47022
+ geom = appendSourceRings(geom, shape, dataset.arcs);
47023
+ }
47024
+ return geom;
47025
+ });
47026
+ return {
47027
+ geojson: {
47028
+ type: 'GeometryCollection',
47029
+ geometries: geometries
47030
+ },
47031
+ dissolveAfterSplit: hasPositiveDistance && !hasNegativeDistance
47032
+ };
47033
+ }
47034
+
47035
+ // True if the shape has a fill (positive-area ring) nested directly inside a
47036
+ // hole (negative-area ring) -- e.g. an island sitting in a lake. The clean-
47037
+ // outline grow can't represent such a fill (it grows all outer rings into one
47038
+ // solid before carving holes, absorbing the nested fill), so callers route
47039
+ // these shapes to the winding-fill construction instead.
47040
+ function shapeHasFillInsideHole(shape, arcs) {
47041
+ var paths = exportPathData(shape, arcs, 'polygon').pathData;
47042
+ var fills = 0, holes = 0;
47043
+ paths.forEach(function(p) {
47044
+ if (p.area > 0) fills++;
47045
+ else if (p.area < 0) holes++;
47046
+ });
47047
+ // Nesting a fill inside a hole needs at least two fills (a container and the
47048
+ // nested one) and at least one hole; bail cheaply otherwise (the common case)
47049
+ // before building the spatial index.
47050
+ if (fills < 2 || holes < 1) return false;
47051
+ var ringShapes = paths.map(function(p) { return [p.ids]; });
47052
+ var index = new PathIndex(ringShapes, arcs);
47053
+ return paths.some(function(p) {
47054
+ if (p.area <= 0) return false; // only fills can be nested inside a hole
47055
+ var containerId = index.findSmallestEnclosingPolygon(p.ids);
47056
+ return containerId > -1 && paths[containerId].area < 0;
47057
+ });
47058
+ }
47059
+
47060
+ // Split a shape's rings into outer rings (positive signed area) and hole rings
47061
+ // (negative signed area), keeping each ring's arc ids.
47062
+ function splitShapeRingsByArea(shape, arcs) {
47063
+ var outer = [];
47064
+ var holes = [];
47065
+ exportPathData(shape, arcs, 'polygon').pathData.forEach(function(path) {
47066
+ (path.area < 0 ? holes : outer).push(path.ids.concat());
47067
+ });
47068
+ return {outer: outer, holes: holes};
47069
+ }
47070
+
47071
+ function makeOutlineBufferGeometry(shape, arcs, distance, opts, outerMaker,
47072
+ holeEroder) {
47073
+ var rings = splitShapeRingsByArea(shape, arcs);
47074
+ var outerLoops = rings.outer.length > 0 ?
47075
+ getBufferMultiPolygonCoords(rings.outer, distance, outerMaker) : [];
47076
+ if (outerLoops.length === 0) return null;
47077
+ // Resolve the outer offset loops' self-overlaps into clean grown polygons.
47078
+ var coords = dissolveOffsetRingsToCoords(outerLoops, opts);
47079
+ if (coords.length === 0) return null;
47080
+ // Strip artifact holes left by the outer grow (the offset loops dissolve into
47081
+ // a clean fill, so any interior ring is a self-overlap artifact) BEFORE
47082
+ // carving the real holes. The artifact filter's heuristic can otherwise
47083
+ // delete a legitimately-shrunk hole whose eroded boundary happens to run near
47084
+ // the source outline; the carved holes below are explicit and known-good, so
47085
+ // they must not pass through it.
47086
+ var grown = removePositiveBufferArtifactHoles(
47087
+ {type: 'MultiPolygon', coordinates: coords}, shape, arcs, distance);
47088
+ coords = grown ? grown.coordinates : [];
47089
+ if (coords.length === 0) return null;
47090
+ if (rings.holes.length > 0) {
47091
+ // Shrink the holes (an inward offset) with the band erode: treat each hole
47092
+ // as a polygon to erode by reversing it to outer (CCW) orientation, then
47093
+ // carve the eroded regions out of the grown outer.
47094
+ var holeShape = rings.holes.map(reversePath);
47095
+ var holeGeom = holeEroder(holeShape, distance);
47096
+ if (holeGeom && holeGeom.coordinates.length > 0) {
47097
+ coords = subtractHolesFromOuter(coords, holeGeom.coordinates);
45416
47098
  }
45417
47099
  }
45418
- return dataset2;
47100
+ if (coords.length === 0) return null;
47101
+ return {type: 'MultiPolygon', coordinates: coords};
47102
+ }
47103
+
47104
+ // Carve clean shrunk-hole regions out of clean grown-outer polygons. Both arrive
47105
+ // as positive (CCW) rings. The winding union can't subtract one nested loop from
47106
+ // another -- GeoJSON import rewinds every outer ring to CCW, so two separately
47107
+ // imported nested loops both read as fill -- so instead we make each hole a
47108
+ // negative-area inner ring of the same shape: reverse the hole rings at the arc
47109
+ // level (reversing GeoJSON coordinates wouldn't survive import's rewind) and let
47110
+ // groupPolygonRings (which classifies a ring as a hole by negative area) nest
47111
+ // each hole into its containing outer. The outer and hole rings are disjoint
47112
+ // (holes lie strictly inside outers), so no intersection cuts are needed.
47113
+ function subtractHolesFromOuter(outerCoords, holeCoords) {
47114
+ if (!holeCoords || holeCoords.length === 0) return outerCoords;
47115
+ var dataset = importGeoJSON({
47116
+ type: 'GeometryCollection',
47117
+ geometries: [
47118
+ {type: 'MultiPolygon', coordinates: outerCoords},
47119
+ {type: 'MultiPolygon', coordinates: holeCoords}
47120
+ ]
47121
+ }, {type: 'polygon'});
47122
+ if (!dataset.arcs) return outerCoords;
47123
+ var shapes = dataset.layers[0].shapes;
47124
+ var outerShape = shapes[0] || [];
47125
+ var holeShape = shapes[1] || [];
47126
+ var merged = outerShape.concat(holeShape.map(reversePath));
47127
+ return getPolygonMultiPolygonCoords(merged, dataset.arcs);
45419
47128
  }
45420
47129
 
45421
47130
  // World rectangle (lng/lat) the polar buffer is clipped to.
@@ -45436,7 +47145,7 @@ ${svg}
45436
47145
  if (polarBufferHasNegativeDistance(lyr, dataset, opts)) {
45437
47146
  stop$1('The polar option does not support negative (erode) buffers yet.');
45438
47147
  }
45439
- var output = makePolygonBufferGeoJSON(lyr, dataset, opts);
47148
+ var output = buildPolygonBufferOutput(lyr, dataset, opts);
45440
47149
  var dataset2 = importGeoJSON(output.geojson, {type: 'polygon'});
45441
47150
  if (dataset2.arcs) {
45442
47151
  if (output.dissolveAfterSplit) {
@@ -45536,9 +47245,9 @@ ${svg}
45536
47245
  }
45537
47246
 
45538
47247
  function getPolygonRingBufferMaker(dataset, opts, side, winding) {
45539
- // The sector-band escape hatch forces the older non-winding construction even
45540
- // for callers that request winding-fill (see the 'sector-band' option).
45541
- var useWinding = !opts.sector_band;
47248
+ // The band-method escape hatch forces the older non-winding construction even
47249
+ // for callers that request winding-fill (see the 'band-method' option).
47250
+ var useWinding = !opts.band_method;
45542
47251
  var makerOpts = Object.assign({}, opts, {
45543
47252
  left: side == 'left',
45544
47253
  right: side == 'right',
@@ -45581,6 +47290,7 @@ ${svg}
45581
47290
  tmpGeometries.push(getPolygonGeometry(shape, dataset.arcs));
45582
47291
  });
45583
47292
 
47293
+ profileStart('topo:offsets');
45584
47294
  shapes.forEach(function(shape, i) {
45585
47295
  var distance = distances[i];
45586
47296
  var pathData, bufferCoords;
@@ -45590,10 +47300,10 @@ ${svg}
45590
47300
  bufferCoords = getBufferMultiPolygonCoords(pathData.paths, distance, bufferMaker);
45591
47301
  // Resolve the winding-fill rings' self-overlaps into a clean polygon (the
45592
47302
  // mosaic's boundary-flood membership cannot), so this feature enters the
45593
- // shared mosaic as an ordinary polygon. The sector-band fallback emits
47303
+ // shared mosaic as an ordinary polygon. The band-method fallback emits
45594
47304
  // boundary-flood-resolvable bands, so it feeds the mosaic directly (as the
45595
47305
  // topological pipeline did before the winding-fill construction).
45596
- if (!opts.sector_band) {
47306
+ if (!opts.band_method) {
45597
47307
  bufferCoords = dissolveOffsetRingsToCoords(bufferCoords, opts);
45598
47308
  }
45599
47309
  if (bufferCoords.length > 0) {
@@ -45604,6 +47314,7 @@ ${svg}
45604
47314
  });
45605
47315
  }
45606
47316
  });
47317
+ profileEnd('topo:offsets');
45607
47318
 
45608
47319
  if (!hasPositiveDistance || tmpGeometries.length === 0) {
45609
47320
  geometries = shapes.map(function() { return null; });
@@ -45613,7 +47324,8 @@ ${svg}
45613
47324
  geometries: tmpGeometries
45614
47325
  }, {type: 'polygon'});
45615
47326
  geometries = makeTopologicalPolygonBufferGeometries(shapes, distances,
45616
- sourceIds, bufferIds, tmpDataset, dataset.arcs);
47327
+ sourceIds, bufferIds, tmpDataset, dataset.arcs,
47328
+ getMedialSimplifyInterval(dataset, opts, distances));
45617
47329
  }
45618
47330
  return {
45619
47331
  geojson: {
@@ -45625,39 +47337,168 @@ ${svg}
45625
47337
  }
45626
47338
 
45627
47339
  function makeTopologicalPolygonBufferGeometries(shapes, distances, sourceIds,
45628
- bufferIds, tmpDataset, sourceArcs) {
45629
- var tmpLyr = tmpDataset.layers[0];
45630
- var nodes = addIntersectionCuts(tmpDataset, {rebuild_topology: true});
47340
+ bufferIds, tmpDataset, sourceArcs, medialSimplifyInterval) {
47341
+ // Inject inter-feature Voronoi (medial-axis) cut lines so the buffer mosaic's
47342
+ // contested tiles are subdivided along the equidistant boundary before the
47343
+ // tiles are assigned (see assignment by nearest source below).
47344
+ profileStart('topo:medial');
47345
+ var dataset = injectMedialCutLines(tmpDataset, shapes, distances, sourceArcs,
47346
+ medialSimplifyInterval);
47347
+ profileEnd('topo:medial');
47348
+ var tmpLyr = dataset.layers.filter(function(l) {
47349
+ return l.geometry_type == 'polygon';
47350
+ })[0];
47351
+ profileStart('topo:intersectionCuts');
47352
+ var nodes = addIntersectionCuts(dataset, {rebuild_topology: true});
47353
+ profileEnd('topo:intersectionCuts');
47354
+ profileStart('topo:mosaicIndex');
45631
47355
  var mosaicIndex = new MosaicIndex(tmpLyr, nodes, {flat: false, no_holes: false});
47356
+ profileEnd('topo:mosaicIndex');
45632
47357
  var pathfind = getRingIntersector(mosaicIndex.nodes);
45633
47358
  var sourceIdIndex = getIdLookup(sourceIds);
45634
47359
  var bufferIdIndex = getIdToFeatureIdLookup(bufferIds);
45635
47360
  var sourceAreas = getSourceShapeAreas(shapes, sourceArcs);
45636
- return shapes.map(function(shape, i) {
47361
+ var sourceInteriorPoints = getSourceInteriorPoints(shapes, sourceArcs);
47362
+ var ownerCtx = createTileOwnerContext(shapes, sourceArcs, sourceAreas,
47363
+ mosaicIndex.nodes.arcs);
47364
+ profileStart('topo:assignTiles');
47365
+ var result = shapes.map(function(shape, i) {
45637
47366
  var distance = distances[i];
45638
47367
  var tileIds, geom;
45639
47368
  if (!distance || !shape) return null;
45640
47369
  tileIds = getTopologicalBufferTileIds(sourceIds[i], bufferIds[i],
45641
- i, mosaicIndex, sourceIdIndex, bufferIdIndex, sourceAreas);
47370
+ i, mosaicIndex, sourceIdIndex, bufferIdIndex, ownerCtx);
45642
47371
  geom = getTileIdsGeometry(tileIds, mosaicIndex, pathfind);
45643
- return removePositiveBufferArtifactHoles(geom, shape, sourceArcs, distance);
47372
+ // Preserve holes that coincide with another feature's source territory (a
47373
+ // tile excluded to prevent buffer overlap, see getTopologicalBufferTileIds);
47374
+ // the single-shape artifact/sliver heuristics would otherwise fill them.
47375
+ return removePositiveBufferArtifactHoles(geom, shape, sourceArcs, distance,
47376
+ {points: sourceInteriorPoints, featureId: i});
47377
+ });
47378
+ profileEnd('topo:assignTiles');
47379
+ return result;
47380
+ }
47381
+
47382
+ // Build the working dataset for the topological mosaic: the source/buffer
47383
+ // polygons plus a polyline layer of inter-feature Voronoi cut-lines (so the
47384
+ // contested tiles split along the equidistant boundary). Returns @tmpDataset
47385
+ // unchanged when there are no contested edges (no overlap between features).
47386
+ function injectMedialCutLines(tmpDataset, shapes, distances, arcs, simplifyInterval) {
47387
+ var coordDistances = distances.map(function(d) {
47388
+ return d > 0 ? getCoordinateDistance(d, arcs) : 0;
47389
+ });
47390
+ var medial = buildInterFeatureMedialLines(shapes, coordDistances, arcs,
47391
+ {simplifyInterval: simplifyInterval || 0});
47392
+ if (!medial) return tmpDataset;
47393
+ var lineDataset = importGeoJSON({
47394
+ type: 'GeometryCollection',
47395
+ geometries: [medial]
47396
+ }, {});
47397
+ if (!lineDataset.arcs) return tmpDataset;
47398
+ return mergeDatasets([tmpDataset, lineDataset]);
47399
+ }
47400
+
47401
+ // Smoothing scale for the constructed medial lines, as a multiple of the
47402
+ // buffer's own positional tolerance. Weighted Visvalingam removes sub-tolerance
47403
+ // wiggles (the residual zigzag of the discrete medial sampling) and thins the
47404
+ // uneven vertex density without pulling the partition off the centerline:
47405
+ // keeping the interval near the buffer's accuracy budget bounds any deviation to
47406
+ // what the buffer geometry already tolerates.
47407
+ var MEDIAL_SIMPLIFY_FACTOR = 3; // 2
47408
+
47409
+ // Simplification interval (in source-coordinate units) for the medial cut-lines.
47410
+ // Tied to the buffer tolerance at the largest buffer distance present, so the
47411
+ // medial axis is smoothed at the same scale the buffer outline is approximated.
47412
+ // Returns 0 (no simplification) when tolerance is disabled or all distances are
47413
+ // non-positive.
47414
+ function getMedialSimplifyInterval(dataset, opts, distances) {
47415
+ if (opts.tolerance === 0 || opts.tolerance == '0' || opts.tolerance == '0%') {
47416
+ return 0;
47417
+ }
47418
+ var repDist = 0;
47419
+ for (var i = 0; i < distances.length; i++) {
47420
+ if (distances[i] > repDist) repDist = distances[i];
47421
+ }
47422
+ if (repDist <= 0) return 0;
47423
+ var tolMeters = getBufferToleranceFunction(dataset, opts)(repDist);
47424
+ return getCoordinateDistance(tolMeters, dataset.arcs) * MEDIAL_SIMPLIFY_FACTOR;
47425
+ }
47426
+
47427
+ // Per-feature buffer distances for the debug builders below, in meters and in
47428
+ // source-coordinate units (matching what the topological pipeline computes).
47429
+ function getMedialDebugDistances(lyr, dataset, opts) {
47430
+ var shapes = lyr.shapes || [];
47431
+ var distanceFn = getBufferDistanceFunction(lyr, dataset, opts);
47432
+ var distances = shapes.map(function(shape, i) {
47433
+ var d = shape ? distanceFn(i) : 0;
47434
+ return d > 0 ? d : 0;
47435
+ });
47436
+ var coordDistances = distances.map(function(d) {
47437
+ return d > 0 ? getCoordinateDistance(d, dataset.arcs) : 0;
45644
47438
  });
47439
+ return {shapes: shapes, distances: distances, coordDistances: coordDistances};
47440
+ }
47441
+
47442
+ // Build a polyline dataset of the inter-feature medial-axis (Voronoi) cut-lines
47443
+ // for the -buffer debug-voronoi option (topological only). These are the same
47444
+ // lines injected into the mosaic to partition contested space, after the
47445
+ // post-construction smoothing simplification.
47446
+ function makeVoronoiDebugDataset(lyr, dataset, opts) {
47447
+ if (!opts.topological) {
47448
+ warn('debug-voronoi has no effect without the topological option; ignoring');
47449
+ return importGeoJSON({type: 'GeometryCollection', geometries: []}, {});
47450
+ }
47451
+ var d = getMedialDebugDistances(lyr, dataset, opts);
47452
+ var medial = buildInterFeatureMedialLines(d.shapes, d.coordDistances, dataset.arcs,
47453
+ {simplifyInterval: getMedialSimplifyInterval(dataset, opts, d.distances)});
47454
+ var geometries = medial ? [medial] : [];
47455
+ return importGeoJSON({type: 'GeometryCollection', geometries: geometries}, {});
47456
+ }
47457
+
47458
+ // Build a polygon dataset of the Delaunay triangulation of the adaptive sample
47459
+ // sites for the -buffer debug-delaunay option (topological only) -- the mesh the
47460
+ // medial axis is derived from.
47461
+ function makeDelaunayDebugDataset(lyr, dataset, opts) {
47462
+ if (!opts.topological) {
47463
+ warn('debug-delaunay has no effect without the topological option; ignoring');
47464
+ return importGeoJSON({type: 'GeometryCollection', geometries: []}, {type: 'polygon'});
47465
+ }
47466
+ var d = getMedialDebugDistances(lyr, dataset, opts);
47467
+ var tris = buildInterFeatureDelaunay(d.shapes, d.coordDistances, dataset.arcs);
47468
+ // tris is a GeometryCollection of one Polygon per triangle; import it directly
47469
+ // so each triangle is its own feature (selectable/inspectable).
47470
+ return importGeoJSON(tris || {type: 'GeometryCollection', geometries: []},
47471
+ {type: 'polygon'});
45645
47472
  }
45646
47473
 
45647
47474
  function getTopologicalBufferTileIds(sourceId, bufferId, featureId, mosaicIndex,
45648
- sourceIdIndex, bufferIdIndex, sourceAreas) {
47475
+ sourceIdIndex, bufferIdIndex, ownerCtx) {
45649
47476
  var ids = [];
45650
47477
  var index = [];
45651
47478
  addTileIds(ids, index, mosaicIndex.getTileIdsByShapeId(sourceId));
45652
47479
  if (bufferId >= 0) {
45653
47480
  addTileIds(ids, index, mosaicIndex.getTileIdsByShapeId(bufferId).filter(function(tileId) {
45654
47481
  return !tileHasSourcePolygon(tileId, mosaicIndex, sourceIdIndex) &&
45655
- getBufferTileOwnerId(tileId, mosaicIndex, bufferIdIndex, sourceAreas) == featureId;
47482
+ getBufferTileOwnerId(tileId, mosaicIndex, bufferIdIndex, ownerCtx) == featureId;
45656
47483
  }));
45657
47484
  }
45658
47485
  return ids;
45659
47486
  }
45660
47487
 
47488
+ // Per-mosaic context for nearest-source tile ownership. Source-shape segment
47489
+ // indexes and tile representative points are built lazily and cached, since
47490
+ // only a fraction of tiles are contested.
47491
+ function createTileOwnerContext(shapes, sourceArcs, sourceAreas, mosaicArcs) {
47492
+ return {
47493
+ shapes: shapes,
47494
+ sourceArcs: sourceArcs,
47495
+ sourceAreas: sourceAreas,
47496
+ mosaicArcs: mosaicArcs,
47497
+ sourceIndexCache: [],
47498
+ anchorCache: []
47499
+ };
47500
+ }
47501
+
45661
47502
  function addTileIds(memo, index, ids) {
45662
47503
  ids.forEach(function(id) {
45663
47504
  if (index[id]) return;
@@ -45672,29 +47513,110 @@ ${svg}
45672
47513
  });
45673
47514
  }
45674
47515
 
45675
- function getBufferTileOwnerId(tileId, mosaicIndex, bufferIdIndex, sourceAreas) {
45676
- var ownerId = -1;
45677
- var ownerArea = -Infinity;
47516
+ // Tiny relative tolerance for treating two source distances as equal (a tile
47517
+ // sitting on the equidistant boundary), falling back to the largest-area then
47518
+ // lowest-id rule for a deterministic result.
47519
+ var OWNER_DIST_EPS = 1e-6;
47520
+
47521
+ // Owner of a contested buffer tile: among the features whose buffer covers the
47522
+ // tile, the one whose source polygon is nearest to the tile's representative
47523
+ // point. Choosing only among covering features means reassignment never creates
47524
+ // a coverage gap (the tile is inside every candidate's buffer); the Voronoi
47525
+ // cut-lines split tiles along the equidistant boundary so each sub-tile lies on
47526
+ // one source's near side. Ties (a tile straddling the boundary, or one the
47527
+ // medial axis did not reach) fall back to largest area then lowest id.
47528
+ function getBufferTileOwnerId(tileId, mosaicIndex, bufferIdIndex, ownerCtx) {
47529
+ var candidates = [];
45678
47530
  mosaicIndex.getSourceIdsByTileId(tileId).forEach(function(shapeId) {
45679
47531
  var featureId = bufferIdIndex[shapeId];
45680
- var area;
45681
- if (featureId >= 0) {
45682
- area = sourceAreas[featureId];
45683
- if (area > ownerArea || area == ownerArea && featureId < ownerId) {
45684
- ownerId = featureId;
45685
- ownerArea = area;
45686
- }
47532
+ if (featureId >= 0) candidates.push(featureId);
47533
+ });
47534
+ if (candidates.length === 0) return -1;
47535
+ if (candidates.length === 1) return candidates[0];
47536
+ var p = getTileAnchorPoint(tileId, mosaicIndex, ownerCtx);
47537
+ if (!p) return pickLargestAreaFeature(candidates, ownerCtx.sourceAreas);
47538
+ var ownerId = -1;
47539
+ var bestDist = Infinity;
47540
+ var bestArea = -Infinity;
47541
+ for (var i = 0; i < candidates.length; i++) {
47542
+ var featureId = candidates[i];
47543
+ var dist = getPointToSourceDistance(p.x, p.y, featureId, ownerCtx);
47544
+ var area = ownerCtx.sourceAreas[featureId];
47545
+ var tol = bestDist === Infinity ? 0 :
47546
+ OWNER_DIST_EPS * Math.max(1, Math.abs(bestDist), Math.abs(dist));
47547
+ if (dist < bestDist - tol ||
47548
+ (Math.abs(dist - bestDist) <= tol &&
47549
+ (area > bestArea || area == bestArea && featureId < ownerId))) {
47550
+ ownerId = featureId;
47551
+ bestDist = dist;
47552
+ bestArea = area;
47553
+ }
47554
+ }
47555
+ return ownerId;
47556
+ }
47557
+
47558
+ function pickLargestAreaFeature(candidates, sourceAreas) {
47559
+ var ownerId = -1;
47560
+ var ownerArea = -Infinity;
47561
+ candidates.forEach(function(featureId) {
47562
+ var area = sourceAreas[featureId];
47563
+ if (area > ownerArea || area == ownerArea && featureId < ownerId) {
47564
+ ownerId = featureId;
47565
+ ownerArea = area;
45687
47566
  }
45688
47567
  });
45689
47568
  return ownerId;
45690
47569
  }
45691
47570
 
47571
+ // Representative interior point of a mosaic tile, cached by tile id. Uses the
47572
+ // pole of inaccessibility (findAnchorPoint), which is guaranteed to lie inside
47573
+ // the tile -- unlike the centroid, which for a thin or curved contested strip
47574
+ // (the sub-tiles the medial-axis cuts create along a narrow channel) can fall
47575
+ // outside the tile, on the wrong side of the equidistant boundary, and so
47576
+ // misclassify the strip's nearest source. The centroid is used only as a
47577
+ // fallback if the anchor probe fails.
47578
+ function getTileAnchorPoint(tileId, mosaicIndex, ownerCtx) {
47579
+ if (tileId in ownerCtx.anchorCache) return ownerCtx.anchorCache[tileId];
47580
+ var tile = mosaicIndex.mosaic[tileId];
47581
+ var p = findAnchorPoint(tile, ownerCtx.mosaicArcs) ||
47582
+ getPathCentroid(tile[0], ownerCtx.mosaicArcs) || null;
47583
+ ownerCtx.anchorCache[tileId] = p;
47584
+ return p;
47585
+ }
47586
+
47587
+ // Distance from a point to a feature's source polygon, using a lazily-built and
47588
+ // cached chunk-bounds segment index (see buildShapeSegmentIndex).
47589
+ function getPointToSourceDistance(x, y, featureId, ownerCtx) {
47590
+ var index = ownerCtx.sourceIndexCache[featureId];
47591
+ if (!index) {
47592
+ index = ownerCtx.sourceIndexCache[featureId] =
47593
+ buildShapeSegmentIndex(ownerCtx.shapes[featureId], ownerCtx.sourceArcs);
47594
+ }
47595
+ return getPointToIndexedShapeDistance(x, y, index);
47596
+ }
47597
+
45692
47598
  function getSourceShapeAreas(shapes, arcs) {
45693
47599
  return shapes.map(function(shape) {
45694
47600
  return Math.abs(getShapeArea(shape, arcs));
45695
47601
  });
45696
47602
  }
45697
47603
 
47604
+ // One interior point per positive ring-group (part) of every source shape,
47605
+ // tagged with its feature id. A multipart source contributes a point for each
47606
+ // detached part (e.g. a small island ring), so the topological hole filter can
47607
+ // recognize a neighbor's territory even when it is one part of a multipolygon.
47608
+ function getSourceInteriorPoints(shapes, arcs) {
47609
+ var points = [];
47610
+ shapes.forEach(function(shape, featureId) {
47611
+ if (!shape) return;
47612
+ getPolygonRingGroupShapes(shape, arcs).forEach(function(group) {
47613
+ var p = findAnchorPoint(group, arcs);
47614
+ if (p) points.push({x: p.x, y: p.y, featureId: featureId});
47615
+ });
47616
+ });
47617
+ return points;
47618
+ }
47619
+
45698
47620
  function getIdLookup(ids) {
45699
47621
  var index = [];
45700
47622
  ids.forEach(function(id) {
@@ -45862,10 +47784,10 @@ ${svg}
45862
47784
  if (!bufferDataset.arcs) return null;
45863
47785
  // The default offset rings come from the winding-fill maker (one self-
45864
47786
  // overlapping ring per source ring) and must be unioned by winding number;
45865
- // the sector-band fallback emits overlapping bands that a boundary flood
47787
+ // the band-method fallback emits overlapping bands that a boundary flood
45866
47788
  // resolves instead (its maker leaves winding_fill off to match).
45867
47789
  dissolveBufferDataset2(bufferDataset,
45868
- Object.assign({}, opts, {winding_fill: !opts.sector_band}));
47790
+ Object.assign({}, opts, {winding_fill: !opts.band_method}));
45869
47791
  bufferShape = bufferLyr.shapes && bufferLyr.shapes[0];
45870
47792
  if (!bufferShape) return null;
45871
47793
  bufferData = exportPathData(bufferShape, bufferDataset.arcs, 'polygon');
@@ -45894,8 +47816,28 @@ ${svg}
45894
47816
  });
45895
47817
  }
45896
47818
 
45897
- function removePositiveBufferArtifactHoles(geom, shape, arcs, distance) {
47819
+ // True if the buffer geometry has any interior ring (a candidate artifact
47820
+ // hole). A clean positive grow -- the common case, e.g. a hole-free coastline
47821
+ // dissolved by the winding-number fill -- has none, so the (potentially large)
47822
+ // source-shape spatial indexes below need never be built.
47823
+ function bufferGeomHasCandidateHole(geom) {
47824
+ if (geom.type == 'Polygon') return geom.coordinates.length > 1;
47825
+ if (geom.type == 'MultiPolygon') {
47826
+ return geom.coordinates.some(function(polygon) { return polygon.length > 1; });
47827
+ }
47828
+ return false;
47829
+ }
47830
+
47831
+ // @territory (optional, topological pipeline only): {points, featureId} where
47832
+ // points are source-part interior points tagged by feature id; a candidate hole
47833
+ // enclosing another feature's point is that neighbor's territory (excluded to
47834
+ // prevent overlap) and is always kept.
47835
+ function removePositiveBufferArtifactHoles(geom, shape, arcs, distance, territory) {
45898
47836
  if (!geom) return null;
47837
+ // Nothing to filter unless the result actually has interior rings. Skip the
47838
+ // index build (filterArtifactHoles is itself a no-op on hole-free polygons,
47839
+ // so this only avoids needless work, not any classification).
47840
+ if (!bufferGeomHasCandidateHole(geom)) return geom;
45899
47841
  var threshold = getPositiveHoleArtifactThreshold(distance, arcs);
45900
47842
  var minHoleArea = getPositiveHoleArtifactAreaThreshold(distance, arcs);
45901
47843
  var sourceHoles = getSourceHoleShapes(shape, arcs);
@@ -45914,7 +47856,9 @@ ${svg}
45914
47856
  shapeIndex: buildShapeSegmentIndex(shape, arcs),
45915
47857
  pathIndex: shape && shape.length > 0 ? new PathIndex([shape], arcs) : null,
45916
47858
  holeIndex: sourceHoles.length > 0 ?
45917
- buildShapeSegmentIndex(sourceHoles.map(function(h) {return h[0];}), arcs) : null
47859
+ buildShapeSegmentIndex(sourceHoles.map(function(h) {return h[0];}), arcs) : null,
47860
+ territoryPoints: territory ? territory.points : null,
47861
+ featureId: territory ? territory.featureId : -1
45918
47862
  };
45919
47863
  if (geom.type == 'Polygon') {
45920
47864
  geom.coordinates = filterArtifactHoles(geom.coordinates, minHoleArea, ctx);
@@ -46024,11 +47968,40 @@ ${svg}
46024
47968
  function filterArtifactHoles(polygon, minHoleArea, ctx) {
46025
47969
  if (polygon.length < 2) return polygon;
46026
47970
  return [polygon[0]].concat(polygon.slice(1).filter(function(ring) {
47971
+ // A hole over another feature's source is real territory (kept regardless of
47972
+ // size or how deep it sits in this feature's buffer); see getSourceInteriorPoints.
47973
+ if (ringEnclosesOtherTerritory(ring, ctx)) return true;
46027
47974
  return Math.abs(getGeoJSONRingArea(ring)) > minHoleArea &&
46028
47975
  !positiveBufferHoleIsArtifact(ring, ctx);
46029
47976
  }));
46030
47977
  }
46031
47978
 
47979
+ function ringEnclosesOtherTerritory(ring, ctx) {
47980
+ var points = ctx.territoryPoints;
47981
+ if (!points) return false;
47982
+ for (var i = 0; i < points.length; i++) {
47983
+ if (points[i].featureId === ctx.featureId) continue;
47984
+ if (pointInGeoJSONRing(points[i].x, points[i].y, ring)) return true;
47985
+ }
47986
+ return false;
47987
+ }
47988
+
47989
+ // Ray-casting point-in-ring test for a closed GeoJSON ring (array of [x, y],
47990
+ // first == last). Boundary cases are irrelevant here: territory probe points are
47991
+ // well inside their source part, far from any hole-ring edge.
47992
+ function pointInGeoJSONRing(x, y, ring) {
47993
+ var inside = false;
47994
+ for (var i = 0, j = ring.length - 1; i < ring.length; j = i++) {
47995
+ var xi = ring[i][0], yi = ring[i][1];
47996
+ var xj = ring[j][0], yj = ring[j][1];
47997
+ if ((yi > y) !== (yj > y) &&
47998
+ x < (xj - xi) * (y - yi) / (yj - yi) + xi) {
47999
+ inside = !inside;
48000
+ }
48001
+ }
48002
+ return inside;
48003
+ }
48004
+
46032
48005
  function positiveBufferHoleIsArtifact(ring, ctx) {
46033
48006
  var n = ring.length - 1; // skip duplicate endpoint
46034
48007
  var step = Math.max(1, Math.floor(n / 20));
@@ -48528,6 +50501,9 @@ ${svg}
48528
50501
  if (opts.topological && lyr.geometry_type != 'polygon') {
48529
50502
  stop$1('The topological buffer option requires a polygon layer');
48530
50503
  }
50504
+ if (opts.fill_gaps && lyr.geometry_type != 'polygon') {
50505
+ stop$1('The fill-gaps option requires a polygon layer');
50506
+ }
48531
50507
  if (opts.geodesic && !isLatLngCRS(getDatasetCRS(dataset))) {
48532
50508
  // Geodesic buffer of projected data: reproject the source paths through
48533
50509
  // WGS84 lng/lat, run the ordinary (spherical) buffer pipeline, then
@@ -53900,219 +55876,73 @@ ${svg}
53900
55876
  var rec = records[i] || {};
53901
55877
  if (!shp) {
53902
55878
  // case: record with no geometry -- retain in the output layer
53903
- shapes2.push(null);
53904
- records2.push(rec);
53905
- return;
53906
- }
53907
- outputLines = [];
53908
- outputKeys = [];
53909
- outputMatches = [];
53910
- forEachShapePart(shp, onPart);
53911
- outputLines.forEach(function(shape2, i) {
53912
- shapes2.push(shape2);
53913
- records2.push(utils.extend({}, rec));
53914
- index2.push(outputMatches[i]);
53915
- });
53916
- });
53917
- polylineLyr.shapes = shapes2;
53918
- polylineLyr.data = new DataTable(records2);
53919
- markLayerChanged(polylineLyr, {operation: 'divide', unit: 'shapes-data'});
53920
- joinTables(polylineLyr.data, polygonLyr.data, function(i) {
53921
- return index2[i] || [];
53922
- }, opts);
53923
-
53924
- function addDividedParts(parts, keys, matches) {
53925
- var keyId, key;
53926
- for (var i=0; i<parts.length; i++) {
53927
- key = keys[i];
53928
- keyId = outputKeys.indexOf(key);
53929
- if (keyId == -1) {
53930
- outputKeys.push(key);
53931
- outputLines.push([parts[i]]);
53932
- outputMatches.push(matches[i]);
53933
- } else {
53934
- outputLines[keyId].push(parts[i]);
53935
- }
53936
- }
53937
- }
53938
-
53939
- function getKey(shapeIds) {
53940
- return shapeIds.sort().join(',');
53941
- // multiple matches: treat like no match
53942
- // return shapeIds.length == 1 ? String(shapeIds[0]) : '-1';
53943
- }
53944
-
53945
- // Partition each part
53946
- function onPart(ids) {
53947
- var parts2 = [];
53948
- var keys2 = [];
53949
- var matches2 = [];
53950
- var prevKey = null;
53951
- var containingIds, key, part2, arcId;
53952
- // assign each arc to a divided shape
53953
- for (var i=0, n=ids.length; i<n; i++) {
53954
- arcId = ids[i];
53955
- containingIds = index.findShapesEnclosingArc(absArcId(arcId));
53956
- key = getKey(containingIds);
53957
- if (key === prevKey) {
53958
- // case: continuation of a part
53959
- part2.push(arcId);
53960
- } else {
53961
- // case: start of a new part
53962
- part2 = [arcId];
53963
- parts2.push(part2);
53964
- keys2.push(key);
53965
- matches2.push(containingIds);
53966
- }
53967
- prevKey = key;
53968
- }
53969
- addDividedParts(parts2, keys2, matches2);
53970
- }
53971
- }
53972
-
53973
- function MaxHeap() {
53974
- return new Heap('max');
53975
- }
53976
-
53977
- // A heap data structure used for computing Visvalingam simplification data.
53978
- // type: 'max' or 'min' (min is default)
53979
- //
53980
- function Heap(type) {
53981
- var heapBuf = utils.expandoBuffer(Int32Array),
53982
- indexBuf = utils.expandoBuffer(Int32Array),
53983
- heavierThan = type == 'max' ? lessThan : greaterThan,
53984
- itemsInHeap = 0,
53985
- dataArr,
53986
- heapArr,
53987
- indexArr;
53988
-
53989
- this.init = function(values) {
53990
- var i;
53991
- dataArr = values;
53992
- itemsInHeap = values.length;
53993
- heapArr = heapBuf(itemsInHeap);
53994
- indexArr = indexBuf(itemsInHeap);
53995
- for (i=0; i<itemsInHeap; i++) {
53996
- insertValue(i, i);
53997
- }
53998
- // place non-leaf items
53999
- for (i=(itemsInHeap-2) >> 1; i >= 0; i--) {
54000
- downHeap(i);
54001
- }
54002
- };
54003
-
54004
- this.size = function() {
54005
- return itemsInHeap;
54006
- };
54007
-
54008
- // Update a single value and re-heap
54009
- this.updateValue = function(valIdx, val) {
54010
- var heapIdx = indexArr[valIdx];
54011
- dataArr[valIdx] = val;
54012
- if (!(heapIdx >= 0 && heapIdx < itemsInHeap)) {
54013
- error("Out-of-range heap index.");
54014
- }
54015
- downHeap(upHeap(heapIdx));
54016
- };
54017
-
54018
- this.popValue = function() {
54019
- return dataArr[this.pop()];
54020
- };
54021
-
54022
- this.getValue = function(idx) {
54023
- return dataArr[idx];
54024
- };
54025
-
54026
- this.peek = function() {
54027
- return heapArr[0];
54028
- };
54029
-
54030
- this.peekValue = function() {
54031
- return dataArr[heapArr[0]];
54032
- };
54033
-
54034
- // Return the idx of the lowest-value item in the heap
54035
- this.pop = function() {
54036
- var popIdx;
54037
- if (itemsInHeap <= 0) {
54038
- error("Tried to pop from an empty heap.");
54039
- }
54040
- popIdx = heapArr[0];
54041
- insertValue(0, heapArr[--itemsInHeap]); // move last item in heap into root position
54042
- downHeap(0);
54043
- return popIdx;
54044
- };
54045
-
54046
- function upHeap(idx) {
54047
- var parentIdx;
54048
- // Move item up in the heap until it's at the top or is not lighter than its parent
54049
- while (idx > 0) {
54050
- parentIdx = (idx - 1) >> 1;
54051
- if (heavierThan(idx, parentIdx)) {
54052
- break;
54053
- }
54054
- swapItems(idx, parentIdx);
54055
- idx = parentIdx;
55879
+ shapes2.push(null);
55880
+ records2.push(rec);
55881
+ return;
54056
55882
  }
54057
- return idx;
54058
- }
54059
-
54060
- // Swap item at @idx with any lighter children
54061
- function downHeap(idx) {
54062
- var minIdx = compareDown(idx);
55883
+ outputLines = [];
55884
+ outputKeys = [];
55885
+ outputMatches = [];
55886
+ forEachShapePart(shp, onPart);
55887
+ outputLines.forEach(function(shape2, i) {
55888
+ shapes2.push(shape2);
55889
+ records2.push(utils.extend({}, rec));
55890
+ index2.push(outputMatches[i]);
55891
+ });
55892
+ });
55893
+ polylineLyr.shapes = shapes2;
55894
+ polylineLyr.data = new DataTable(records2);
55895
+ markLayerChanged(polylineLyr, {operation: 'divide', unit: 'shapes-data'});
55896
+ joinTables(polylineLyr.data, polygonLyr.data, function(i) {
55897
+ return index2[i] || [];
55898
+ }, opts);
54063
55899
 
54064
- while (minIdx > idx) {
54065
- swapItems(idx, minIdx);
54066
- idx = minIdx; // descend in the heap
54067
- minIdx = compareDown(idx);
55900
+ function addDividedParts(parts, keys, matches) {
55901
+ var keyId, key;
55902
+ for (var i=0; i<parts.length; i++) {
55903
+ key = keys[i];
55904
+ keyId = outputKeys.indexOf(key);
55905
+ if (keyId == -1) {
55906
+ outputKeys.push(key);
55907
+ outputLines.push([parts[i]]);
55908
+ outputMatches.push(matches[i]);
55909
+ } else {
55910
+ outputLines[keyId].push(parts[i]);
55911
+ }
54068
55912
  }
54069
55913
  }
54070
55914
 
54071
- function swapItems(a, b) {
54072
- var i = heapArr[a];
54073
- insertValue(a, heapArr[b]);
54074
- insertValue(b, i);
54075
- }
54076
-
54077
- // Associate a heap idx with the index of a value in data arr
54078
- function insertValue(heapIdx, valId) {
54079
- indexArr[valId] = heapIdx;
54080
- heapArr[heapIdx] = valId;
54081
- }
54082
-
54083
- // comparator for Visvalingam min heap
54084
- // @a, @b: Indexes in @heapArr
54085
- function greaterThan(a, b) {
54086
- var idx1 = heapArr[a],
54087
- idx2 = heapArr[b],
54088
- val1 = dataArr[idx1],
54089
- val2 = dataArr[idx2];
54090
- // If values are equal, compare array indexes.
54091
- // This is not a requirement of the Visvalingam algorithm,
54092
- // but it generates output that matches Mahes Visvalingam's
54093
- // reference implementation.
54094
- // See https://hydra.hull.ac.uk/assets/hull:10874/content
54095
- return (val1 > val2 || val1 === val2 && idx1 > idx2);
54096
- }
54097
-
54098
- // comparator for max heap
54099
- function lessThan(a, b) {
54100
- var idx1 = heapArr[a],
54101
- idx2 = heapArr[b];
54102
- return dataArr[idx1] < dataArr[idx2];
55915
+ function getKey(shapeIds) {
55916
+ return shapeIds.sort().join(',');
55917
+ // multiple matches: treat like no match
55918
+ // return shapeIds.length == 1 ? String(shapeIds[0]) : '-1';
54103
55919
  }
54104
55920
 
54105
- function compareDown(idx) {
54106
- var a = 2 * idx + 1,
54107
- b = a + 1,
54108
- n = itemsInHeap;
54109
- if (a < n && heavierThan(idx, a)) {
54110
- idx = a;
54111
- }
54112
- if (b < n && heavierThan(idx, b)) {
54113
- idx = b;
55921
+ // Partition each part
55922
+ function onPart(ids) {
55923
+ var parts2 = [];
55924
+ var keys2 = [];
55925
+ var matches2 = [];
55926
+ var prevKey = null;
55927
+ var containingIds, key, part2, arcId;
55928
+ // assign each arc to a divided shape
55929
+ for (var i=0, n=ids.length; i<n; i++) {
55930
+ arcId = ids[i];
55931
+ containingIds = index.findShapesEnclosingArc(absArcId(arcId));
55932
+ key = getKey(containingIds);
55933
+ if (key === prevKey) {
55934
+ // case: continuation of a part
55935
+ part2.push(arcId);
55936
+ } else {
55937
+ // case: start of a new part
55938
+ part2 = [arcId];
55939
+ parts2.push(part2);
55940
+ keys2.push(key);
55941
+ matches2.push(containingIds);
55942
+ }
55943
+ prevKey = key;
54114
55944
  }
54115
- return idx;
55945
+ addDividedParts(parts2, keys2, matches2);
54116
55946
  }
54117
55947
  }
54118
55948
 
@@ -59573,151 +61403,6 @@ ${svg}
59573
61403
  return m;
59574
61404
  }
59575
61405
 
59576
- var Visvalingam = {};
59577
-
59578
- Visvalingam.getArcCalculator = function(metric, is3D) {
59579
- var heap = new Heap(),
59580
- prevBuf = utils.expandoBuffer(Int32Array),
59581
- nextBuf = utils.expandoBuffer(Int32Array),
59582
- calc = is3D ?
59583
- function(b, c, d, xx, yy, zz) {
59584
- return metric(xx[b], yy[b], zz[b], xx[c], yy[c], zz[c], xx[d], yy[d], zz[d]);
59585
- } :
59586
- function(b, c, d, xx, yy) {
59587
- return metric(xx[b], yy[b], xx[c], yy[c], xx[d], yy[d]);
59588
- };
59589
-
59590
- // Calculate Visvalingam simplification data for an arc
59591
- // @kk (Float64Array|Array) Receives calculated simplification thresholds
59592
- // @xx, @yy, (@zz) Buffers containing vertex coordinates
59593
- return function calcVisvalingam(kk, xx, yy, zz) {
59594
- var arcLen = kk.length,
59595
- prevArr = prevBuf(arcLen),
59596
- nextArr = nextBuf(arcLen),
59597
- val, maxVal = -Infinity,
59598
- b, c, d; // indexes of points along arc
59599
-
59600
- if (zz && !is3D) {
59601
- error("[visvalingam] Received z-axis data for 2D simplification");
59602
- } else if (!zz && is3D) {
59603
- error("[visvalingam] Missing z-axis data for 3D simplification");
59604
- } else if (kk.length > xx.length) {
59605
- error("[visvalingam] Incompatible data arrays:", kk.length, xx.length);
59606
- }
59607
-
59608
- // Initialize Visvalingam "effective area" values and references to
59609
- // prev/next points for each point in arc.
59610
- for (c=0; c<arcLen; c++) {
59611
- b = c-1;
59612
- d = c+1;
59613
- if (b < 0 || d >= arcLen) {
59614
- val = Infinity; // endpoint maxVals
59615
- } else {
59616
- val = calc(b, c, d, xx, yy, zz);
59617
- }
59618
- kk[c] = val;
59619
- nextArr[c] = d;
59620
- prevArr[c] = b;
59621
- }
59622
- heap.init(kk);
59623
-
59624
- // Calculate removal thresholds for each internal point in the arc
59625
- //
59626
- while (heap.size() > 0) {
59627
- c = heap.pop(); // Remove the point with the least effective area.
59628
- val = kk[c];
59629
- if (val === Infinity) {
59630
- break;
59631
- }
59632
- if (val < maxVal) {
59633
- // don't assign current point a lesser value than the last removed vertex
59634
- kk[c] = maxVal;
59635
- } else {
59636
- maxVal = val;
59637
- }
59638
-
59639
- // Recompute effective area of neighbors of the removed point.
59640
- b = prevArr[c];
59641
- d = nextArr[c];
59642
- if (b > 0) {
59643
- val = calc(prevArr[b], b, d, xx, yy, zz);
59644
- heap.updateValue(b, val);
59645
- }
59646
- if (d < arcLen-1) {
59647
- val = calc(b, d, nextArr[d], xx, yy, zz);
59648
- heap.updateValue(d, val);
59649
- }
59650
- nextArr[b] = d;
59651
- prevArr[d] = b;
59652
- }
59653
- };
59654
- };
59655
-
59656
- Visvalingam.standardMetric = geom.triangleArea;
59657
- Visvalingam.standardMetric3D = geom.triangleArea3D;
59658
-
59659
- Visvalingam.getWeightedMetric = function(opts) {
59660
- var weight = Visvalingam.getWeightFunction(opts);
59661
- return function(ax, ay, bx, by, cx, cy) {
59662
- var area = geom.triangleArea(ax, ay, bx, by, cx, cy),
59663
- cos = geom.cosine(ax, ay, bx, by, cx, cy);
59664
- return weight(cos) * area;
59665
- };
59666
- };
59667
-
59668
- Visvalingam.getWeightedMetric3D = function(opts) {
59669
- var weight = Visvalingam.getWeightFunction(opts);
59670
- return function(ax, ay, az, bx, by, bz, cx, cy, cz) {
59671
- var area = geom.triangleArea3D(ax, ay, az, bx, by, bz, cx, cy, cz),
59672
- cos = geom.cosine3D(ax, ay, az, bx, by, bz, cx, cy, cz);
59673
- return weight(cos) * area;
59674
- };
59675
- };
59676
-
59677
- Visvalingam.getWeightCoefficient = function(opts) {
59678
- return opts && utils.isNumber(opts && opts.weighting) ? opts.weighting : 0.7;
59679
- };
59680
-
59681
- // Get a parameterized version of Visvalingam.weight()
59682
- Visvalingam.getWeightFunction = function(opts) {
59683
- var k = Visvalingam.getWeightCoefficient(opts);
59684
- return function(cos) {
59685
- return -cos * k + 1;
59686
- };
59687
- };
59688
-
59689
- // Weight triangle area by inverse cosine
59690
- // Standard weighting favors 90-deg angles; this curve peaks at 120 deg.
59691
- Visvalingam.weight = function(cos) {
59692
- var k = 0.7;
59693
- return -cos * k + 1;
59694
- };
59695
-
59696
- Visvalingam.getEffectiveAreaSimplifier = function(use3D) {
59697
- var metric = use3D ? Visvalingam.standardMetric3D : Visvalingam.standardMetric;
59698
- return Visvalingam.getPathSimplifier(metric, use3D);
59699
- };
59700
-
59701
- Visvalingam.getWeightedSimplifier = function(opts, use3D) {
59702
- var metric = use3D ? Visvalingam.getWeightedMetric3D(opts) : Visvalingam.getWeightedMetric(opts);
59703
- return Visvalingam.getPathSimplifier(metric, use3D);
59704
- };
59705
-
59706
- Visvalingam.getPathSimplifier = function(metric, use3D) {
59707
- return Visvalingam.scaledSimplify(Visvalingam.getArcCalculator(metric, use3D));
59708
- };
59709
-
59710
-
59711
- Visvalingam.scaledSimplify = function(f) {
59712
- return function(kk, xx, yy, zz) {
59713
- f(kk, xx, yy, zz);
59714
- for (var i=1, n=kk.length - 1; i<n; i++) {
59715
- // convert area metric to a linear equivalent
59716
- kk[i] = Math.sqrt(kk[i]) * 0.65;
59717
- }
59718
- };
59719
- };
59720
-
59721
61406
  function getSimplifyMethodLabel(slug) {
59722
61407
  return {
59723
61408
  dp: "Ramer-Douglas-Peucker",
@@ -62073,7 +63758,7 @@ ${svg}
62073
63758
  return name == 'rectangle' || name == 'rectangles' || name == 'filter' && opts.cleanup;
62074
63759
  }
62075
63760
 
62076
- var version = "0.7.27";
63761
+ var version = "0.7.29";
62077
63762
 
62078
63763
  // Parse command line args into commands and run them
62079
63764
  // Function takes an optional Node-style callback. A Promise is returned if no callback is given.