mapshaper 0.7.37 → 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 +295 -34
  2. package/package.json +1 -1
  3. package/www/mapshaper.js +295 -34
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
@@ -63763,9 +63820,107 @@ ${svg}
63763
63820
  if (inner[j] < params.concentration * turns[j]) continue;
63764
63821
  if (isLocalMaxTurn(t, turns, j, W, L, m, lo, hi, cyclic)) corners.push(j);
63765
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
+ }
63766
63854
  return corners;
63767
63855
  }
63768
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
+
63769
63924
  // Is span [a, b] (inclusive vertex indices, a < b, open frame) a structural run:
63770
63925
  // long relative to the tolerance and low-curvature throughout?
63771
63926
  function isStructuralRun(t, channels, a, b, params) {
@@ -64139,6 +64294,20 @@ ${svg}
64139
64294
  // segments); user-overridable via max-bend-angle
64140
64295
  var DEVIATION_FACTOR = 0.1; // sagitta guard: also cut a gentle bend that bows
64141
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
64142
64311
 
64143
64312
  // Smooth a single arc's coordinates.
64144
64313
  // @xx, @yy: coordinate arrays (may be typed-array subarrays) for one arc.
@@ -64147,8 +64316,9 @@ ${svg}
64147
64316
  // arcs are preserved exactly (so shared topology nodes stay put); closed arcs
64148
64317
  // are smoothed cyclically and returned closed (first point repeated at the end).
64149
64318
  // With keepCorners, structural corners (where long straight/low-curvature runs
64150
- // meet) are detected and pinned, and the runs themselves are kept verbatim;
64151
- // 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.
64152
64322
  // Resolve the curvature-correction gain (default 1 = fully corrected). gain=0
64153
64323
  // leaves the plain weighted moving average; negative values are clamped to 0.
64154
64324
  function resolveGain(opts) {
@@ -64198,7 +64368,7 @@ ${svg}
64198
64368
  var origY = toArray(yy);
64199
64369
  var tol = opts.tolerance * KERNEL_FROM_DISTANCE;
64200
64370
  if (n < 3 || !(opts.tolerance > 0)) {
64201
- return {xx: origX, yy: origY};
64371
+ return {xx: origX, yy: origY, corners: 0};
64202
64372
  }
64203
64373
  var method = opts.method == 'gaussian' ? 'gaussian' : 'paek';
64204
64374
  var closed = !!opts.closed;
@@ -64210,7 +64380,7 @@ ${svg}
64210
64380
  // kernel scale stays in true distance regardless of coordinate representation.
64211
64381
  var t = arcLengths(origX, origY, n, spherical);
64212
64382
  if (!(t[n - 1] > 0)) {
64213
- return {xx: origX, yy: origY}; // degenerate (coincident points)
64383
+ return {xx: origX, yy: origY, corners: 0}; // degenerate (coincident points)
64214
64384
  }
64215
64385
  // The low-pass kernel scale is the raw distance scale (tol) times the baked-in
64216
64386
  // KERNEL_STRENGTH calibration and the user's `strength` multiplier (default 1).
@@ -64260,7 +64430,9 @@ ${svg}
64260
64430
  corners = filterRingCornersByStructure(t, channels, n, corners, ringParams);
64261
64431
  }
64262
64432
  if (corners.length === 0) {
64263
- return smoothClosedCyclic(t, channels, n, ctx);
64433
+ var cyc = smoothClosedCyclic(t, channels, n, ctx);
64434
+ cyc.corners = 0;
64435
+ return cyc;
64264
64436
  }
64265
64437
  // A ring with corners is processed as an open path: rotate it to start (and
64266
64438
  // end) at one corner, with the remaining corners as interior breakpoints.
@@ -64271,7 +64443,11 @@ ${svg}
64271
64443
  t = arcLengths(origX, origY, n, spherical);
64272
64444
  channels = spherical ? lngLatToXYZChannels(origX, origY, n) : [origX, origY];
64273
64445
  var breaks = mapRotatedCorners(corners, rot.shift, rot.m);
64274
- 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;
64275
64451
  }
64276
64452
 
64277
64453
  var openBreaks = keepCorners ?
@@ -64304,11 +64480,12 @@ ${svg}
64304
64480
  var lo = bounds[s], hi = bounds[s + 1];
64305
64481
  var preserve = !!params && isStructuralRun(t, channels, lo, hi, params);
64306
64482
  var span = preserve ?
64307
- copySpan(origX, origY, lo, hi) :
64483
+ resampleStructuralRun(origX, origY, channels, lo, hi, ctx) :
64308
64484
  smoothSpanOpen(origX, origY, t, channels, lo, hi, ctx);
64309
64485
  appendSpan(xx, yy, span, s === 0);
64310
64486
  }
64311
- 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};
64312
64489
  }
64313
64490
 
64314
64491
  // Drop interior breakpoints that don't border any pinnable straight run (e.g.
@@ -64534,6 +64711,34 @@ ${svg}
64534
64711
  return {xx: xx, yy: yy};
64535
64712
  }
64536
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
+
64537
64742
  function appendSpan(xx, yy, span, isFirst) {
64538
64743
  for (var i = isFirst ? 0 : 1; i < span.xx.length; i++) {
64539
64744
  xx.push(span.xx[i]);
@@ -64658,26 +64863,38 @@ ${svg}
64658
64863
  }
64659
64864
 
64660
64865
  // 2. one-pass bend-angle filter
64661
- var theta = ctx.bendAngle;
64662
- var epsDev = ctx.tol * DEVIATION_FACTOR;
64866
+ var keep = decimateByBend(P, K, ctx.bendAngle, ctx.tol * DEVIATION_FACTOR);
64663
64867
  var out = [];
64664
64868
  for (var c = 0; c < K; c++) out.push([]);
64665
- 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;
64666
64885
  var anchor = 0; // last kept vertex
64667
64886
  var accTurn = 0; // absolute turning accumulated since the anchor
64668
- for (var j = 1; j < nDense - 1; j++) {
64887
+ for (var j = 1; j < n - 1; j++) {
64669
64888
  accTurn += vecAngle(P[j - 1], P[j], P[j], P[j + 1], K);
64670
- // sagitta of a circular arc of chord c and total turn a is ~ c*a/8; cut a
64671
- // long gentle bend before it bows more than epsDev from its chord
64672
64889
  var sagitta = chordLen(P[anchor], P[j + 1], K) * accTurn * 0.125;
64673
64890
  if (accTurn >= theta || sagitta >= epsDev) {
64674
- appendPoint(out, P[j], K);
64891
+ keep.push(j);
64675
64892
  anchor = j;
64676
64893
  accTurn = 0;
64677
64894
  }
64678
64895
  }
64679
- appendPoint(out, P[nDense - 1], K);
64680
- return out;
64896
+ keep.push(n - 1);
64897
+ return keep;
64681
64898
  }
64682
64899
 
64683
64900
  // Angle (radians) between vectors (b - a) and (d - c) over K channels.
@@ -64998,16 +65215,32 @@ ${svg}
64998
65215
  }
64999
65216
  }
65000
65217
 
65001
- 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, {
65002
65232
  tolerance: tolerance,
65003
65233
  method: method,
65004
65234
  spherical: spherical,
65005
65235
  keepCorners: keepCorners,
65006
- cornerBias: opts.corner_bias,
65236
+ cornerBias: effectiveCornerBias,
65007
65237
  gain: opts.gain,
65008
65238
  strength: opts.strength,
65009
65239
  maxBendAngle: opts.max_bend_angle
65010
65240
  });
65241
+ if (keepCorners && corners > 0) {
65242
+ message('Pinned ' + corners + ' corner' + utils.pluralSuffix(corners));
65243
+ }
65011
65244
 
65012
65245
  if (implicitlySmoothedNames.length > 0) {
65013
65246
  message(
@@ -65021,10 +65254,12 @@ ${svg}
65021
65254
  // untouched, shared polygon boundaries stay coincident and topology is
65022
65255
  // preserved; updateVertexData() also handles undo capture and resets stale
65023
65256
  // simplification thresholds.
65257
+ // Returns the total number of structural corners preserved across all arcs.
65024
65258
  function smoothPaths(arcs, opts) {
65025
65259
  var nn = [];
65026
65260
  var xx = [];
65027
65261
  var yy = [];
65262
+ var corners = 0;
65028
65263
  var i, k, res;
65029
65264
  arcs.forEach3(function(axx, ayy, azz, arcId) {
65030
65265
  res = smoothArcCoords(axx, ayy, {
@@ -65038,6 +65273,7 @@ ${svg}
65038
65273
  maxBendAngle: opts.maxBendAngle,
65039
65274
  closed: arcs.arcIsClosed(arcId)
65040
65275
  });
65276
+ corners += res.corners || 0;
65041
65277
  nn.push(res.xx.length);
65042
65278
  for (i = 0, k = res.xx.length; i < k; i++) {
65043
65279
  xx.push(res.xx[i]);
@@ -65045,6 +65281,31 @@ ${svg}
65045
65281
  }
65046
65282
  });
65047
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);
65048
65309
  }
65049
65310
 
65050
65311
  function getSmoothMethod(opts) {
@@ -66852,7 +67113,7 @@ ${svg}
66852
67113
  return name == 'rectangle' || name == 'rectangles' || name == 'filter' && opts.cleanup;
66853
67114
  }
66854
67115
 
66855
- var version = "0.7.37";
67116
+ var version = "0.7.38";
66856
67117
 
66857
67118
  // Parse command line args into commands and run them
66858
67119
  // Function takes an optional Node-style callback. A Promise is returned if no callback is given.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mapshaper",
3
- "version": "0.7.37",
3
+ "version": "0.7.38",
4
4
  "description": "A tool for editing geospatial data for mapping and GIS.",
5
5
  "keywords": [
6
6
  "shapefile",
package/www/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
@@ -63763,9 +63820,107 @@ ${svg}
63763
63820
  if (inner[j] < params.concentration * turns[j]) continue;
63764
63821
  if (isLocalMaxTurn(t, turns, j, W, L, m, lo, hi, cyclic)) corners.push(j);
63765
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
+ }
63766
63854
  return corners;
63767
63855
  }
63768
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
+
63769
63924
  // Is span [a, b] (inclusive vertex indices, a < b, open frame) a structural run:
63770
63925
  // long relative to the tolerance and low-curvature throughout?
63771
63926
  function isStructuralRun(t, channels, a, b, params) {
@@ -64139,6 +64294,20 @@ ${svg}
64139
64294
  // segments); user-overridable via max-bend-angle
64140
64295
  var DEVIATION_FACTOR = 0.1; // sagitta guard: also cut a gentle bend that bows
64141
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
64142
64311
 
64143
64312
  // Smooth a single arc's coordinates.
64144
64313
  // @xx, @yy: coordinate arrays (may be typed-array subarrays) for one arc.
@@ -64147,8 +64316,9 @@ ${svg}
64147
64316
  // arcs are preserved exactly (so shared topology nodes stay put); closed arcs
64148
64317
  // are smoothed cyclically and returned closed (first point repeated at the end).
64149
64318
  // With keepCorners, structural corners (where long straight/low-curvature runs
64150
- // meet) are detected and pinned, and the runs themselves are kept verbatim;
64151
- // 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.
64152
64322
  // Resolve the curvature-correction gain (default 1 = fully corrected). gain=0
64153
64323
  // leaves the plain weighted moving average; negative values are clamped to 0.
64154
64324
  function resolveGain(opts) {
@@ -64198,7 +64368,7 @@ ${svg}
64198
64368
  var origY = toArray(yy);
64199
64369
  var tol = opts.tolerance * KERNEL_FROM_DISTANCE;
64200
64370
  if (n < 3 || !(opts.tolerance > 0)) {
64201
- return {xx: origX, yy: origY};
64371
+ return {xx: origX, yy: origY, corners: 0};
64202
64372
  }
64203
64373
  var method = opts.method == 'gaussian' ? 'gaussian' : 'paek';
64204
64374
  var closed = !!opts.closed;
@@ -64210,7 +64380,7 @@ ${svg}
64210
64380
  // kernel scale stays in true distance regardless of coordinate representation.
64211
64381
  var t = arcLengths(origX, origY, n, spherical);
64212
64382
  if (!(t[n - 1] > 0)) {
64213
- return {xx: origX, yy: origY}; // degenerate (coincident points)
64383
+ return {xx: origX, yy: origY, corners: 0}; // degenerate (coincident points)
64214
64384
  }
64215
64385
  // The low-pass kernel scale is the raw distance scale (tol) times the baked-in
64216
64386
  // KERNEL_STRENGTH calibration and the user's `strength` multiplier (default 1).
@@ -64260,7 +64430,9 @@ ${svg}
64260
64430
  corners = filterRingCornersByStructure(t, channels, n, corners, ringParams);
64261
64431
  }
64262
64432
  if (corners.length === 0) {
64263
- return smoothClosedCyclic(t, channels, n, ctx);
64433
+ var cyc = smoothClosedCyclic(t, channels, n, ctx);
64434
+ cyc.corners = 0;
64435
+ return cyc;
64264
64436
  }
64265
64437
  // A ring with corners is processed as an open path: rotate it to start (and
64266
64438
  // end) at one corner, with the remaining corners as interior breakpoints.
@@ -64271,7 +64443,11 @@ ${svg}
64271
64443
  t = arcLengths(origX, origY, n, spherical);
64272
64444
  channels = spherical ? lngLatToXYZChannels(origX, origY, n) : [origX, origY];
64273
64445
  var breaks = mapRotatedCorners(corners, rot.shift, rot.m);
64274
- 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;
64275
64451
  }
64276
64452
 
64277
64453
  var openBreaks = keepCorners ?
@@ -64304,11 +64480,12 @@ ${svg}
64304
64480
  var lo = bounds[s], hi = bounds[s + 1];
64305
64481
  var preserve = !!params && isStructuralRun(t, channels, lo, hi, params);
64306
64482
  var span = preserve ?
64307
- copySpan(origX, origY, lo, hi) :
64483
+ resampleStructuralRun(origX, origY, channels, lo, hi, ctx) :
64308
64484
  smoothSpanOpen(origX, origY, t, channels, lo, hi, ctx);
64309
64485
  appendSpan(xx, yy, span, s === 0);
64310
64486
  }
64311
- 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};
64312
64489
  }
64313
64490
 
64314
64491
  // Drop interior breakpoints that don't border any pinnable straight run (e.g.
@@ -64534,6 +64711,34 @@ ${svg}
64534
64711
  return {xx: xx, yy: yy};
64535
64712
  }
64536
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
+
64537
64742
  function appendSpan(xx, yy, span, isFirst) {
64538
64743
  for (var i = isFirst ? 0 : 1; i < span.xx.length; i++) {
64539
64744
  xx.push(span.xx[i]);
@@ -64658,26 +64863,38 @@ ${svg}
64658
64863
  }
64659
64864
 
64660
64865
  // 2. one-pass bend-angle filter
64661
- var theta = ctx.bendAngle;
64662
- var epsDev = ctx.tol * DEVIATION_FACTOR;
64866
+ var keep = decimateByBend(P, K, ctx.bendAngle, ctx.tol * DEVIATION_FACTOR);
64663
64867
  var out = [];
64664
64868
  for (var c = 0; c < K; c++) out.push([]);
64665
- 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;
64666
64885
  var anchor = 0; // last kept vertex
64667
64886
  var accTurn = 0; // absolute turning accumulated since the anchor
64668
- for (var j = 1; j < nDense - 1; j++) {
64887
+ for (var j = 1; j < n - 1; j++) {
64669
64888
  accTurn += vecAngle(P[j - 1], P[j], P[j], P[j + 1], K);
64670
- // sagitta of a circular arc of chord c and total turn a is ~ c*a/8; cut a
64671
- // long gentle bend before it bows more than epsDev from its chord
64672
64889
  var sagitta = chordLen(P[anchor], P[j + 1], K) * accTurn * 0.125;
64673
64890
  if (accTurn >= theta || sagitta >= epsDev) {
64674
- appendPoint(out, P[j], K);
64891
+ keep.push(j);
64675
64892
  anchor = j;
64676
64893
  accTurn = 0;
64677
64894
  }
64678
64895
  }
64679
- appendPoint(out, P[nDense - 1], K);
64680
- return out;
64896
+ keep.push(n - 1);
64897
+ return keep;
64681
64898
  }
64682
64899
 
64683
64900
  // Angle (radians) between vectors (b - a) and (d - c) over K channels.
@@ -64998,16 +65215,32 @@ ${svg}
64998
65215
  }
64999
65216
  }
65000
65217
 
65001
- 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, {
65002
65232
  tolerance: tolerance,
65003
65233
  method: method,
65004
65234
  spherical: spherical,
65005
65235
  keepCorners: keepCorners,
65006
- cornerBias: opts.corner_bias,
65236
+ cornerBias: effectiveCornerBias,
65007
65237
  gain: opts.gain,
65008
65238
  strength: opts.strength,
65009
65239
  maxBendAngle: opts.max_bend_angle
65010
65240
  });
65241
+ if (keepCorners && corners > 0) {
65242
+ message('Pinned ' + corners + ' corner' + utils.pluralSuffix(corners));
65243
+ }
65011
65244
 
65012
65245
  if (implicitlySmoothedNames.length > 0) {
65013
65246
  message(
@@ -65021,10 +65254,12 @@ ${svg}
65021
65254
  // untouched, shared polygon boundaries stay coincident and topology is
65022
65255
  // preserved; updateVertexData() also handles undo capture and resets stale
65023
65256
  // simplification thresholds.
65257
+ // Returns the total number of structural corners preserved across all arcs.
65024
65258
  function smoothPaths(arcs, opts) {
65025
65259
  var nn = [];
65026
65260
  var xx = [];
65027
65261
  var yy = [];
65262
+ var corners = 0;
65028
65263
  var i, k, res;
65029
65264
  arcs.forEach3(function(axx, ayy, azz, arcId) {
65030
65265
  res = smoothArcCoords(axx, ayy, {
@@ -65038,6 +65273,7 @@ ${svg}
65038
65273
  maxBendAngle: opts.maxBendAngle,
65039
65274
  closed: arcs.arcIsClosed(arcId)
65040
65275
  });
65276
+ corners += res.corners || 0;
65041
65277
  nn.push(res.xx.length);
65042
65278
  for (i = 0, k = res.xx.length; i < k; i++) {
65043
65279
  xx.push(res.xx[i]);
@@ -65045,6 +65281,31 @@ ${svg}
65045
65281
  }
65046
65282
  });
65047
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);
65048
65309
  }
65049
65310
 
65050
65311
  function getSmoothMethod(opts) {
@@ -66852,7 +67113,7 @@ ${svg}
66852
67113
  return name == 'rectangle' || name == 'rectangles' || name == 'filter' && opts.cleanup;
66853
67114
  }
66854
67115
 
66855
- var version = "0.7.37";
67116
+ var version = "0.7.38";
66856
67117
 
66857
67118
  // Parse command line args into commands and run them
66858
67119
  // Function takes an optional Node-style callback. A Promise is returned if no callback is given.