mapshaper 0.7.37 → 0.7.39

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 +837 -40
  2. package/package.json +1 -1
  3. package/www/mapshaper.js +837 -40
package/www/mapshaper.js CHANGED
@@ -31882,6 +31882,7 @@ ${svg}
31882
31882
  type: 'flag'
31883
31883
  })
31884
31884
  .option('polar', {
31885
+ // special polar handling is activated automatically now, so I'm making this an undocumented option.
31885
31886
  // describe: 'keep lat-long buffers within the valid extent (+/-180, +/-90); for growing polygons sliced at the antimeridian/poles (erode not yet supported)',
31886
31887
  type: 'flag'
31887
31888
  })
@@ -32233,6 +32234,26 @@ ${svg}
32233
32234
  describe: 'one or more assignment expressions (comma-sep.)'
32234
32235
  });
32235
32236
 
32237
+ parser.command('densify')
32238
+ .describe('add vertices along path segments so no segment exceeds an interval')
32239
+ .option('interval', {
32240
+ DEFAULT: true,
32241
+ describe: 'max segment length in distance units or degrees for lat-long data in planar mode'
32242
+ })
32243
+ .option('geodesic', {
32244
+ describe: '[lat-long, default] interpolate along the ellipsoidal shortest path',
32245
+ type: 'flag'
32246
+ })
32247
+ .option('rhumb', {
32248
+ describe: '[lat-long] interpolate along a rhumb line (constant bearing)',
32249
+ type: 'flag'
32250
+ })
32251
+ .option('planar', {
32252
+ describe: '[default for projected data] interpolate along a straight line in coordinate space',
32253
+ type: 'flag'
32254
+ })
32255
+ .option('target', targetOpt);
32256
+
32236
32257
  parser.command('dissolve')
32237
32258
  .describe('merge features within a layer (repairs polygon topology)')
32238
32259
  .option('field', {}) // old arg handled by dissolve function
@@ -33076,15 +33097,18 @@ ${svg}
33076
33097
  type: 'flag'
33077
33098
  })
33078
33099
  .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)',
33100
+ // Sensitivity of corner detection, RELATIVE to an automatic baseline
33101
+ // (default 0 = the automatic setting). Detection is auto-coarsened on
33102
+ // geometry that is sparse relative to the smoothing distance (long segments
33103
+ // read ordinary coarse bends as corners); this option adds to that base.
33104
+ // Positive keeps more corners, negative fewer; it scales only the distance-
33105
+ // proportional detection parameters (not angles), detecting corners as if
33106
+ // the distance were divided by k, where k = bias+1 for bias >= 0 and
33107
+ // 1/(1-bias) for bias < 0, and the smoothing kernel keeps using the real
33108
+ // distance. So on fine data (no auto adjustment) corner-bias=-1 finds the
33109
+ // corners a 2x distance would; on coarse data a positive value counteracts
33110
+ // the automatic coarsening. Use no-corners to turn preservation off entirely.
33111
+ describe: 'corner-detection sensitivity relative to auto (default 0; + more, - fewer)',
33088
33112
  type: 'number'
33089
33113
  })
33090
33114
  .option('prefilter-gate', {
@@ -43238,7 +43262,7 @@ ${svg}
43238
43262
  return x;
43239
43263
  }
43240
43264
 
43241
- var POLAR_BUFFER_MARGIN_DEGREES = 1e-4;
43265
+ var POLAR_BUFFER_MARGIN_DEGREES$1 = 1e-4;
43242
43266
 
43243
43267
 
43244
43268
  // Returns a function for generating GeoJSON MultiPolygon geometries
@@ -43303,9 +43327,9 @@ ${svg}
43303
43327
  maxAbsLat = Math.max(maxAbsLat, Math.abs(latLngPathIter.y));
43304
43328
  }
43305
43329
  });
43306
- if (maxAbsLat + angularDist >= 90 - POLAR_BUFFER_MARGIN_DEGREES) {
43330
+ if (maxAbsLat + angularDist >= 90 - POLAR_BUFFER_MARGIN_DEGREES$1) {
43307
43331
  stop$1('Buffering lat-long coordinates near the poles is not supported; ' +
43308
- 'use the polar option for polygons sliced at the antimeridian/poles.');
43332
+ 'positive polygon buffers that reach a pole use experimental polar handling.');
43309
43333
  }
43310
43334
  }
43311
43335
 
@@ -47692,6 +47716,32 @@ ${svg}
47692
47716
 
47693
47717
  function makePolygonBuffer(lyr, dataset, opts) {
47694
47718
  var spherical = isLatLngCRS(getDatasetCRS(dataset));
47719
+ if (spherical && sourceHasCollapsingBandEdge(lyr, dataset)) {
47720
+ // A ring edge that jumps a full 360 degrees of longitude (e.g. -180 -> 180)
47721
+ // with no intermediate vertices is ambiguous: the antimeridian unwrap reads
47722
+ // its delta as 0, collapsing the edge's endpoints onto one meridian in
47723
+ // Mercator, so the offset returns a sliver. (Pole-encircling rings and
47724
+ // pole floors are exempt -- see sourceHasCollapsingBandEdge.) Warn rather
47725
+ // than silently collapse; densifying the edge fixes it.
47726
+ warn('A polygon edge spans the full longitude range with no intermediate vertices and will collapse when buffered. Add vertices along the edge (e.g. densify it) before buffering.');
47727
+ }
47728
+ if (spherical && !opts.polar && sourceHasPoleEnclosingRing(lyr, dataset)) {
47729
+ // A lat-long polygon whose ring encircles a pole (e.g. an Antarctica shell)
47730
+ // has no closed planar representation: projected to Mercator and unwrapped
47731
+ // across the antimeridian, its endpoints land a full world-width apart, so
47732
+ // the offset construction reads the ring as an open line and returns a
47733
+ // two-sided ribbon instead of a grown polygon. Insert a floor along the
47734
+ // enclosed pole line so the ring becomes an ordinary closed polygon (the same
47735
+ // representation as a pole-touching shell) and route it through polar
47736
+ // handling, which grows it correctly and clips the result to the world rect.
47737
+ var normalized = buildPoleEnclosingNormalizedSource(lyr, dataset);
47738
+ if (normalized) {
47739
+ message('Buffering a polygon that encircles a pole; using experimental polar handling.');
47740
+ lyr = normalized.layer;
47741
+ dataset = normalized.dataset;
47742
+ opts = Object.assign({}, opts, {polar: true});
47743
+ }
47744
+ }
47695
47745
  // debug-mosaic is implemented only for line buffers; for polygons it has no
47696
47746
  // handling and would leak into the per-shape dissolve and corrupt output, so
47697
47747
  // drop it and warn rather than silently mislead.
@@ -47749,6 +47799,17 @@ ${svg}
47749
47799
  }
47750
47800
  return debugDataset;
47751
47801
  }
47802
+ if (spherical && !opts.polar && polygonBufferNeedsPolarMode(lyr, dataset, opts)) {
47803
+ message('Using experimental polar buffer mode because the buffer reaches a pole.');
47804
+ opts = Object.assign({}, opts, {polar: true});
47805
+ } else if (spherical && !opts.polar && polygonBufferWrapsAntimeridian(lyr, dataset, opts)) {
47806
+ // A ring that wraps the full longitude range (e.g. a band around the globe)
47807
+ // becomes a world-wide rectangle in Mercator whose long straight edges the
47808
+ // default offset construction collapses to thin seam caps. Route it through
47809
+ // polar handling, which pins the seam edges and clips to the world rect.
47810
+ message('Using experimental polar buffer mode because the geometry wraps around the antimeridian.');
47811
+ opts = Object.assign({}, opts, {polar: true});
47812
+ }
47752
47813
  if (spherical && opts.polar) {
47753
47814
  // Pole/antimeridian-sliced polygons (grow only): keep the seam edges at the
47754
47815
  // extent and clip to the world rectangle instead of wrapping at the
@@ -48260,11 +48321,276 @@ ${svg}
48260
48321
 
48261
48322
  // World rectangle (lng/lat) the polar buffer is clipped to.
48262
48323
  var POLAR_WORLD_BBOX = [-180, -90, 180, 90];
48324
+ var POLAR_BUFFER_MARGIN_DEGREES = 1e-4;
48325
+
48326
+ function polygonBufferNeedsPolarMode(lyr, dataset, opts) {
48327
+ if (!dataset.arcs) return false;
48328
+ var bounds = dataset.arcs.getBounds();
48329
+ var maxAbsLat = Math.max(Math.abs(bounds.ymin), Math.abs(bounds.ymax));
48330
+ var maxPositiveDistance = getMaxPositiveBufferDistance(lyr, dataset, opts);
48331
+ if (!(maxPositiveDistance > 0)) return false;
48332
+ return maxAbsLat + maxPositiveDistance / R$3 * R2D$1 >= 90 - POLAR_BUFFER_MARGIN_DEGREES;
48333
+ }
48334
+
48335
+ function getMaxPositiveBufferDistance(lyr, dataset, opts) {
48336
+ var distanceFn = getBufferDistanceFunction(lyr, dataset, opts);
48337
+ var max = 0, hasNegative = false;
48338
+ (lyr.shapes || []).forEach(function(shape, i) {
48339
+ if (!shape) return;
48340
+ var distance = distanceFn(i);
48341
+ if (distance < 0) hasNegative = true;
48342
+ if (distance > max) max = distance;
48343
+ });
48344
+ // Don't auto-enable polar mode for mixed or negative erode buffers: the polar
48345
+ // construction keeps pole seams pinned and currently supports grow buffers only.
48346
+ return hasNegative ? 0 : max;
48347
+ }
48348
+
48349
+ // True for a positive (grow) buffer whose source has a ring that wraps the full
48350
+ // longitude range (e.g. a band circling the globe). Such a ring is closed in
48351
+ // unwrapped Mercator (net winding 0, unlike a pole-encircling ring) but its
48352
+ // world-wide straight edges collapse in the default construction, so it needs
48353
+ // the seam-pinning polar path.
48354
+ function polygonBufferWrapsAntimeridian(lyr, dataset, opts) {
48355
+ if (!(getMaxPositiveBufferDistance(lyr, dataset, opts) > 0)) return false;
48356
+ return sourceHasFullLongitudeRing(lyr, dataset);
48357
+ }
48358
+
48359
+ // Minimum unwrapped longitude span (of 360) for a ring to count as wrapping the
48360
+ // full globe.
48361
+ var FULL_LONGITUDE_MIN_SPAN = 350;
48362
+
48363
+ // Raw (not unwrapped) longitude delta, in degrees, above which an edge is treated
48364
+ // as a full-turn "sweep" edge (endpoints on opposite antimeridian reps, e.g.
48365
+ // -180 -> 180). A genuine antimeridian crossing has a much smaller raw delta.
48366
+ var SWEEP_EDGE_MIN_RAW_DELTA = 359;
48367
+ // A vertex within this many degrees of +/-90 counts as sitting on a pole.
48368
+ var POLE_LATITUDE_EPS = 1e-3;
48369
+ // A ring is pole-encircling (handled by normalization, not a collapsing band)
48370
+ // when |net longitude winding| is near 360; treat anything below this as a band.
48371
+ var NON_ENCIRCLING_WINDING_MAX = 180;
48372
+
48373
+ // True if a ring has a full-longitude "sweep" edge that will collapse when
48374
+ // buffered: an edge whose raw longitude delta is ~360 and that does NOT sit on a
48375
+ // pole (a sweep edge along a pole line is a single point, so it is harmless).
48376
+ function ringHasCollapsingSweepEdge(ring) {
48377
+ if (!ring) return false;
48378
+ for (var i = 1; i < ring.length; i++) {
48379
+ var a = ring[i - 1], b = ring[i];
48380
+ if (Math.abs(b[0] - a[0]) < SWEEP_EDGE_MIN_RAW_DELTA) continue;
48381
+ if (Math.abs(a[1]) < 90 - POLE_LATITUDE_EPS ||
48382
+ Math.abs(b[1]) < 90 - POLE_LATITUDE_EPS) {
48383
+ return true;
48384
+ }
48385
+ }
48386
+ return false;
48387
+ }
48388
+
48389
+ // True if the source has a ring that will collapse when buffered because a
48390
+ // full-longitude edge has no intermediate vertices. Excludes pole-encircling
48391
+ // rings (|net winding| ~ 360), which are normalized and buffer correctly, and
48392
+ // sweep edges that lie on a pole line (harmless). Cheap O(1) gates first: the
48393
+ // dataset must reach both antimeridian extremes; only full-width shapes are
48394
+ // exported and scanned.
48395
+ function sourceHasCollapsingBandEdge(lyr, dataset) {
48396
+ if (!dataset.arcs) return false;
48397
+ var b = dataset.arcs.getBounds();
48398
+ if (b.xmin > -180 + 1e-6 || b.xmax < 180 - 1e-6) return false;
48399
+ return (lyr.shapes || []).some(function(shape) {
48400
+ if (!shape) return false;
48401
+ var sb = dataset.arcs.getMultiShapeBounds(shape);
48402
+ if (!sb || (sb.xmax - sb.xmin) < FULL_LONGITUDE_MIN_SPAN) return false;
48403
+ return getPolygonMultiPolygonCoords(shape, dataset.arcs).some(function(rings) {
48404
+ return rings.some(function(ring) {
48405
+ return Math.abs(ringLngWinding(ring)) < NON_ENCIRCLING_WINDING_MAX &&
48406
+ ringHasCollapsingSweepEdge(ring);
48407
+ });
48408
+ });
48409
+ });
48410
+ }
48411
+
48412
+ // True if any source ring spans (nearly) the full longitude range once its
48413
+ // longitudes are unwrapped across the antimeridian. Uses the same cheap gates as
48414
+ // sourceHasPoleEnclosingRing (dataset reaches the antimeridian; only wide shapes
48415
+ // are exported and scanned). A small polygon that merely crosses the antimeridian
48416
+ // unwraps to a narrow span and is correctly excluded.
48417
+ function sourceHasFullLongitudeRing(lyr, dataset) {
48418
+ if (!dataset.arcs) return false;
48419
+ var b = dataset.arcs.getBounds();
48420
+ if (b.xmin > -180 + 1e-6 && b.xmax < 180 - 1e-6) return false;
48421
+ return (lyr.shapes || []).some(function(shape) {
48422
+ if (!shape) return false;
48423
+ var sb = dataset.arcs.getMultiShapeBounds(shape);
48424
+ if (!sb || (sb.xmax - sb.xmin) < FULL_LONGITUDE_MIN_SPAN) return false;
48425
+ return getPolygonMultiPolygonCoords(shape, dataset.arcs).some(function(rings) {
48426
+ return rings.some(function(ring) {
48427
+ return ringUnwrappedLngSpan(ring) >= FULL_LONGITUDE_MIN_SPAN;
48428
+ });
48429
+ });
48430
+ });
48431
+ }
48432
+
48433
+ // Longitude extent of a ring after unwrapping across the antimeridian (each edge's
48434
+ // longitude delta reduced to (-180, 180], accumulated). A globe-wrapping ring
48435
+ // spans ~360; a small antimeridian-crossing polygon spans only its true width.
48436
+ function ringUnwrappedLngSpan(ring) {
48437
+ if (!ring || ring.length < 2) return 0;
48438
+ var x = 0, min = 0, max = 0;
48439
+ for (var i = 1; i < ring.length; i++) {
48440
+ var d = ring[i][0] - ring[i - 1][0];
48441
+ if (d > 180) d -= 360;
48442
+ else if (d < -180) d += 360;
48443
+ x += d;
48444
+ if (x < min) min = x;
48445
+ if (x > max) max = x;
48446
+ }
48447
+ return max - min;
48448
+ }
48449
+
48450
+ // A ring is at least this many degrees wide (full longitude range is 360) to be
48451
+ // considered a possible pole-encircling ring; skips the coordinate export and
48452
+ // winding scan for ordinary shapes.
48453
+ var POLE_ENCLOSING_MIN_WIDTH = 350;
48454
+ // A ring encircles a pole when its longitudes wind a full turn; accept a wide
48455
+ // band around 360 so a jagged coastline that overshoots/undershoots still counts.
48456
+ var POLE_WINDING_TOLERANCE = 90;
48457
+
48458
+ // True if any source ring encircles a pole (spans the full longitude range and
48459
+ // its longitudes wind a full turn). Cheap O(1) guards first: the dataset must
48460
+ // reach the antimeridian, and only shapes spanning nearly the whole longitude
48461
+ // range are exported to coordinates and winding-tested.
48462
+ function sourceHasPoleEnclosingRing(lyr, dataset) {
48463
+ if (!dataset.arcs) return false;
48464
+ var b = dataset.arcs.getBounds();
48465
+ if (b.xmin > -180 + 1e-6 && b.xmax < 180 - 1e-6) return false;
48466
+ return (lyr.shapes || []).some(function(shape) {
48467
+ if (!shape) return false;
48468
+ var sb = dataset.arcs.getMultiShapeBounds(shape);
48469
+ if (!sb || (sb.xmax - sb.xmin) < POLE_ENCLOSING_MIN_WIDTH) return false;
48470
+ return getPolygonMultiPolygonCoords(shape, dataset.arcs).some(function(rings) {
48471
+ return rings.some(function(ring) { return ringEnclosedPole(ring) !== 0; });
48472
+ });
48473
+ });
48474
+ }
48475
+
48476
+ // Net signed longitude winding of a lng/lat ring (sum of per-edge longitude
48477
+ // deltas, each unwrapped to (-180, 180]). A ring that encircles a pole winds
48478
+ // +/-360; an ordinary ring (including one that merely straddles the antimeridian)
48479
+ // winds 0.
48480
+ function ringLngWinding(ring) {
48481
+ var net = 0;
48482
+ for (var i = 1; i < ring.length; i++) {
48483
+ var d = ring[i][0] - ring[i - 1][0];
48484
+ if (d > 180) d -= 360;
48485
+ else if (d < -180) d += 360;
48486
+ net += d;
48487
+ }
48488
+ return net;
48489
+ }
48490
+
48491
+ // Returns the pole latitude (+90 or -90) a ring encircles, or 0 if it does not.
48492
+ // The enclosed pole is the one in the ring's hemisphere: a pole-hugging coastline
48493
+ // lies entirely to one side of the equator, so the mean vertex latitude picks the
48494
+ // correct pole (and is robust to the ambiguous which-pole guess in
48495
+ // removePolygonCrosses).
48496
+ function ringEnclosedPole(ring) {
48497
+ if (!ring || ring.length < 4) return 0;
48498
+ if (Math.abs(Math.abs(ringLngWinding(ring)) - 360) > POLE_WINDING_TOLERANCE) return 0;
48499
+ var sum = 0;
48500
+ for (var i = 0; i < ring.length; i++) sum += ring[i][1];
48501
+ return sum / ring.length < 0 ? -90 : 90;
48502
+ }
48503
+
48504
+ // Close a pole-encircling ring by walking a floor along the enclosed pole line
48505
+ // from the ring's antimeridian exit meridian back to its entry meridian, turning
48506
+ // it into an ordinary closed polygon (the pole-touching-shell representation).
48507
+ // Handles the common single-antimeridian-crossing case; returns null otherwise so
48508
+ // the caller leaves the ring unchanged.
48509
+ function closeRingThroughPole(ring, poleLat) {
48510
+ if (countCrosses(ring) === 0) return null;
48511
+ var parts = splitPathAtAntimeridian(ring);
48512
+ if (parts.length !== 1) return null; // only the single-crossing case
48513
+ var part = parts[0];
48514
+ var startX = part[0][0];
48515
+ var endX = part[part.length - 1][0];
48516
+ if (Math.abs(startX) !== 180 || Math.abs(endX) !== 180 || startX === endX) {
48517
+ return null;
48518
+ }
48519
+ var out = part.map(function(p) { return p.concat(); });
48520
+ poleLineVertices(endX, startX, poleLat).forEach(function(p) { out.push(p); });
48521
+ out.push(part[0].concat()); // close the ring
48522
+ return out;
48523
+ }
48524
+
48525
+ // Vertices tracing the pole line from fromX to toX (both +/-180) at poleLat, with
48526
+ // an intermediate point at least every 45 degrees so no segment spans more than a
48527
+ // quarter turn (long near-pole segments confuse the Mercator offset joins).
48528
+ function poleLineVertices(fromX, toX, poleLat) {
48529
+ var step = fromX > toX ? -45 : 45;
48530
+ var pts = [[fromX, poleLat]];
48531
+ var x = fromX;
48532
+ while (Math.abs(x - toX) > 45 + 1e-9) {
48533
+ x += step;
48534
+ pts.push([x, poleLat]);
48535
+ }
48536
+ pts.push([toX, poleLat]);
48537
+ return pts;
48538
+ }
48539
+
48540
+ // Rebuild the target layer with pole floors inserted into pole-encircling rings,
48541
+ // so the buffer's polar path can grow them. Preserves shape order/count (so the
48542
+ // per-shape distance function and data table stay aligned) and the source CRS.
48543
+ // Returns {layer, dataset} or null if no ring was actually normalized.
48544
+ function buildPoleEnclosingNormalizedSource(lyr, dataset) {
48545
+ var changed = false;
48546
+ var features = (lyr.shapes || []).map(function(shape) {
48547
+ var geom = null;
48548
+ if (shape) {
48549
+ var polys = getPolygonMultiPolygonCoords(shape, dataset.arcs).map(function(rings) {
48550
+ return rings.map(function(ring) {
48551
+ var poleLat = ringEnclosedPole(ring);
48552
+ if (!poleLat) return ring;
48553
+ var closed = closeRingThroughPole(ring, poleLat);
48554
+ if (closed) { changed = true; return closed; }
48555
+ return ring;
48556
+ });
48557
+ });
48558
+ if (polys.length > 0) geom = {type: 'MultiPolygon', coordinates: polys};
48559
+ }
48560
+ return {type: 'Feature', properties: null, geometry: geom};
48561
+ });
48562
+ if (!changed) return null;
48563
+ var normDataset = importGeoJSON({type: 'FeatureCollection', features: features},
48564
+ {type: 'polygon'});
48565
+ if (!normDataset.arcs) return null;
48566
+ normDataset.info = Object.assign({}, dataset.info);
48567
+ var normLyr = normDataset.layers[0];
48568
+ normLyr.name = lyr.name;
48569
+ if (lyr.data) normLyr.data = lyr.data.clone();
48570
+ return {layer: normLyr, dataset: normDataset};
48571
+ }
48263
48572
 
48264
48573
  // Buffer a polygon sliced at the antimeridian (lng +/-180) and/or a pole
48265
48574
  // (lat +/-90): build the offset (the pole-touching source rings are added back,
48266
- // see makePolygonBufferGeoJSON), dissolve, and clip to the world rectangle
48267
- // instead of wrapping at the antimeridian.
48575
+ // see makePolygonBufferGeoJSON), dissolve, and constrain the result to the world
48576
+ // rectangle. The overshoot past the antimeridian is handled one of two ways,
48577
+ // depending on the source:
48578
+ //
48579
+ // - A pole-abutting shape that does NOT span the antimeridian (e.g. a cap slice
48580
+ // reaching the pole from a limited range of longitudes) genuinely wraps when
48581
+ // grown: a fixed ground distance spans an unbounded longitude range as
48582
+ // latitude approaches +/-90, so the offset ring's near-pole corners swing all
48583
+ // the way across the antimeridian. That wrapped part is real coverage, so it
48584
+ // is folded back into [-180,180] by an antimeridian split; a plain world-rect
48585
+ // clip would discard it and drop the whole shape.
48586
+ //
48587
+ // - A shape whose source already sits on the antimeridian seam (an
48588
+ // Antarctica-style shell spanning +/-180) only spills a thin band past the
48589
+ // seam, and that band is redundant -- the shell already covers those
48590
+ // longitudes -- so it is clipped off to keep the seam pinned to the extent.
48591
+ // Wrapping such a full-width ring instead mangles it (the near-global ring
48592
+ // reads as an antimeridian crossing and gets cut apart), so the split is
48593
+ // skipped for these.
48268
48594
  //
48269
48595
  // Only positive (grow) distances are supported. A negative (erode) buffer would
48270
48596
  // have to keep the artificial seam edges pinned to the extent while only the
@@ -48282,11 +48608,23 @@ ${svg}
48282
48608
  if (output.dissolveAfterSplit) {
48283
48609
  dissolveBufferDataset2(dataset2, opts);
48284
48610
  }
48611
+ if (!sourceReachesAntimeridian(dataset)) {
48612
+ splitAntimeridianBufferDataset(dataset2);
48613
+ }
48285
48614
  clipDatasetToWorldRect(dataset2);
48286
48615
  }
48287
48616
  return dataset2;
48288
48617
  }
48289
48618
 
48619
+ // True if the source geometry reaches the antimeridian (lng +/-180), i.e. it is
48620
+ // an antimeridian-sliced shell whose seam edges the polar buffer should pin to
48621
+ // the extent rather than wrap (see makePolarPolygonBuffer).
48622
+ function sourceReachesAntimeridian(dataset) {
48623
+ if (!dataset.arcs) return false;
48624
+ var b = dataset.arcs.getBounds();
48625
+ return b.xmin <= -180 + 1e-3 || b.xmax >= 180 - 1e-3;
48626
+ }
48627
+
48290
48628
  function polarBufferHasNegativeDistance(lyr, dataset, opts) {
48291
48629
  var distanceFn = getBufferDistanceFunction(lyr, dataset, opts);
48292
48630
  return (lyr.shapes || []).some(function(shape, i) {
@@ -48375,8 +48713,17 @@ ${svg}
48375
48713
  // Combine a buffer geometry with the source polygon's rings into one
48376
48714
  // MultiPolygon (overlapping); a later union dissolve merges them. Used by the
48377
48715
  // polar option to keep the polar interior that the pole-pinched ribbon drops.
48716
+ //
48717
+ // Only the source parts that actually reach a pole are appended. A multipolygon
48718
+ // feature can mix a pole-touching part with mid-latitude parts (e.g. a lake-
48719
+ // holed rectangle far from the pole in the same feature); those mid-latitude
48720
+ // parts buffer correctly on their own, and re-injecting their source rings would
48721
+ // override the eroded holes the offset construction already produced (the source
48722
+ // holes come in at full, un-eroded size and win the union), leaving the holes
48723
+ // unbuffered.
48378
48724
  function appendSourceRings(geom, shape, arcs) {
48379
- var sourceCoords = getPolygonMultiPolygonCoords(shape, arcs);
48725
+ var sourceCoords = getPolygonMultiPolygonCoords(shape, arcs)
48726
+ .filter(polyReachesPole);
48380
48727
  var coords = [];
48381
48728
  if (geom && geom.type == 'MultiPolygon') coords = coords.concat(geom.coordinates);
48382
48729
  else if (geom && geom.type == 'Polygon') coords.push(geom.coordinates);
@@ -48385,6 +48732,16 @@ ${svg}
48385
48732
  return {type: 'MultiPolygon', coordinates: coords};
48386
48733
  }
48387
48734
 
48735
+ // True if any vertex of a MultiPolygon part (an array of [x,y] rings) sits at a
48736
+ // pole (lat +/-90).
48737
+ function polyReachesPole(poly) {
48738
+ return poly.some(function(ring) {
48739
+ return ring.some(function(p) {
48740
+ return p[1] >= 90 - 1e-3 || p[1] <= -90 + 1e-3;
48741
+ });
48742
+ });
48743
+ }
48744
+
48388
48745
  function getPolygonRingBufferMaker(dataset, opts, side, winding) {
48389
48746
  // The band-method escape hatch forces the older non-winding construction even
48390
48747
  // for callers that request winding-fill (see the 'band-method' option).
@@ -55728,6 +56085,185 @@ ${svg}
55728
56085
  compiled(null, defs);
55729
56086
  };
55730
56087
 
56088
+ cmd.densify = densifyCommandDataset;
56089
+
56090
+ // Add vertices along path segments so no segment is longer than an interval,
56091
+ // interpolating along one of three paths:
56092
+ // geodesic (default for lat-long) - ellipsoidal shortest path
56093
+ // rhumb - constant bearing (loxodrome)
56094
+ // planar (default for projected) - straight line in coordinate space
56095
+ //
56096
+ // interval= is a ground distance (with units, e.g. 100km) for geodesic/rhumb; for
56097
+ // planar it is decimal degrees for lat-long data and coordinate units for
56098
+ // projected data.
56099
+ function densifyCommandDataset(dataset, opts, targetLayers) {
56100
+ var crs = getDatasetCRS(dataset);
56101
+ var spherical = isLatLngCRS(crs);
56102
+ var mode = getDensifyMode(opts, spherical);
56103
+ var interval = getDensifyInterval(opts, crs, mode, spherical);
56104
+ var interpolate = getDensifyInterpolator(mode, crs);
56105
+ var segmentLength = getSegmentLengthFunction(mode);
56106
+ var targetSet = getTargetLayerSet(dataset, targetLayers);
56107
+ // full-longitude edge detection only applies to lat-long data (projected
56108
+ // coordinates routinely differ by more than 180 units)
56109
+ var stats = spherical ? {undividedWideSegments: 0} : null;
56110
+ var editor = new DatasetEditor(dataset);
56111
+ dataset.layers.forEach(function(lyr) {
56112
+ var densify = targetSet.indexOf(lyr) > -1 &&
56113
+ (lyr.geometry_type == 'polygon' || lyr.geometry_type == 'polyline');
56114
+ editor.editLayer(lyr, function(coords) {
56115
+ if (lyr.geometry_type == 'point') return coords; // pass points through
56116
+ return [densify ?
56117
+ densifyPath(coords, interval, interpolate, segmentLength, stats) : coords];
56118
+ });
56119
+ });
56120
+ editor.done();
56121
+ if (stats && stats.undividedWideSegments > 0) {
56122
+ message('Left ' + stats.undividedWideSegments + ' full-longitude edge(s) undivided; ' +
56123
+ (mode == 'geodesic' ?
56124
+ 'geodesic interpolation can\'t subdivide an edge whose endpoints coincide on the globe -- use the rhumb or planar option.' :
56125
+ 'the endpoints coincide, so there is no path to interpolate along.'));
56126
+ }
56127
+ }
56128
+
56129
+ function getTargetLayerSet(dataset, targetLayers) {
56130
+ return targetLayers && targetLayers.length ? targetLayers : dataset.layers;
56131
+ }
56132
+
56133
+ function getDensifyMode(opts, spherical) {
56134
+ var modes = ['geodesic', 'rhumb', 'planar'].filter(function(m) { return opts[m]; });
56135
+ if (modes.length > 1) {
56136
+ stop$1('Use only one of geodesic, rhumb or planar');
56137
+ }
56138
+ var mode = modes[0] || (spherical ? 'geodesic' : 'planar');
56139
+ if (!spherical && mode != 'planar') {
56140
+ stop$1('The ' + mode + ' option requires a lat-long dataset; use planar for projected data');
56141
+ }
56142
+ return mode;
56143
+ }
56144
+
56145
+ function getDensifyInterval(opts, crs, mode, spherical) {
56146
+ if (opts.interval === undefined) {
56147
+ stop$1('Expected an interval= parameter');
56148
+ }
56149
+ var interval;
56150
+ if (mode == 'planar' && spherical) {
56151
+ // planar densification of lat-long data works in coordinate space, so the
56152
+ // interval is in decimal degrees
56153
+ interval = parseDegreeInterval(opts.interval);
56154
+ } else if (mode == 'planar') {
56155
+ // planar densification of projected data uses coordinate units
56156
+ interval = convertIntervalParam(opts.interval, crs);
56157
+ } else {
56158
+ // geodesic/rhumb measure the interval as a ground distance in meters
56159
+ interval = convertDistanceParam(opts.interval, crs);
56160
+ }
56161
+ if (interval > 0 === false) {
56162
+ stop$1('Expected a positive interval, received:', opts.interval);
56163
+ }
56164
+ return interval;
56165
+ }
56166
+
56167
+ // Parse a decimal-degrees interval: a bare number, or a number with an explicit
56168
+ // degree unit (e.g. 5deg or 5°). Distance units (km, mi, ...) are rejected
56169
+ // because planar interpolation of lat-long data measures the interval in
56170
+ // coordinate (degree) space.
56171
+ function parseDegreeInterval(opt) {
56172
+ var str = String(opt).trim();
56173
+ var match = /^(-?\d*\.?\d+)\s*(?:d|deg|degs|degree|degrees|°)?$/i.exec(str);
56174
+ if (match) {
56175
+ return Number(match[1]);
56176
+ }
56177
+ if (parseMeasure2(str).units) {
56178
+ stop$1('Planar densification of a lat-long dataset uses decimal degrees; "' + opt +
56179
+ '" has distance units -- use the geodesic or rhumb option for a ground distance.');
56180
+ }
56181
+ stop$1('Invalid interval:', opt);
56182
+ }
56183
+
56184
+ function getDensifyInterpolator(mode, crs) {
56185
+ if (mode == 'planar') {
56186
+ return function(a, b, k) { return interpolatePoint2D(a[0], a[1], b[0], b[1], k); };
56187
+ }
56188
+ if (mode == 'rhumb') {
56189
+ return interpolateRhumbPoint;
56190
+ }
56191
+ var geodesic = getInterpolationFunction(crs);
56192
+ return function(a, b, k) { return geodesic(a[0], a[1], b[0], b[1], k); };
56193
+ }
56194
+
56195
+ function getSegmentLengthFunction(mode) {
56196
+ if (mode == 'planar') {
56197
+ return function(a, b) {
56198
+ return Math.sqrt((b[0] - a[0]) * (b[0] - a[0]) + (b[1] - a[1]) * (b[1] - a[1]));
56199
+ };
56200
+ }
56201
+ if (mode == 'rhumb') {
56202
+ return rhumbDistance;
56203
+ }
56204
+ return function(a, b) { return greatCircleDistance(a[0], a[1], b[0], b[1]); };
56205
+ }
56206
+
56207
+ // Insert vertices so no segment exceeds the interval; endpoints are computed
56208
+ // independently at even fractions so they land exactly on the interpolated path
56209
+ // and no rounding drift accumulates.
56210
+ function densifyPath(coords, interval, interpolate, segmentLength, stats) {
56211
+ if (!coords || coords.length < 2) return coords;
56212
+ var out = [coords[0]];
56213
+ for (var i = 1; i < coords.length; i++) {
56214
+ var a = coords[i - 1], b = coords[i];
56215
+ var n = Math.ceil(segmentLength(a, b) / interval);
56216
+ // A segment that spans a large longitude range but has ~zero length can't be
56217
+ // subdivided by this mode (e.g. a -180 -> 180 edge has zero great-circle
56218
+ // distance -- the endpoints are the same point on the globe).
56219
+ if (n < 2 && stats && Math.abs(b[0] - a[0]) > 180) {
56220
+ stats.undividedWideSegments++;
56221
+ }
56222
+ for (var j = 1; j < n; j++) {
56223
+ var p = interpolate(a, b, j / n);
56224
+ if (isFinite(p[0]) && isFinite(p[1])) out.push(p); // guard against degenerate math
56225
+ }
56226
+ out.push(b);
56227
+ }
56228
+ return out;
56229
+ }
56230
+
56231
+ // Isometric ("Mercator-stretched") latitude in degrees-in; returns +/-Infinity at
56232
+ // the poles.
56233
+ function isometricLatitude(latDeg) {
56234
+ return Math.log(Math.tan(Math.PI / 4 + latDeg * D2R$1 / 2));
56235
+ }
56236
+
56237
+ // Rhumb-line (loxodrome) interpolation, fraction k of the way from A to B.
56238
+ // Uses the RAW longitude delta so a -180 -> 180 edge fills the whole parallel
56239
+ // (the constant-latitude case), rather than collapsing across the antimeridian.
56240
+ function interpolateRhumbPoint(a, b, k) {
56241
+ var lat1 = a[1], lat2 = b[1];
56242
+ var dLng = b[0] - a[0]; // raw: preserves a full-longitude sweep
56243
+ var lat = lat1 + (lat2 - lat1) * k; // rhumb distance is linear in latitude
56244
+ var psi1 = isometricLatitude(lat1);
56245
+ var psi2 = isometricLatitude(lat2);
56246
+ // Longitude advances linearly with isometric latitude, except along a meridian
56247
+ // (dLng == 0), a parallel (lat1 == lat2), or when an endpoint is at a pole
56248
+ // (isometric latitude is infinite) -- in those cases longitude is linear in k
56249
+ // (and the 0 * Infinity / Infinity would otherwise be NaN).
56250
+ var lng = dLng !== 0 && isFinite(psi1) && isFinite(psi2) && psi2 !== psi1 ?
56251
+ a[0] + dLng * (isometricLatitude(lat) - psi1) / (psi2 - psi1) :
56252
+ a[0] + dLng * k;
56253
+ return [lng, lat];
56254
+ }
56255
+
56256
+ // Rhumb-line distance in meters, using the raw longitude delta (so a full-parallel
56257
+ // sweep returns the parallel's length rather than 0).
56258
+ function rhumbDistance(a, b) {
56259
+ var phi1 = a[1] * D2R$1, phi2 = b[1] * D2R$1;
56260
+ var dPhi = phi2 - phi1;
56261
+ var dLambda = (b[0] - a[0]) * D2R$1; // raw
56262
+ var dPsi = Math.log(Math.tan(Math.PI / 4 + phi2 / 2) / Math.tan(Math.PI / 4 + phi1 / 2));
56263
+ var q = Math.abs(dPsi) > 1e-12 ? dPhi / dPsi : Math.cos(phi1);
56264
+ return Math.sqrt(dPhi * dPhi + q * q * dLambda * dLambda) * R$3;
56265
+ }
56266
+
55731
56267
  // Variable name pattern. Matches simple identifiers: must start with a letter
55732
56268
  // or underscore, followed by letters, digits or underscores.
55733
56269
  var VAR_NAME_RXP = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -63609,6 +64145,14 @@ ${svg}
63609
64145
  // -- whether a tight coastline or a gentle, hundreds-of-km graticule arc --
63610
64146
  // never qualifies; only a localized kink, where the inner turn approaches
63611
64147
  // the full turn, is a corner.
64148
+ // 1b. (open paths) Also flag the end of a long straight run that turns sharply
64149
+ // over a single segment but only gently over the window -- a small jog where
64150
+ // a surveyed border meets a curve, which step 1's window would dilute below
64151
+ // the corner angle. Keyed off the raw segment turn, but the pinned vertex is
64152
+ // snapped to the nearby end of the straight run (stable under tiny vertex
64153
+ // moves) and only where that run's line is actually left (so an incidental
64154
+ // notch after which the run resumes on the same line is not flagged). See
64155
+ // straightRunEndNear.
63612
64156
  // 2. Between flagged corners, classify each span as "structural" if it is long
63613
64157
  // relative to the tolerance and its curvature stays low (so a straight or
63614
64158
  // slowly-curving graticule line counts, but sub-tolerance wiggle does not).
@@ -63651,14 +64195,18 @@ ${svg}
63651
64195
  // to count, which matches intuition (a 28 km stretch bending at radius 4 km is
63652
64196
  // obviously not straight). Genuinely curving coastline bows far from its chord
63653
64197
  // and is still rejected, so spurious corners inside wiggly stretches keep getting
63654
- // culled.
63655
- var STRAIGHT_DEV_FACTOR = 0.03;
64198
+ // culled. Tightened from 0.03 to 0.02 (a run may curve ~9 deg over its length, not
64199
+ // ~14) after a coastline island pinned corners at both ends of a ~10 deg-curving
64200
+ // stretch that read as "straight" only under the looser corridor: an acute corner
64201
+ // takes the full corridor regardless of the angle coupling below, so only the base
64202
+ // factor governs whether such a run can anchor a sharp corner.
64203
+ var STRAIGHT_DEV_FACTOR = 0.02;
63656
64204
 
63657
64205
  // Angle coupling for corner retention: how much sharper the corner must turn than
63658
64206
  // the run it borders already curves. A run that passes the chord test may still
63659
64207
  // 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
64208
+ // chord-deviation ratio is ~ (the run's total turn)/8, so the base 0.02 admits a
64209
+ // run that curves ~9 deg over its length. Pinning a *gentle* bend at the end of
63662
64210
  // such a run is unsafe: the "corner" is barely sharper than the run's own
63663
64211
  // curving, so it is really a point on a smooth bend, not a junction. (This is the
63664
64212
  // failure mode on coarsely sampled / already-simplified coastlines, where a
@@ -63669,7 +64217,7 @@ ${svg}
63669
64217
  // turn >= PIN_TURN_RATIO * (8 * dev) <=> dev <= turn / (8 * PIN_TURN_RATIO).
63670
64218
  // retentionDevLimit() returns the smaller of STRAIGHT_DEV_FACTOR and
63671
64219
  // 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
64220
+ // ~2*STRAIGHT_DEV_FACTOR*PIN_TURN_RATIO ~ 46 deg); sharp corners (surveyed-border
63673
64221
  // right angles, spits, hairpins) keep the full base tolerance, unchanged.
63674
64222
  var PIN_TURN_RATIO = 5;
63675
64223
 
@@ -63693,6 +64241,15 @@ ${svg}
63693
64241
  // old 1*tol behaviour for users who want shorter runs pinned.
63694
64242
  var MIN_PIN_RUN_LEN_FACTOR = 2.0;
63695
64243
 
64244
+ // When a long straight run's end turns sharply into a curve with a small jog, the
64245
+ // sharpest single-segment turn can land a few vertices past the run's actual end.
64246
+ // straightRunEndNear searches back this many source vertices (in addition to the
64247
+ // distance-scaled tangent window) to snap to the stable run end, so detection
64248
+ // does not depend on the smoothing distance being large enough for the window to
64249
+ // span the jog. A jog is a handful of vertices; this is deliberately generous
64250
+ // because the search returns the nearest genuine run end (or nothing).
64251
+ var MAX_JOG_VERTICES = 6;
64252
+
63696
64253
  // Convert the user-facing corner-bias (0 = neutral) into the positive multiplier
63697
64254
  // k applied to corner-detection resolution (ctol = tol / k). The mapping is
63698
64255
  // symmetric about zero -- k(+b) * k(-b) = 1 -- and smooth there (both branches
@@ -63707,6 +64264,39 @@ ${svg}
63707
64264
  return b >= 0 ? b + 1 : 1 / (1 - b);
63708
64265
  }
63709
64266
 
64267
+ // Ratio of (typical segment length / smoothing distance) at or below which corner
64268
+ // detection sees several segments per tangent window and behaves normally, so no
64269
+ // automatic coarsening is applied. Above it the window shrinks toward a single
64270
+ // segment and ordinary coarse-data bends start reading as corners.
64271
+ var AUTO_BIAS_RATIO = 0.15;
64272
+ // Most-negative automatic bias. Caps how far detection is coarsened on very sparse
64273
+ // geometry (e.g. lo-res contours), where the ratio can be many times AUTO_BIAS_RATIO
64274
+ // but a handful of doublings already merges the whole neighbourhood.
64275
+ var AUTO_BIAS_FLOOR = -4;
64276
+
64277
+ // Automatic corner-bias from the geometry's coarseness relative to the smoothing
64278
+ // distance. @medianSeg is a robust (median) segment length; @dist is the raw
64279
+ // smoothing distance, both in the same ground units. Corner detection keys off a
64280
+ // tangent window ~0.3*dist wide; when the typical segment is an appreciable
64281
+ // fraction of the distance (ratio r = medianSeg/dist above AUTO_BIAS_RATIO) that
64282
+ // window spans too few segments and gentle-but-coarse bends read as corners. We
64283
+ // return a negative bias that coarsens detection (as if the distance were larger)
64284
+ // enough to push the effective ratio back down: each halving of the effective
64285
+ // resolution costs one bias step, so bias = -log2(r / AUTO_BIAS_RATIO), floored.
64286
+ // Returns 0 (no adjustment) when the geometry is fine relative to the distance,
64287
+ // which is the normal case for detailed datasets smoothed at a real distance.
64288
+ // Note it only ever coarsens: a straight run that genuinely anchors a sharp corner
64289
+ // survives this (the corner is retained by bordering a long straight run, which
64290
+ // coarsening does not remove until it exceeds the run's length), while weakly
64291
+ // supported bends on coarse coastlines/contours fall below the corner threshold.
64292
+ function autoCornerBias(medianSeg, dist) {
64293
+ if (!(medianSeg > 0) || !(dist > 0)) return 0;
64294
+ var r = medianSeg / dist;
64295
+ if (r <= AUTO_BIAS_RATIO) return 0;
64296
+ var b = -Math.log2(r / AUTO_BIAS_RATIO);
64297
+ return b < AUTO_BIAS_FLOOR ? AUTO_BIAS_FLOOR : b;
64298
+ }
64299
+
63710
64300
  // @cornerBias (optional, default 0 = neutral) scales only the distance-
63711
64301
  // proportional corner parameters, by dividing the tolerance they key off
63712
64302
  // (ctol = tol / k, k = cornerBiasScale(bias)). The dimensionless thresholds are
@@ -63763,9 +64353,107 @@ ${svg}
63763
64353
  if (inner[j] < params.concentration * turns[j]) continue;
63764
64354
  if (isLocalMaxTurn(t, turns, j, W, L, m, lo, hi, cyclic)) corners.push(j);
63765
64355
  }
64356
+ // Open paths: also flag the terminal vertex of a long straight run that bends
64357
+ // sharply over a single segment but only gently over the tangent window -- e.g.
64358
+ // where a surveyed border meets a coastline with a small jog. The windowed test
64359
+ // above misses these because the wide window dilutes the sharp segment turn into
64360
+ // a sub-threshold bend, so the straight run's end gets rounded into the adjacent
64361
+ // curve. Here we key off the raw segment turn instead, but confine this rescue
64362
+ // to a genuine straight-run end with two gates (plus straightRunEndNear):
64363
+ // 1. the turn is fully concentrated in the inner window: inner-window turn >=
64364
+ // full-window turn. This is a stricter form of the windowed path's
64365
+ // concentration test (>= concentration * full) and is the crux of the
64366
+ // distinction. At a straight-run end the approaches carry ~no turning, so
64367
+ // the whole window's turn sits in the inner window (ratio >= 1); on a
64368
+ // steadily-curving coastline the turning is spread across the window (inner
64369
+ // < full), so a lone sharp segment there is rejected. The wide window
64370
+ // dilutes the end's departure below the corner threshold (which is why the
64371
+ // windowed path misses it), but the concentration ratio stays high.
64372
+ // 2. (via straightRunEndNear) a bordering straight run's line is actually left,
64373
+ // so an incidental notch on an otherwise-continuing run is not pinned.
64374
+ // Rings keep the windowed-only detection for now.
64375
+ if (!cyclic) {
64376
+ var raw = new Float64Array(m);
64377
+ for (i = lo; i < hi; i++) raw[i] = vertexTurn(channels, K, i);
64378
+ for (j = lo; j < hi; j++) {
64379
+ if (raw[j] < params.cornerAngle) continue;
64380
+ if (inner[j] < turns[j]) continue;
64381
+ if (!isLocalMaxTurn(t, raw, j, W, L, m, lo, hi, cyclic)) continue;
64382
+ var e = straightRunEndNear(t, channels, n, j, W, params);
64383
+ if (e >= 0 && corners.indexOf(e) === -1) corners.push(e);
64384
+ }
64385
+ corners.sort(function (a, b) { return a - b; });
64386
+ }
63766
64387
  return corners;
63767
64388
  }
63768
64389
 
64390
+ // Given a sharp single-segment turn at open-path vertex @j, find the end of a
64391
+ // long straight run that the path leaves, within arc length @W of @j, and return
64392
+ // that run-end vertex to pin (or -1). We search a neighbourhood rather than
64393
+ // requiring the run's last vertex to be exactly where the segment turn peaks: a
64394
+ // jog into a curve can place its sharpest vertex a step or two past the run's
64395
+ // end, and exactly which vertex is sharpest is sensitive to tiny differences in
64396
+ // vertex placement -- but the straight run's end itself is stable. Snapping to it
64397
+ // keeps detection from flickering on and off with sub-tolerance vertex moves. The
64398
+ // straightRunLeaves test only succeeds at a genuine run end whose continuation
64399
+ // departs, so an incidental jog after which the run resumes is still not pinned.
64400
+ function straightRunEndNear(t, channels, n, j, W, params) {
64401
+ var e, k;
64402
+ // The jog between the sharp turn and the run's end is a fixed handful of source
64403
+ // vertices, independent of the smoothing distance -- so the search extends by a
64404
+ // vertex count as well as the distance-scaled window W. Without the vertex floor
64405
+ // the window shrinks with the distance and, at small distances, can no longer
64406
+ // reach back across the jog to the run end (fewer, not more, corners pinned as
64407
+ // the distance drops -- the opposite of what a long run warrants). We return the
64408
+ // first (nearest) run end found, so a generous reach only costs a few extra
64409
+ // straightRunLeaves checks when there is nothing to pin.
64410
+ for (e = j, k = 0; e >= 1; e--, k++) {
64411
+ if (t[j] - t[e] > W && k > MAX_JOG_VERTICES) break;
64412
+ if (straightRunLeaves(t, channels, n, e, -1, params) ||
64413
+ straightRunLeaves(t, channels, n, e, 1, params)) return e;
64414
+ }
64415
+ for (e = j + 1, k = 1; e < n - 1; e++, k++) {
64416
+ if (t[e] - t[j] > W && k > MAX_JOG_VERTICES) break;
64417
+ if (straightRunLeaves(t, channels, n, e, -1, params) ||
64418
+ straightRunLeaves(t, channels, n, e, 1, params)) return e;
64419
+ }
64420
+ return -1;
64421
+ }
64422
+
64423
+ // Is the near side of @e (walking direction @dir: -1 = run precedes e, +1 = run
64424
+ // follows e) a long straight run, AND does the far side leave that run's line --
64425
+ // i.e. a point a full run-length along the far side sits outside the run's
64426
+ // straightness corridor? A jog that rejoins the run returns to ~0 perpendicular
64427
+ // offset and fails this, so it is not flagged. (This also implies @e is the run's
64428
+ // end: if the run continued straight past @e, the far point would stay on its
64429
+ // line.)
64430
+ function straightRunLeaves(t, channels, n, e, dir, params) {
64431
+ var K = channels.length, L = t[n - 1];
64432
+ var nearEnd = reach(t, n, n, L, e, dir, params.minPinRunLen, false);
64433
+ if (nearEnd === e) return false;
64434
+ var a = dir < 0 ? nearEnd : e;
64435
+ var b = dir < 0 ? e : nearEnd;
64436
+ // Require the near run to be straight to the SAME angle-coupled tolerance
64437
+ // retention will demand of a pin at @e (retentionDevLimit of the turn at @e),
64438
+ // not the looser default corridor. This makes straightRunEndNear's backward
64439
+ // scan stop at the run's true end -- the last vertex actually on the run's line
64440
+ // -- rather than a vertex a little past it that squeaks inside the loose 3%
64441
+ // corridor but would then be culled by retention (leaving nothing pinned).
64442
+ var devLim = retentionDevLimit(cornerTurn(t, channels, n, e, false, params));
64443
+ if (!isStraightRun(t, channels, a, b, params, devLim)) return false;
64444
+ var farEnd = reach(t, n, n, L, e, -dir, params.minPinRunLen, false);
64445
+ if (farEnd === e) return false;
64446
+ var pe = getPt(channels, K, e);
64447
+ var u = subv(getPt(channels, K, nearEnd), pe, K); // direction along the near run
64448
+ var uu = dot(u, u, K);
64449
+ if (!(uu > 0)) return false;
64450
+ var af = subv(getPt(channels, K, farEnd), pe, K);
64451
+ var along = Math.abs(dot(af, u, K)) / Math.sqrt(uu);
64452
+ var perp = Math.sqrt(perpDistSq(channels, K, farEnd, pe, u, uu));
64453
+ // outside the straight corridor extended from the run -> the path has left it
64454
+ return perp > STRAIGHT_DEV_FACTOR * along;
64455
+ }
64456
+
63769
64457
  // Is span [a, b] (inclusive vertex indices, a < b, open frame) a structural run:
63770
64458
  // long relative to the tolerance and low-curvature throughout?
63771
64459
  function isStructuralRun(t, channels, a, b, params) {
@@ -64139,6 +64827,20 @@ ${svg}
64139
64827
  // segments); user-overridable via max-bend-angle
64140
64828
  var DEVIATION_FACTOR = 0.1; // sagitta guard: also cut a gentle bend that bows
64141
64829
  // more than tolerance * this from its chord
64830
+ // Preserved structural runs (long straight / low-curvature spans between pinned
64831
+ // corners) are not smoothed, but their ORIGINAL vertices are resampled with the
64832
+ // same bend-angle decimation so the whole output has adaptive vertex spacing
64833
+ // (no abrupt density seam at a run boundary). The decimation runs in the
64834
+ // smoothing channels, so for unprojected data a long line -- which curves in the
64835
+ // geocentric x,y,z space even when it is "straight" in lng/lat -- keeps enough
64836
+ // interior vertices to approximate that curve on reprojection, scaling with the
64837
+ // line's length automatically. Because it only ever keeps a SUBSET of the
64838
+ // original vertices (never interpolates new ones), it can't distort a rhumb or
64839
+ // geodesic edge -- every output vertex still lies exactly where the source drew
64840
+ // it. Structural runs use a fraction of the bend angle (sampled finer than
64841
+ // smoothed spans) as a conservative bias toward preservation / reprojection
64842
+ // headroom.
64843
+ var STRUCTURAL_BEND_FACTOR = 0.5; // structural-run bend angle = max-bend-angle * this
64142
64844
 
64143
64845
  // Smooth a single arc's coordinates.
64144
64846
  // @xx, @yy: coordinate arrays (may be typed-array subarrays) for one arc.
@@ -64147,8 +64849,9 @@ ${svg}
64147
64849
  // arcs are preserved exactly (so shared topology nodes stay put); closed arcs
64148
64850
  // are smoothed cyclically and returned closed (first point repeated at the end).
64149
64851
  // 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.
64852
+ // meet) are detected and pinned; the runs themselves are not smoothed but are
64853
+ // resampled (a subset of their original vertices, at adaptive spacing), and only
64854
+ // the spans between corners are smoothed.
64152
64855
  // Resolve the curvature-correction gain (default 1 = fully corrected). gain=0
64153
64856
  // leaves the plain weighted moving average; negative values are clamped to 0.
64154
64857
  function resolveGain(opts) {
@@ -64198,7 +64901,7 @@ ${svg}
64198
64901
  var origY = toArray(yy);
64199
64902
  var tol = opts.tolerance * KERNEL_FROM_DISTANCE;
64200
64903
  if (n < 3 || !(opts.tolerance > 0)) {
64201
- return {xx: origX, yy: origY};
64904
+ return {xx: origX, yy: origY, corners: 0};
64202
64905
  }
64203
64906
  var method = opts.method == 'gaussian' ? 'gaussian' : 'paek';
64204
64907
  var closed = !!opts.closed;
@@ -64210,7 +64913,7 @@ ${svg}
64210
64913
  // kernel scale stays in true distance regardless of coordinate representation.
64211
64914
  var t = arcLengths(origX, origY, n, spherical);
64212
64915
  if (!(t[n - 1] > 0)) {
64213
- return {xx: origX, yy: origY}; // degenerate (coincident points)
64916
+ return {xx: origX, yy: origY, corners: 0}; // degenerate (coincident points)
64214
64917
  }
64215
64918
  // The low-pass kernel scale is the raw distance scale (tol) times the baked-in
64216
64919
  // KERNEL_STRENGTH calibration and the user's `strength` multiplier (default 1).
@@ -64260,7 +64963,9 @@ ${svg}
64260
64963
  corners = filterRingCornersByStructure(t, channels, n, corners, ringParams);
64261
64964
  }
64262
64965
  if (corners.length === 0) {
64263
- return smoothClosedCyclic(t, channels, n, ctx);
64966
+ var cyc = smoothClosedCyclic(t, channels, n, ctx);
64967
+ cyc.corners = 0;
64968
+ return cyc;
64264
64969
  }
64265
64970
  // A ring with corners is processed as an open path: rotate it to start (and
64266
64971
  // end) at one corner, with the remaining corners as interior breakpoints.
@@ -64271,7 +64976,11 @@ ${svg}
64271
64976
  t = arcLengths(origX, origY, n, spherical);
64272
64977
  channels = spherical ? lngLatToXYZChannels(origX, origY, n) : [origX, origY];
64273
64978
  var breaks = mapRotatedCorners(corners, rot.shift, rot.m);
64274
- return smoothOpenSpans(origX, origY, t, channels, n, breaks, ctx);
64979
+ var ring = smoothOpenSpans(origX, origY, t, channels, n, breaks, ctx);
64980
+ // The ring seam (corners[0], pinned as the rotated start/end) is itself a
64981
+ // preserved corner, on top of the interior breakpoints smoothOpenSpans kept.
64982
+ ring.corners += 1;
64983
+ return ring;
64275
64984
  }
64276
64985
 
64277
64986
  var openBreaks = keepCorners ?
@@ -64304,11 +65013,12 @@ ${svg}
64304
65013
  var lo = bounds[s], hi = bounds[s + 1];
64305
65014
  var preserve = !!params && isStructuralRun(t, channels, lo, hi, params);
64306
65015
  var span = preserve ?
64307
- copySpan(origX, origY, lo, hi) :
65016
+ resampleStructuralRun(origX, origY, channels, lo, hi, ctx) :
64308
65017
  smoothSpanOpen(origX, origY, t, channels, lo, hi, ctx);
64309
65018
  appendSpan(xx, yy, span, s === 0);
64310
65019
  }
64311
- return {xx: xx, yy: yy};
65020
+ // Interior breakpoints that survived refinement are the pinned corners.
65021
+ return {xx: xx, yy: yy, corners: bounds.length - 2};
64312
65022
  }
64313
65023
 
64314
65024
  // Drop interior breakpoints that don't border any pinnable straight run (e.g.
@@ -64534,6 +65244,34 @@ ${svg}
64534
65244
  return {xx: xx, yy: yy};
64535
65245
  }
64536
65246
 
65247
+ // Resample a preserved structural run [lo, hi] (a long straight / low-curvature
65248
+ // span between pinned corners). The run is NOT smoothed: its shape is kept by
65249
+ // emitting a SUBSET of its original vertices, decimated with the shared
65250
+ // bend-angle filter in the smoothing channels (so a long line that curves in the
65251
+ // geocentric space keeps interior vertices scaling with its length -- see
65252
+ // STRUCTURAL_BEND_FACTOR). Both endpoints are always kept, at their exact
65253
+ // original coordinates, so pinned corners and shared topology nodes are
65254
+ // unchanged. A run too short to decimate is copied verbatim.
65255
+ function resampleStructuralRun(origX, origY, channels, lo, hi, ctx) {
65256
+ var nSub = hi - lo + 1;
65257
+ if (nSub < 3) return copySpan(origX, origY, lo, hi);
65258
+ var K = channels.length;
65259
+ var P = new Array(nSub);
65260
+ for (var i = 0; i < nSub; i++) {
65261
+ var p = new Array(K);
65262
+ for (var c = 0; c < K; c++) p[c] = channels[c][lo + i];
65263
+ P[i] = p;
65264
+ }
65265
+ var keep = decimateByBend(P, K, ctx.bendAngle * STRUCTURAL_BEND_FACTOR, ctx.tol * DEVIATION_FACTOR);
65266
+ var xx = [], yy = [];
65267
+ for (var ki = 0; ki < keep.length; ki++) {
65268
+ var idx = lo + keep[ki];
65269
+ xx.push(origX[idx]); // exact original coordinate, never interpolated
65270
+ yy.push(origY[idx]);
65271
+ }
65272
+ return {xx: xx, yy: yy};
65273
+ }
65274
+
64537
65275
  function appendSpan(xx, yy, span, isFirst) {
64538
65276
  for (var i = isFirst ? 0 : 1; i < span.xx.length; i++) {
64539
65277
  xx.push(span.xx[i]);
@@ -64658,26 +65396,38 @@ ${svg}
64658
65396
  }
64659
65397
 
64660
65398
  // 2. one-pass bend-angle filter
64661
- var theta = ctx.bendAngle;
64662
- var epsDev = ctx.tol * DEVIATION_FACTOR;
65399
+ var keep = decimateByBend(P, K, ctx.bendAngle, ctx.tol * DEVIATION_FACTOR);
64663
65400
  var out = [];
64664
65401
  for (var c = 0; c < K; c++) out.push([]);
64665
- appendPoint(out, P[0], K);
65402
+ for (var ki = 0; ki < keep.length; ki++) appendPoint(out, P[keep[ki]], K);
65403
+ return out;
65404
+ }
65405
+
65406
+ // One-pass forward decimation of a K-channel point list @P: keep the two
65407
+ // endpoints plus every interior point where the turn accumulated since the last
65408
+ // kept point reaches @theta, or where the estimated sagitta of the skipped
65409
+ // stretch (chord * accumulated turn / 8, the bow of a circular arc) reaches
65410
+ // @epsDev. Bounds the angle between consecutive kept segments by construction, so
65411
+ // joins stay smooth. Returns the kept indices into @P (always including 0 and the
65412
+ // last index). Shared by the smoothed-curve resampler and the structural-run
65413
+ // resampler (see resampleStructuralRun).
65414
+ function decimateByBend(P, K, theta, epsDev) {
65415
+ var n = P.length;
65416
+ var keep = [0];
65417
+ if (n < 2) return keep;
64666
65418
  var anchor = 0; // last kept vertex
64667
65419
  var accTurn = 0; // absolute turning accumulated since the anchor
64668
- for (var j = 1; j < nDense - 1; j++) {
65420
+ for (var j = 1; j < n - 1; j++) {
64669
65421
  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
65422
  var sagitta = chordLen(P[anchor], P[j + 1], K) * accTurn * 0.125;
64673
65423
  if (accTurn >= theta || sagitta >= epsDev) {
64674
- appendPoint(out, P[j], K);
65424
+ keep.push(j);
64675
65425
  anchor = j;
64676
65426
  accTurn = 0;
64677
65427
  }
64678
65428
  }
64679
- appendPoint(out, P[nDense - 1], K);
64680
- return out;
65429
+ keep.push(n - 1);
65430
+ return keep;
64681
65431
  }
64682
65432
 
64683
65433
  // Angle (radians) between vectors (b - a) and (d - c) over K channels.
@@ -64998,16 +65748,32 @@ ${svg}
64998
65748
  }
64999
65749
  }
65000
65750
 
65001
- smoothPaths(arcs, {
65751
+ // Corner detection is automatically coarsened on geometry that is sparse
65752
+ // relative to the smoothing distance (long segments -> few segments per
65753
+ // detection window -> ordinary coarse bends misread as corners; see
65754
+ // autoCornerBias). The user's corner-bias is relative to this automatic base:
65755
+ // it is added on top, so corner-bias=0 (the default) is "whatever the geometry
65756
+ // warrants", a positive value finds more corners than the auto baseline, a
65757
+ // negative value fewer.
65758
+ var autoBias = 0;
65759
+ if (keepCorners) {
65760
+ autoBias = autoCornerBias(medianSegmentLength(arcs, spherical), tolerance);
65761
+ }
65762
+ var effectiveCornerBias = autoBias + (opts.corner_bias || 0);
65763
+
65764
+ var corners = smoothPaths(arcs, {
65002
65765
  tolerance: tolerance,
65003
65766
  method: method,
65004
65767
  spherical: spherical,
65005
65768
  keepCorners: keepCorners,
65006
- cornerBias: opts.corner_bias,
65769
+ cornerBias: effectiveCornerBias,
65007
65770
  gain: opts.gain,
65008
65771
  strength: opts.strength,
65009
65772
  maxBendAngle: opts.max_bend_angle
65010
65773
  });
65774
+ if (keepCorners && corners > 0) {
65775
+ message('Pinned ' + corners + ' corner' + utils.pluralSuffix(corners));
65776
+ }
65011
65777
 
65012
65778
  if (implicitlySmoothedNames.length > 0) {
65013
65779
  message(
@@ -65021,10 +65787,12 @@ ${svg}
65021
65787
  // untouched, shared polygon boundaries stay coincident and topology is
65022
65788
  // preserved; updateVertexData() also handles undo capture and resets stale
65023
65789
  // simplification thresholds.
65790
+ // Returns the total number of structural corners preserved across all arcs.
65024
65791
  function smoothPaths(arcs, opts) {
65025
65792
  var nn = [];
65026
65793
  var xx = [];
65027
65794
  var yy = [];
65795
+ var corners = 0;
65028
65796
  var i, k, res;
65029
65797
  arcs.forEach3(function(axx, ayy, azz, arcId) {
65030
65798
  res = smoothArcCoords(axx, ayy, {
@@ -65038,6 +65806,7 @@ ${svg}
65038
65806
  maxBendAngle: opts.maxBendAngle,
65039
65807
  closed: arcs.arcIsClosed(arcId)
65040
65808
  });
65809
+ corners += res.corners || 0;
65041
65810
  nn.push(res.xx.length);
65042
65811
  for (i = 0, k = res.xx.length; i < k; i++) {
65043
65812
  xx.push(res.xx[i]);
@@ -65045,6 +65814,31 @@ ${svg}
65045
65814
  }
65046
65815
  });
65047
65816
  arcs.updateVertexData(nn, xx, yy);
65817
+ return corners;
65818
+ }
65819
+
65820
+ // Median segment length across all arcs, in ground units (meters for spherical
65821
+ // data), used to gauge how coarse the geometry is relative to the smoothing
65822
+ // distance (see autoCornerBias). The median is robust to a few very long straight
65823
+ // segments (which are not a spurious-corner risk) and to dense sub-scale detail.
65824
+ // Very large datasets are sampled with a stride so the cost stays bounded.
65825
+ function medianSegmentLength(arcs, spherical) {
65826
+ var totalSegs = arcs.getPointCount() - arcs.size();
65827
+ if (totalSegs < 1) return 0;
65828
+ var MAX_SAMPLES = 100000;
65829
+ var stride = Math.ceil(totalSegs / MAX_SAMPLES);
65830
+ var distFn = spherical ? greatCircleDistance : distance2D;
65831
+ var lens = [];
65832
+ var counter = 0;
65833
+ arcs.forEach3(function(xx, yy) {
65834
+ for (var i = 1, n = xx.length; i < n; i++) {
65835
+ if (counter++ % stride === 0) {
65836
+ lens.push(distFn(xx[i - 1], yy[i - 1], xx[i], yy[i]));
65837
+ }
65838
+ }
65839
+ });
65840
+ if (lens.length === 0) return 0;
65841
+ return utils.findMedian(lens);
65048
65842
  }
65049
65843
 
65050
65844
  function getSmoothMethod(opts) {
@@ -66512,6 +67306,9 @@ ${svg}
66512
67306
  } else if (name == 'define') {
66513
67307
  cmd.define(job.catalog, opts);
66514
67308
 
67309
+ } else if (name == 'densify') {
67310
+ cmd.densify(targetDataset, opts, targetLayers);
67311
+
66515
67312
  } else if (name == 'vars') {
66516
67313
  cmd.vars(job, opts);
66517
67314
 
@@ -66852,7 +67649,7 @@ ${svg}
66852
67649
  return name == 'rectangle' || name == 'rectangles' || name == 'filter' && opts.cleanup;
66853
67650
  }
66854
67651
 
66855
- var version = "0.7.37";
67652
+ var version = "0.7.39";
66856
67653
 
66857
67654
  // Parse command line args into commands and run them
66858
67655
  // Function takes an optional Node-style callback. A Promise is returned if no callback is given.