mapshaper 0.7.36 → 0.7.38

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 +361 -60
  2. package/package.json +1 -1
  3. package/www/mapshaper.js +361 -60
package/mapshaper.js CHANGED
@@ -33076,15 +33076,18 @@ ${svg}
33076
33076
  type: 'flag'
33077
33077
  })
33078
33078
  .option('corner-bias', {
33079
- // Sensitivity of corner detection (default 0 = neutral). Positive keeps
33080
- // more corners, negative fewer; +/- values of equal size are inverses.
33081
- // Under the hood it scales only the distance-proportional detection
33082
- // parameters (not angles), detecting corners as if the distance were
33083
- // divided by k, where k = bias+1 for bias >= 0 and 1/(1-bias) for bias < 0;
33084
- // the smoothing kernel keeps using the real distance. So corner-bias=-1
33085
- // finds the corners a 2x distance would, corner-bias=1 those of a 0.5x
33086
- // distance. Use no-corners to turn corner preservation off entirely.
33087
- describe: 'corner-detection sensitivity (default 0; + is more sensitive, - is less)',
33079
+ // Sensitivity of corner detection, RELATIVE to an automatic baseline
33080
+ // (default 0 = the automatic setting). Detection is auto-coarsened on
33081
+ // geometry that is sparse relative to the smoothing distance (long segments
33082
+ // read ordinary coarse bends as corners); this option adds to that base.
33083
+ // Positive keeps more corners, negative fewer; it scales only the distance-
33084
+ // proportional detection parameters (not angles), detecting corners as if
33085
+ // the distance were divided by k, where k = bias+1 for bias >= 0 and
33086
+ // 1/(1-bias) for bias < 0, and the smoothing kernel keeps using the real
33087
+ // distance. So on fine data (no auto adjustment) corner-bias=-1 finds the
33088
+ // corners a 2x distance would; on coarse data a positive value counteracts
33089
+ // the automatic coarsening. Use no-corners to turn preservation off entirely.
33090
+ describe: 'corner-detection sensitivity relative to auto (default 0; + more, - fewer)',
33088
33091
  type: 'number'
33089
33092
  })
33090
33093
  .option('prefilter-gate', {
@@ -63609,6 +63612,14 @@ ${svg}
63609
63612
  // -- whether a tight coastline or a gentle, hundreds-of-km graticule arc --
63610
63613
  // never qualifies; only a localized kink, where the inner turn approaches
63611
63614
  // the full turn, is a corner.
63615
+ // 1b. (open paths) Also flag the end of a long straight run that turns sharply
63616
+ // over a single segment but only gently over the window -- a small jog where
63617
+ // a surveyed border meets a curve, which step 1's window would dilute below
63618
+ // the corner angle. Keyed off the raw segment turn, but the pinned vertex is
63619
+ // snapped to the nearby end of the straight run (stable under tiny vertex
63620
+ // moves) and only where that run's line is actually left (so an incidental
63621
+ // notch after which the run resumes on the same line is not flagged). See
63622
+ // straightRunEndNear.
63612
63623
  // 2. Between flagged corners, classify each span as "structural" if it is long
63613
63624
  // relative to the tolerance and its curvature stays low (so a straight or
63614
63625
  // slowly-curving graticule line counts, but sub-tolerance wiggle does not).
@@ -63651,14 +63662,18 @@ ${svg}
63651
63662
  // to count, which matches intuition (a 28 km stretch bending at radius 4 km is
63652
63663
  // obviously not straight). Genuinely curving coastline bows far from its chord
63653
63664
  // and is still rejected, so spurious corners inside wiggly stretches keep getting
63654
- // culled.
63655
- var STRAIGHT_DEV_FACTOR = 0.03;
63665
+ // culled. Tightened from 0.03 to 0.02 (a run may curve ~9 deg over its length, not
63666
+ // ~14) after a coastline island pinned corners at both ends of a ~10 deg-curving
63667
+ // stretch that read as "straight" only under the looser corridor: an acute corner
63668
+ // takes the full corridor regardless of the angle coupling below, so only the base
63669
+ // factor governs whether such a run can anchor a sharp corner.
63670
+ var STRAIGHT_DEV_FACTOR = 0.02;
63656
63671
 
63657
63672
  // Angle coupling for corner retention: how much sharper the corner must turn than
63658
63673
  // the run it borders already curves. A run that passes the chord test may still
63659
63674
  // bend gently within the STRAIGHT_DEV_FACTOR corridor -- for a circular arc the
63660
- // chord-deviation ratio is ~ (the run's total turn)/8, so the base 0.03 admits a
63661
- // run that curves ~14 deg over its length. Pinning a *gentle* bend at the end of
63675
+ // chord-deviation ratio is ~ (the run's total turn)/8, so the base 0.02 admits a
63676
+ // run that curves ~9 deg over its length. Pinning a *gentle* bend at the end of
63662
63677
  // such a run is unsafe: the "corner" is barely sharper than the run's own
63663
63678
  // curving, so it is really a point on a smooth bend, not a junction. (This is the
63664
63679
  // failure mode on coarsely sampled / already-simplified coastlines, where a
@@ -63669,7 +63684,7 @@ ${svg}
63669
63684
  // turn >= PIN_TURN_RATIO * (8 * dev) <=> dev <= turn / (8 * PIN_TURN_RATIO).
63670
63685
  // retentionDevLimit() returns the smaller of STRAIGHT_DEV_FACTOR and
63671
63686
  // turn/(8*PIN_TURN_RATIO), so the coupling only bites for gentle corners (below
63672
- // ~2*STRAIGHT_DEV_FACTOR*PIN_TURN_RATIO ~ 69 deg); sharp corners (surveyed-border
63687
+ // ~2*STRAIGHT_DEV_FACTOR*PIN_TURN_RATIO ~ 46 deg); sharp corners (surveyed-border
63673
63688
  // right angles, spits, hairpins) keep the full base tolerance, unchanged.
63674
63689
  var PIN_TURN_RATIO = 5;
63675
63690
 
@@ -63693,6 +63708,15 @@ ${svg}
63693
63708
  // old 1*tol behaviour for users who want shorter runs pinned.
63694
63709
  var MIN_PIN_RUN_LEN_FACTOR = 2.0;
63695
63710
 
63711
+ // When a long straight run's end turns sharply into a curve with a small jog, the
63712
+ // sharpest single-segment turn can land a few vertices past the run's actual end.
63713
+ // straightRunEndNear searches back this many source vertices (in addition to the
63714
+ // distance-scaled tangent window) to snap to the stable run end, so detection
63715
+ // does not depend on the smoothing distance being large enough for the window to
63716
+ // span the jog. A jog is a handful of vertices; this is deliberately generous
63717
+ // because the search returns the nearest genuine run end (or nothing).
63718
+ var MAX_JOG_VERTICES = 6;
63719
+
63696
63720
  // Convert the user-facing corner-bias (0 = neutral) into the positive multiplier
63697
63721
  // k applied to corner-detection resolution (ctol = tol / k). The mapping is
63698
63722
  // symmetric about zero -- k(+b) * k(-b) = 1 -- and smooth there (both branches
@@ -63707,6 +63731,39 @@ ${svg}
63707
63731
  return b >= 0 ? b + 1 : 1 / (1 - b);
63708
63732
  }
63709
63733
 
63734
+ // Ratio of (typical segment length / smoothing distance) at or below which corner
63735
+ // detection sees several segments per tangent window and behaves normally, so no
63736
+ // automatic coarsening is applied. Above it the window shrinks toward a single
63737
+ // segment and ordinary coarse-data bends start reading as corners.
63738
+ var AUTO_BIAS_RATIO = 0.15;
63739
+ // Most-negative automatic bias. Caps how far detection is coarsened on very sparse
63740
+ // geometry (e.g. lo-res contours), where the ratio can be many times AUTO_BIAS_RATIO
63741
+ // but a handful of doublings already merges the whole neighbourhood.
63742
+ var AUTO_BIAS_FLOOR = -4;
63743
+
63744
+ // Automatic corner-bias from the geometry's coarseness relative to the smoothing
63745
+ // distance. @medianSeg is a robust (median) segment length; @dist is the raw
63746
+ // smoothing distance, both in the same ground units. Corner detection keys off a
63747
+ // tangent window ~0.3*dist wide; when the typical segment is an appreciable
63748
+ // fraction of the distance (ratio r = medianSeg/dist above AUTO_BIAS_RATIO) that
63749
+ // window spans too few segments and gentle-but-coarse bends read as corners. We
63750
+ // return a negative bias that coarsens detection (as if the distance were larger)
63751
+ // enough to push the effective ratio back down: each halving of the effective
63752
+ // resolution costs one bias step, so bias = -log2(r / AUTO_BIAS_RATIO), floored.
63753
+ // Returns 0 (no adjustment) when the geometry is fine relative to the distance,
63754
+ // which is the normal case for detailed datasets smoothed at a real distance.
63755
+ // Note it only ever coarsens: a straight run that genuinely anchors a sharp corner
63756
+ // survives this (the corner is retained by bordering a long straight run, which
63757
+ // coarsening does not remove until it exceeds the run's length), while weakly
63758
+ // supported bends on coarse coastlines/contours fall below the corner threshold.
63759
+ function autoCornerBias(medianSeg, dist) {
63760
+ if (!(medianSeg > 0) || !(dist > 0)) return 0;
63761
+ var r = medianSeg / dist;
63762
+ if (r <= AUTO_BIAS_RATIO) return 0;
63763
+ var b = -Math.log2(r / AUTO_BIAS_RATIO);
63764
+ return b < AUTO_BIAS_FLOOR ? AUTO_BIAS_FLOOR : b;
63765
+ }
63766
+
63710
63767
  // @cornerBias (optional, default 0 = neutral) scales only the distance-
63711
63768
  // proportional corner parameters, by dividing the tolerance they key off
63712
63769
  // (ctol = tol / k, k = cornerBiasScale(bias)). The dimensionless thresholds are
@@ -63744,7 +63801,6 @@ ${svg}
63744
63801
  var L = t[n - 1];
63745
63802
  var m = cyclic ? n - 1 : n;
63746
63803
  if (cyclic && m < 3) return [];
63747
- var segLen = cyclic ? ringSegLengths(t, m) : null;
63748
63804
  var W = params.tangentWindow;
63749
63805
  var Wi = params.innerWindow;
63750
63806
  var turns = new Float64Array(m);
@@ -63752,8 +63808,8 @@ ${svg}
63752
63808
  var lo = cyclic ? 0 : 1;
63753
63809
  var hi = cyclic ? m : n - 1; // exclusive
63754
63810
  for (var i = lo; i < hi; i++) {
63755
- turns[i] = windowedTurn(t, channels, K, n, L, m, segLen, i, W, cyclic);
63756
- inner[i] = windowedTurn(t, channels, K, n, L, m, segLen, i, Wi, cyclic);
63811
+ turns[i] = windowedTurn(t, channels, K, n, L, m, i, W, cyclic);
63812
+ inner[i] = windowedTurn(t, channels, K, n, L, m, i, Wi, cyclic);
63757
63813
  }
63758
63814
  // candidates above the angle threshold, that are concentrated (a localized
63759
63815
  // turn, not gradual bending -- see isConcentratedTurn), and that are the
@@ -63764,9 +63820,107 @@ ${svg}
63764
63820
  if (inner[j] < params.concentration * turns[j]) continue;
63765
63821
  if (isLocalMaxTurn(t, turns, j, W, L, m, lo, hi, cyclic)) corners.push(j);
63766
63822
  }
63823
+ // Open paths: also flag the terminal vertex of a long straight run that bends
63824
+ // sharply over a single segment but only gently over the tangent window -- e.g.
63825
+ // where a surveyed border meets a coastline with a small jog. The windowed test
63826
+ // above misses these because the wide window dilutes the sharp segment turn into
63827
+ // a sub-threshold bend, so the straight run's end gets rounded into the adjacent
63828
+ // curve. Here we key off the raw segment turn instead, but confine this rescue
63829
+ // to a genuine straight-run end with two gates (plus straightRunEndNear):
63830
+ // 1. the turn is fully concentrated in the inner window: inner-window turn >=
63831
+ // full-window turn. This is a stricter form of the windowed path's
63832
+ // concentration test (>= concentration * full) and is the crux of the
63833
+ // distinction. At a straight-run end the approaches carry ~no turning, so
63834
+ // the whole window's turn sits in the inner window (ratio >= 1); on a
63835
+ // steadily-curving coastline the turning is spread across the window (inner
63836
+ // < full), so a lone sharp segment there is rejected. The wide window
63837
+ // dilutes the end's departure below the corner threshold (which is why the
63838
+ // windowed path misses it), but the concentration ratio stays high.
63839
+ // 2. (via straightRunEndNear) a bordering straight run's line is actually left,
63840
+ // so an incidental notch on an otherwise-continuing run is not pinned.
63841
+ // Rings keep the windowed-only detection for now.
63842
+ if (!cyclic) {
63843
+ var raw = new Float64Array(m);
63844
+ for (i = lo; i < hi; i++) raw[i] = vertexTurn(channels, K, i);
63845
+ for (j = lo; j < hi; j++) {
63846
+ if (raw[j] < params.cornerAngle) continue;
63847
+ if (inner[j] < turns[j]) continue;
63848
+ if (!isLocalMaxTurn(t, raw, j, W, L, m, lo, hi, cyclic)) continue;
63849
+ var e = straightRunEndNear(t, channels, n, j, W, params);
63850
+ if (e >= 0 && corners.indexOf(e) === -1) corners.push(e);
63851
+ }
63852
+ corners.sort(function (a, b) { return a - b; });
63853
+ }
63767
63854
  return corners;
63768
63855
  }
63769
63856
 
63857
+ // Given a sharp single-segment turn at open-path vertex @j, find the end of a
63858
+ // long straight run that the path leaves, within arc length @W of @j, and return
63859
+ // that run-end vertex to pin (or -1). We search a neighbourhood rather than
63860
+ // requiring the run's last vertex to be exactly where the segment turn peaks: a
63861
+ // jog into a curve can place its sharpest vertex a step or two past the run's
63862
+ // end, and exactly which vertex is sharpest is sensitive to tiny differences in
63863
+ // vertex placement -- but the straight run's end itself is stable. Snapping to it
63864
+ // keeps detection from flickering on and off with sub-tolerance vertex moves. The
63865
+ // straightRunLeaves test only succeeds at a genuine run end whose continuation
63866
+ // departs, so an incidental jog after which the run resumes is still not pinned.
63867
+ function straightRunEndNear(t, channels, n, j, W, params) {
63868
+ var e, k;
63869
+ // The jog between the sharp turn and the run's end is a fixed handful of source
63870
+ // vertices, independent of the smoothing distance -- so the search extends by a
63871
+ // vertex count as well as the distance-scaled window W. Without the vertex floor
63872
+ // the window shrinks with the distance and, at small distances, can no longer
63873
+ // reach back across the jog to the run end (fewer, not more, corners pinned as
63874
+ // the distance drops -- the opposite of what a long run warrants). We return the
63875
+ // first (nearest) run end found, so a generous reach only costs a few extra
63876
+ // straightRunLeaves checks when there is nothing to pin.
63877
+ for (e = j, k = 0; e >= 1; e--, k++) {
63878
+ if (t[j] - t[e] > W && k > MAX_JOG_VERTICES) break;
63879
+ if (straightRunLeaves(t, channels, n, e, -1, params) ||
63880
+ straightRunLeaves(t, channels, n, e, 1, params)) return e;
63881
+ }
63882
+ for (e = j + 1, k = 1; e < n - 1; e++, k++) {
63883
+ if (t[e] - t[j] > W && k > MAX_JOG_VERTICES) break;
63884
+ if (straightRunLeaves(t, channels, n, e, -1, params) ||
63885
+ straightRunLeaves(t, channels, n, e, 1, params)) return e;
63886
+ }
63887
+ return -1;
63888
+ }
63889
+
63890
+ // Is the near side of @e (walking direction @dir: -1 = run precedes e, +1 = run
63891
+ // follows e) a long straight run, AND does the far side leave that run's line --
63892
+ // i.e. a point a full run-length along the far side sits outside the run's
63893
+ // straightness corridor? A jog that rejoins the run returns to ~0 perpendicular
63894
+ // offset and fails this, so it is not flagged. (This also implies @e is the run's
63895
+ // end: if the run continued straight past @e, the far point would stay on its
63896
+ // line.)
63897
+ function straightRunLeaves(t, channels, n, e, dir, params) {
63898
+ var K = channels.length, L = t[n - 1];
63899
+ var nearEnd = reach(t, n, n, L, e, dir, params.minPinRunLen, false);
63900
+ if (nearEnd === e) return false;
63901
+ var a = dir < 0 ? nearEnd : e;
63902
+ var b = dir < 0 ? e : nearEnd;
63903
+ // Require the near run to be straight to the SAME angle-coupled tolerance
63904
+ // retention will demand of a pin at @e (retentionDevLimit of the turn at @e),
63905
+ // not the looser default corridor. This makes straightRunEndNear's backward
63906
+ // scan stop at the run's true end -- the last vertex actually on the run's line
63907
+ // -- rather than a vertex a little past it that squeaks inside the loose 3%
63908
+ // corridor but would then be culled by retention (leaving nothing pinned).
63909
+ var devLim = retentionDevLimit(cornerTurn(t, channels, n, e, false, params));
63910
+ if (!isStraightRun(t, channels, a, b, params, devLim)) return false;
63911
+ var farEnd = reach(t, n, n, L, e, -dir, params.minPinRunLen, false);
63912
+ if (farEnd === e) return false;
63913
+ var pe = getPt(channels, K, e);
63914
+ var u = subv(getPt(channels, K, nearEnd), pe, K); // direction along the near run
63915
+ var uu = dot(u, u, K);
63916
+ if (!(uu > 0)) return false;
63917
+ var af = subv(getPt(channels, K, farEnd), pe, K);
63918
+ var along = Math.abs(dot(af, u, K)) / Math.sqrt(uu);
63919
+ var perp = Math.sqrt(perpDistSq(channels, K, farEnd, pe, u, uu));
63920
+ // outside the straight corridor extended from the run -> the path has left it
63921
+ return perp > STRAIGHT_DEV_FACTOR * along;
63922
+ }
63923
+
63770
63924
  // Is span [a, b] (inclusive vertex indices, a < b, open frame) a structural run:
63771
63925
  // long relative to the tolerance and low-curvature throughout?
63772
63926
  function isStructuralRun(t, channels, a, b, params) {
@@ -63876,8 +64030,7 @@ ${svg}
63876
64030
  var K = channels.length;
63877
64031
  var L = t[n - 1];
63878
64032
  var m = cyclic ? n - 1 : n;
63879
- var segLen = cyclic ? ringSegLengths(t, m) : null;
63880
- return windowedTurn(t, channels, K, n, L, m, segLen, i, params.tangentWindow, cyclic);
64033
+ return windowedTurn(t, channels, K, n, L, m, i, params.tangentWindow, cyclic);
63881
64034
  }
63882
64035
 
63883
64036
  // Does the open span [a, b] justify pinning the corner at vertex @corner: is it a
@@ -63899,18 +64052,12 @@ ${svg}
63899
64052
 
63900
64053
  // --- internals ---
63901
64054
 
63902
- function ringSegLengths(t, m) {
63903
- var segLen = new Float64Array(m);
63904
- for (var i = 0; i < m; i++) segLen[i] = t[i + 1] - t[i];
63905
- return segLen;
63906
- }
63907
-
63908
64055
  // Turn angle at vertex i between the incoming and outgoing directions, each
63909
64056
  // estimated over an arc-length window W (so the measure is scale-aware and not
63910
64057
  // dominated by a single short segment).
63911
- function windowedTurn(t, channels, K, n, L, m, segLen, i, W, cyclic) {
63912
- var back = reach(t, segLen, n, m, L, i, -1, W, cyclic);
63913
- var fwd = reach(t, segLen, n, m, L, i, 1, W, cyclic);
64058
+ function windowedTurn(t, channels, K, n, L, m, i, W, cyclic) {
64059
+ var back = reach(t, n, m, L, i, -1, W, cyclic);
64060
+ var fwd = reach(t, n, m, L, i, 1, W, cyclic);
63914
64061
  var pi = getPt(channels, K, i);
63915
64062
  var pb = getPt(channels, K, back);
63916
64063
  var pf = getPt(channels, K, fwd);
@@ -63935,13 +64082,21 @@ ${svg}
63935
64082
 
63936
64083
  // Walk from vertex i in direction dir (+1/-1) until accumulated arc length
63937
64084
  // reaches W (or a boundary, for open arcs), returning the reached vertex index.
63938
- function reach(t, segLen, n, m, L, i, dir, W, cyclic) {
64085
+ // Segment lengths are read straight from the cumulative-length array t (t has
64086
+ // n = m+1 entries with t[m] = L, so t[j+1]-t[j] is valid for every ring vertex
64087
+ // j in 0..m-1, including the closing segment); this avoids allocating a per-ring
64088
+ // segment-length array on every call (cornerTurn is hit ~twice per corner).
64089
+ function reach(t, n, m, L, i, dir, W, cyclic) {
63939
64090
  var j = i, acc = 0;
63940
64091
  while (acc < W) {
63941
64092
  if (cyclic) {
63942
- var k = dir > 0 ? (j + 1) % m : (j - 1 + m) % m;
63943
- acc += dir > 0 ? segLen[j] : segLen[k];
63944
- j = k;
64093
+ if (dir > 0) {
64094
+ acc += t[j + 1] - t[j]; // vertex j -> j+1 (t[m] == L handles the seam)
64095
+ j = j + 1 === m ? 0 : j + 1;
64096
+ } else {
64097
+ acc += j > 0 ? t[j] - t[j - 1] : L - t[m - 1]; // vertex j -> j-1
64098
+ j = (j - 1 + m) % m;
64099
+ }
63945
64100
  if (j === i) break; // wrapped the whole ring
63946
64101
  } else {
63947
64102
  var nk = j + dir;
@@ -63953,14 +64108,39 @@ ${svg}
63953
64108
  return j;
63954
64109
  }
63955
64110
 
64111
+ // Non-maximum suppression: is vertex j the sharpest turn within an arc-length
64112
+ // window W (ties broken toward the lower index)? Only the vertices within W of j
64113
+ // are examined -- walking outward from j in each direction and stopping once the
64114
+ // arc-length gap reaches W -- rather than scanning the whole arc for every
64115
+ // candidate, which was O(vertices^2) when many vertices are candidates (e.g. a
64116
+ // large ring smoothed below its vertex spacing). Cumulative arc length is
64117
+ // monotone, so the outward gap only grows and the early break is safe; a vertex
64118
+ // within the cyclic window is reached going forward or backward (or both on a
64119
+ // tiny ring, which is harmless for an all-or-nothing test).
63956
64120
  function isLocalMaxTurn(t, turns, j, W, L, m, lo, hi, cyclic) {
63957
- for (var k = lo; k < hi; k++) {
63958
- if (k === j) continue;
63959
- var d = Math.abs(t[k] - t[j]);
63960
- if (cyclic && d > L - d) d = L - d;
63961
- if (d < W && (turns[k] > turns[j] || (turns[k] === turns[j] && k < j))) {
63962
- return false;
64121
+ var k, d;
64122
+ if (cyclic) {
64123
+ for (k = (j + 1) % m; k !== j; k = (k + 1) % m) {
64124
+ d = t[k] - t[j];
64125
+ if (d < 0) d += L; // forward arc distance
64126
+ if (d >= W) break;
64127
+ if (turns[k] > turns[j] || (turns[k] === turns[j] && k < j)) return false;
64128
+ }
64129
+ for (k = (j - 1 + m) % m; k !== j; k = (k - 1 + m) % m) {
64130
+ d = t[j] - t[k];
64131
+ if (d < 0) d += L; // backward arc distance
64132
+ if (d >= W) break;
64133
+ if (turns[k] > turns[j] || (turns[k] === turns[j] && k < j)) return false;
63963
64134
  }
64135
+ return true;
64136
+ }
64137
+ for (k = j + 1; k < hi; k++) {
64138
+ if (t[k] - t[j] >= W) break;
64139
+ if (turns[k] > turns[j] || (turns[k] === turns[j] && k < j)) return false;
64140
+ }
64141
+ for (k = j - 1; k >= lo; k--) {
64142
+ if (t[j] - t[k] >= W) break;
64143
+ if (turns[k] > turns[j] || (turns[k] === turns[j] && k < j)) return false;
63964
64144
  }
63965
64145
  return true;
63966
64146
  }
@@ -64114,6 +64294,20 @@ ${svg}
64114
64294
  // segments); user-overridable via max-bend-angle
64115
64295
  var DEVIATION_FACTOR = 0.1; // sagitta guard: also cut a gentle bend that bows
64116
64296
  // more than tolerance * this from its chord
64297
+ // Preserved structural runs (long straight / low-curvature spans between pinned
64298
+ // corners) are not smoothed, but their ORIGINAL vertices are resampled with the
64299
+ // same bend-angle decimation so the whole output has adaptive vertex spacing
64300
+ // (no abrupt density seam at a run boundary). The decimation runs in the
64301
+ // smoothing channels, so for unprojected data a long line -- which curves in the
64302
+ // geocentric x,y,z space even when it is "straight" in lng/lat -- keeps enough
64303
+ // interior vertices to approximate that curve on reprojection, scaling with the
64304
+ // line's length automatically. Because it only ever keeps a SUBSET of the
64305
+ // original vertices (never interpolates new ones), it can't distort a rhumb or
64306
+ // geodesic edge -- every output vertex still lies exactly where the source drew
64307
+ // it. Structural runs use a fraction of the bend angle (sampled finer than
64308
+ // smoothed spans) as a conservative bias toward preservation / reprojection
64309
+ // headroom.
64310
+ var STRUCTURAL_BEND_FACTOR = 0.5; // structural-run bend angle = max-bend-angle * this
64117
64311
 
64118
64312
  // Smooth a single arc's coordinates.
64119
64313
  // @xx, @yy: coordinate arrays (may be typed-array subarrays) for one arc.
@@ -64122,8 +64316,9 @@ ${svg}
64122
64316
  // arcs are preserved exactly (so shared topology nodes stay put); closed arcs
64123
64317
  // are smoothed cyclically and returned closed (first point repeated at the end).
64124
64318
  // With keepCorners, structural corners (where long straight/low-curvature runs
64125
- // meet) are detected and pinned, and the runs themselves are kept verbatim;
64126
- // only the spans between corners are smoothed.
64319
+ // meet) are detected and pinned; the runs themselves are not smoothed but are
64320
+ // resampled (a subset of their original vertices, at adaptive spacing), and only
64321
+ // the spans between corners are smoothed.
64127
64322
  // Resolve the curvature-correction gain (default 1 = fully corrected). gain=0
64128
64323
  // leaves the plain weighted moving average; negative values are clamped to 0.
64129
64324
  function resolveGain(opts) {
@@ -64173,7 +64368,7 @@ ${svg}
64173
64368
  var origY = toArray(yy);
64174
64369
  var tol = opts.tolerance * KERNEL_FROM_DISTANCE;
64175
64370
  if (n < 3 || !(opts.tolerance > 0)) {
64176
- return {xx: origX, yy: origY};
64371
+ return {xx: origX, yy: origY, corners: 0};
64177
64372
  }
64178
64373
  var method = opts.method == 'gaussian' ? 'gaussian' : 'paek';
64179
64374
  var closed = !!opts.closed;
@@ -64185,7 +64380,7 @@ ${svg}
64185
64380
  // kernel scale stays in true distance regardless of coordinate representation.
64186
64381
  var t = arcLengths(origX, origY, n, spherical);
64187
64382
  if (!(t[n - 1] > 0)) {
64188
- return {xx: origX, yy: origY}; // degenerate (coincident points)
64383
+ return {xx: origX, yy: origY, corners: 0}; // degenerate (coincident points)
64189
64384
  }
64190
64385
  // The low-pass kernel scale is the raw distance scale (tol) times the baked-in
64191
64386
  // KERNEL_STRENGTH calibration and the user's `strength` multiplier (default 1).
@@ -64235,7 +64430,9 @@ ${svg}
64235
64430
  corners = filterRingCornersByStructure(t, channels, n, corners, ringParams);
64236
64431
  }
64237
64432
  if (corners.length === 0) {
64238
- return smoothClosedCyclic(t, channels, n, ctx);
64433
+ var cyc = smoothClosedCyclic(t, channels, n, ctx);
64434
+ cyc.corners = 0;
64435
+ return cyc;
64239
64436
  }
64240
64437
  // A ring with corners is processed as an open path: rotate it to start (and
64241
64438
  // end) at one corner, with the remaining corners as interior breakpoints.
@@ -64246,7 +64443,11 @@ ${svg}
64246
64443
  t = arcLengths(origX, origY, n, spherical);
64247
64444
  channels = spherical ? lngLatToXYZChannels(origX, origY, n) : [origX, origY];
64248
64445
  var breaks = mapRotatedCorners(corners, rot.shift, rot.m);
64249
- return smoothOpenSpans(origX, origY, t, channels, n, breaks, ctx);
64446
+ var ring = smoothOpenSpans(origX, origY, t, channels, n, breaks, ctx);
64447
+ // The ring seam (corners[0], pinned as the rotated start/end) is itself a
64448
+ // preserved corner, on top of the interior breakpoints smoothOpenSpans kept.
64449
+ ring.corners += 1;
64450
+ return ring;
64250
64451
  }
64251
64452
 
64252
64453
  var openBreaks = keepCorners ?
@@ -64279,11 +64480,12 @@ ${svg}
64279
64480
  var lo = bounds[s], hi = bounds[s + 1];
64280
64481
  var preserve = !!params && isStructuralRun(t, channels, lo, hi, params);
64281
64482
  var span = preserve ?
64282
- copySpan(origX, origY, lo, hi) :
64483
+ resampleStructuralRun(origX, origY, channels, lo, hi, ctx) :
64283
64484
  smoothSpanOpen(origX, origY, t, channels, lo, hi, ctx);
64284
64485
  appendSpan(xx, yy, span, s === 0);
64285
64486
  }
64286
- return {xx: xx, yy: yy};
64487
+ // Interior breakpoints that survived refinement are the pinned corners.
64488
+ return {xx: xx, yy: yy, corners: bounds.length - 2};
64287
64489
  }
64288
64490
 
64289
64491
  // Drop interior breakpoints that don't border any pinnable straight run (e.g.
@@ -64311,7 +64513,14 @@ ${svg}
64311
64513
  if (!leftStruct && !rightStruct) {
64312
64514
  bounds.splice(i, 1);
64313
64515
  changed = true;
64314
- break;
64516
+ // Removing bounds[i] only changes the spans of its two neighbours; the
64517
+ // breakpoints before them stay stable, so resume the scan just before the
64518
+ // removal (rechecking the left neighbour) instead of restarting from the
64519
+ // start. Restarting made this O(breaks^2) -- a hang on a large ring where
64520
+ // the smoothing distance is finer than the vertex spacing and nearly
64521
+ // every vertex reads as a breakpoint. The removal sequence is identical to
64522
+ // a from-scratch rescan (the stable prefix is never revisited).
64523
+ i = i < 2 ? 0 : i - 2;
64315
64524
  }
64316
64525
  }
64317
64526
  }
@@ -64344,7 +64553,15 @@ ${svg}
64344
64553
  if (!leftStruct && !rightStruct) {
64345
64554
  list.splice(i, 1);
64346
64555
  changed = true;
64347
- break;
64556
+ // As in refineBounds, only the removed corner's neighbours change, so
64557
+ // resume just before the removal (i-- via i-2) rather than restarting the
64558
+ // scan -- restarting is what made this O(corners^2) and hung on large
64559
+ // rings smoothed below their vertex spacing (~half the vertices read as
64560
+ // corners, nearly all culled). Cross-seam effects (the wrap between the
64561
+ // last and first corner) are picked up by the outer while(changed) pass.
64562
+ // The stable prefix is never revisited, so the surviving set matches a
64563
+ // from-scratch rescan exactly.
64564
+ i = i < 2 ? -1 : i - 2;
64348
64565
  }
64349
64566
  }
64350
64567
  }
@@ -64494,6 +64711,34 @@ ${svg}
64494
64711
  return {xx: xx, yy: yy};
64495
64712
  }
64496
64713
 
64714
+ // Resample a preserved structural run [lo, hi] (a long straight / low-curvature
64715
+ // span between pinned corners). The run is NOT smoothed: its shape is kept by
64716
+ // emitting a SUBSET of its original vertices, decimated with the shared
64717
+ // bend-angle filter in the smoothing channels (so a long line that curves in the
64718
+ // geocentric space keeps interior vertices scaling with its length -- see
64719
+ // STRUCTURAL_BEND_FACTOR). Both endpoints are always kept, at their exact
64720
+ // original coordinates, so pinned corners and shared topology nodes are
64721
+ // unchanged. A run too short to decimate is copied verbatim.
64722
+ function resampleStructuralRun(origX, origY, channels, lo, hi, ctx) {
64723
+ var nSub = hi - lo + 1;
64724
+ if (nSub < 3) return copySpan(origX, origY, lo, hi);
64725
+ var K = channels.length;
64726
+ var P = new Array(nSub);
64727
+ for (var i = 0; i < nSub; i++) {
64728
+ var p = new Array(K);
64729
+ for (var c = 0; c < K; c++) p[c] = channels[c][lo + i];
64730
+ P[i] = p;
64731
+ }
64732
+ var keep = decimateByBend(P, K, ctx.bendAngle * STRUCTURAL_BEND_FACTOR, ctx.tol * DEVIATION_FACTOR);
64733
+ var xx = [], yy = [];
64734
+ for (var ki = 0; ki < keep.length; ki++) {
64735
+ var idx = lo + keep[ki];
64736
+ xx.push(origX[idx]); // exact original coordinate, never interpolated
64737
+ yy.push(origY[idx]);
64738
+ }
64739
+ return {xx: xx, yy: yy};
64740
+ }
64741
+
64497
64742
  function appendSpan(xx, yy, span, isFirst) {
64498
64743
  for (var i = isFirst ? 0 : 1; i < span.xx.length; i++) {
64499
64744
  xx.push(span.xx[i]);
@@ -64618,26 +64863,38 @@ ${svg}
64618
64863
  }
64619
64864
 
64620
64865
  // 2. one-pass bend-angle filter
64621
- var theta = ctx.bendAngle;
64622
- var epsDev = ctx.tol * DEVIATION_FACTOR;
64866
+ var keep = decimateByBend(P, K, ctx.bendAngle, ctx.tol * DEVIATION_FACTOR);
64623
64867
  var out = [];
64624
64868
  for (var c = 0; c < K; c++) out.push([]);
64625
- appendPoint(out, P[0], K);
64869
+ for (var ki = 0; ki < keep.length; ki++) appendPoint(out, P[keep[ki]], K);
64870
+ return out;
64871
+ }
64872
+
64873
+ // One-pass forward decimation of a K-channel point list @P: keep the two
64874
+ // endpoints plus every interior point where the turn accumulated since the last
64875
+ // kept point reaches @theta, or where the estimated sagitta of the skipped
64876
+ // stretch (chord * accumulated turn / 8, the bow of a circular arc) reaches
64877
+ // @epsDev. Bounds the angle between consecutive kept segments by construction, so
64878
+ // joins stay smooth. Returns the kept indices into @P (always including 0 and the
64879
+ // last index). Shared by the smoothed-curve resampler and the structural-run
64880
+ // resampler (see resampleStructuralRun).
64881
+ function decimateByBend(P, K, theta, epsDev) {
64882
+ var n = P.length;
64883
+ var keep = [0];
64884
+ if (n < 2) return keep;
64626
64885
  var anchor = 0; // last kept vertex
64627
64886
  var accTurn = 0; // absolute turning accumulated since the anchor
64628
- for (var j = 1; j < nDense - 1; j++) {
64887
+ for (var j = 1; j < n - 1; j++) {
64629
64888
  accTurn += vecAngle(P[j - 1], P[j], P[j], P[j + 1], K);
64630
- // sagitta of a circular arc of chord c and total turn a is ~ c*a/8; cut a
64631
- // long gentle bend before it bows more than epsDev from its chord
64632
64889
  var sagitta = chordLen(P[anchor], P[j + 1], K) * accTurn * 0.125;
64633
64890
  if (accTurn >= theta || sagitta >= epsDev) {
64634
- appendPoint(out, P[j], K);
64891
+ keep.push(j);
64635
64892
  anchor = j;
64636
64893
  accTurn = 0;
64637
64894
  }
64638
64895
  }
64639
- appendPoint(out, P[nDense - 1], K);
64640
- return out;
64896
+ keep.push(n - 1);
64897
+ return keep;
64641
64898
  }
64642
64899
 
64643
64900
  // Angle (radians) between vectors (b - a) and (d - c) over K channels.
@@ -64958,16 +65215,32 @@ ${svg}
64958
65215
  }
64959
65216
  }
64960
65217
 
64961
- smoothPaths(arcs, {
65218
+ // Corner detection is automatically coarsened on geometry that is sparse
65219
+ // relative to the smoothing distance (long segments -> few segments per
65220
+ // detection window -> ordinary coarse bends misread as corners; see
65221
+ // autoCornerBias). The user's corner-bias is relative to this automatic base:
65222
+ // it is added on top, so corner-bias=0 (the default) is "whatever the geometry
65223
+ // warrants", a positive value finds more corners than the auto baseline, a
65224
+ // negative value fewer.
65225
+ var autoBias = 0;
65226
+ if (keepCorners) {
65227
+ autoBias = autoCornerBias(medianSegmentLength(arcs, spherical), tolerance);
65228
+ }
65229
+ var effectiveCornerBias = autoBias + (opts.corner_bias || 0);
65230
+
65231
+ var corners = smoothPaths(arcs, {
64962
65232
  tolerance: tolerance,
64963
65233
  method: method,
64964
65234
  spherical: spherical,
64965
65235
  keepCorners: keepCorners,
64966
- cornerBias: opts.corner_bias,
65236
+ cornerBias: effectiveCornerBias,
64967
65237
  gain: opts.gain,
64968
65238
  strength: opts.strength,
64969
65239
  maxBendAngle: opts.max_bend_angle
64970
65240
  });
65241
+ if (keepCorners && corners > 0) {
65242
+ message('Pinned ' + corners + ' corner' + utils.pluralSuffix(corners));
65243
+ }
64971
65244
 
64972
65245
  if (implicitlySmoothedNames.length > 0) {
64973
65246
  message(
@@ -64981,10 +65254,12 @@ ${svg}
64981
65254
  // untouched, shared polygon boundaries stay coincident and topology is
64982
65255
  // preserved; updateVertexData() also handles undo capture and resets stale
64983
65256
  // simplification thresholds.
65257
+ // Returns the total number of structural corners preserved across all arcs.
64984
65258
  function smoothPaths(arcs, opts) {
64985
65259
  var nn = [];
64986
65260
  var xx = [];
64987
65261
  var yy = [];
65262
+ var corners = 0;
64988
65263
  var i, k, res;
64989
65264
  arcs.forEach3(function(axx, ayy, azz, arcId) {
64990
65265
  res = smoothArcCoords(axx, ayy, {
@@ -64998,6 +65273,7 @@ ${svg}
64998
65273
  maxBendAngle: opts.maxBendAngle,
64999
65274
  closed: arcs.arcIsClosed(arcId)
65000
65275
  });
65276
+ corners += res.corners || 0;
65001
65277
  nn.push(res.xx.length);
65002
65278
  for (i = 0, k = res.xx.length; i < k; i++) {
65003
65279
  xx.push(res.xx[i]);
@@ -65005,6 +65281,31 @@ ${svg}
65005
65281
  }
65006
65282
  });
65007
65283
  arcs.updateVertexData(nn, xx, yy);
65284
+ return corners;
65285
+ }
65286
+
65287
+ // Median segment length across all arcs, in ground units (meters for spherical
65288
+ // data), used to gauge how coarse the geometry is relative to the smoothing
65289
+ // distance (see autoCornerBias). The median is robust to a few very long straight
65290
+ // segments (which are not a spurious-corner risk) and to dense sub-scale detail.
65291
+ // Very large datasets are sampled with a stride so the cost stays bounded.
65292
+ function medianSegmentLength(arcs, spherical) {
65293
+ var totalSegs = arcs.getPointCount() - arcs.size();
65294
+ if (totalSegs < 1) return 0;
65295
+ var MAX_SAMPLES = 100000;
65296
+ var stride = Math.ceil(totalSegs / MAX_SAMPLES);
65297
+ var distFn = spherical ? greatCircleDistance : distance2D;
65298
+ var lens = [];
65299
+ var counter = 0;
65300
+ arcs.forEach3(function(xx, yy) {
65301
+ for (var i = 1, n = xx.length; i < n; i++) {
65302
+ if (counter++ % stride === 0) {
65303
+ lens.push(distFn(xx[i - 1], yy[i - 1], xx[i], yy[i]));
65304
+ }
65305
+ }
65306
+ });
65307
+ if (lens.length === 0) return 0;
65308
+ return utils.findMedian(lens);
65008
65309
  }
65009
65310
 
65010
65311
  function getSmoothMethod(opts) {
@@ -66812,7 +67113,7 @@ ${svg}
66812
67113
  return name == 'rectangle' || name == 'rectangles' || name == 'filter' && opts.cleanup;
66813
67114
  }
66814
67115
 
66815
- var version = "0.7.36";
67116
+ var version = "0.7.38";
66816
67117
 
66817
67118
  // Parse command line args into commands and run them
66818
67119
  // Function takes an optional Node-style callback. A Promise is returned if no callback is given.