mapshaper 0.7.38 → 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.
package/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
@@ -43241,7 +43262,7 @@ ${svg}
43241
43262
  return x;
43242
43263
  }
43243
43264
 
43244
- var POLAR_BUFFER_MARGIN_DEGREES = 1e-4;
43265
+ var POLAR_BUFFER_MARGIN_DEGREES$1 = 1e-4;
43245
43266
 
43246
43267
 
43247
43268
  // Returns a function for generating GeoJSON MultiPolygon geometries
@@ -43306,9 +43327,9 @@ ${svg}
43306
43327
  maxAbsLat = Math.max(maxAbsLat, Math.abs(latLngPathIter.y));
43307
43328
  }
43308
43329
  });
43309
- if (maxAbsLat + angularDist >= 90 - POLAR_BUFFER_MARGIN_DEGREES) {
43330
+ if (maxAbsLat + angularDist >= 90 - POLAR_BUFFER_MARGIN_DEGREES$1) {
43310
43331
  stop$1('Buffering lat-long coordinates near the poles is not supported; ' +
43311
- 'use the polar option for polygons sliced at the antimeridian/poles.');
43332
+ 'positive polygon buffers that reach a pole use experimental polar handling.');
43312
43333
  }
43313
43334
  }
43314
43335
 
@@ -47695,6 +47716,32 @@ ${svg}
47695
47716
 
47696
47717
  function makePolygonBuffer(lyr, dataset, opts) {
47697
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
+ }
47698
47745
  // debug-mosaic is implemented only for line buffers; for polygons it has no
47699
47746
  // handling and would leak into the per-shape dissolve and corrupt output, so
47700
47747
  // drop it and warn rather than silently mislead.
@@ -47752,6 +47799,17 @@ ${svg}
47752
47799
  }
47753
47800
  return debugDataset;
47754
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
+ }
47755
47813
  if (spherical && opts.polar) {
47756
47814
  // Pole/antimeridian-sliced polygons (grow only): keep the seam edges at the
47757
47815
  // extent and clip to the world rectangle instead of wrapping at the
@@ -48263,11 +48321,276 @@ ${svg}
48263
48321
 
48264
48322
  // World rectangle (lng/lat) the polar buffer is clipped to.
48265
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
+ }
48266
48572
 
48267
48573
  // Buffer a polygon sliced at the antimeridian (lng +/-180) and/or a pole
48268
48574
  // (lat +/-90): build the offset (the pole-touching source rings are added back,
48269
- // see makePolygonBufferGeoJSON), dissolve, and clip to the world rectangle
48270
- // 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.
48271
48594
  //
48272
48595
  // Only positive (grow) distances are supported. A negative (erode) buffer would
48273
48596
  // have to keep the artificial seam edges pinned to the extent while only the
@@ -48285,11 +48608,23 @@ ${svg}
48285
48608
  if (output.dissolveAfterSplit) {
48286
48609
  dissolveBufferDataset2(dataset2, opts);
48287
48610
  }
48611
+ if (!sourceReachesAntimeridian(dataset)) {
48612
+ splitAntimeridianBufferDataset(dataset2);
48613
+ }
48288
48614
  clipDatasetToWorldRect(dataset2);
48289
48615
  }
48290
48616
  return dataset2;
48291
48617
  }
48292
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
+
48293
48628
  function polarBufferHasNegativeDistance(lyr, dataset, opts) {
48294
48629
  var distanceFn = getBufferDistanceFunction(lyr, dataset, opts);
48295
48630
  return (lyr.shapes || []).some(function(shape, i) {
@@ -48378,8 +48713,17 @@ ${svg}
48378
48713
  // Combine a buffer geometry with the source polygon's rings into one
48379
48714
  // MultiPolygon (overlapping); a later union dissolve merges them. Used by the
48380
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.
48381
48724
  function appendSourceRings(geom, shape, arcs) {
48382
- var sourceCoords = getPolygonMultiPolygonCoords(shape, arcs);
48725
+ var sourceCoords = getPolygonMultiPolygonCoords(shape, arcs)
48726
+ .filter(polyReachesPole);
48383
48727
  var coords = [];
48384
48728
  if (geom && geom.type == 'MultiPolygon') coords = coords.concat(geom.coordinates);
48385
48729
  else if (geom && geom.type == 'Polygon') coords.push(geom.coordinates);
@@ -48388,6 +48732,16 @@ ${svg}
48388
48732
  return {type: 'MultiPolygon', coordinates: coords};
48389
48733
  }
48390
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
+
48391
48745
  function getPolygonRingBufferMaker(dataset, opts, side, winding) {
48392
48746
  // The band-method escape hatch forces the older non-winding construction even
48393
48747
  // for callers that request winding-fill (see the 'band-method' option).
@@ -55731,6 +56085,185 @@ ${svg}
55731
56085
  compiled(null, defs);
55732
56086
  };
55733
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
+
55734
56267
  // Variable name pattern. Matches simple identifiers: must start with a letter
55735
56268
  // or underscore, followed by letters, digits or underscores.
55736
56269
  var VAR_NAME_RXP = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -66773,6 +67306,9 @@ ${svg}
66773
67306
  } else if (name == 'define') {
66774
67307
  cmd.define(job.catalog, opts);
66775
67308
 
67309
+ } else if (name == 'densify') {
67310
+ cmd.densify(targetDataset, opts, targetLayers);
67311
+
66776
67312
  } else if (name == 'vars') {
66777
67313
  cmd.vars(job, opts);
66778
67314
 
@@ -67113,7 +67649,7 @@ ${svg}
67113
67649
  return name == 'rectangle' || name == 'rectangles' || name == 'filter' && opts.cleanup;
67114
67650
  }
67115
67651
 
67116
- var version = "0.7.38";
67652
+ var version = "0.7.39";
67117
67653
 
67118
67654
  // Parse command line args into commands and run them
67119
67655
  // 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.38",
3
+ "version": "0.7.39",
4
4
  "description": "A tool for editing geospatial data for mapping and GIS.",
5
5
  "keywords": [
6
6
  "shapefile",
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
@@ -43241,7 +43262,7 @@ ${svg}
43241
43262
  return x;
43242
43263
  }
43243
43264
 
43244
- var POLAR_BUFFER_MARGIN_DEGREES = 1e-4;
43265
+ var POLAR_BUFFER_MARGIN_DEGREES$1 = 1e-4;
43245
43266
 
43246
43267
 
43247
43268
  // Returns a function for generating GeoJSON MultiPolygon geometries
@@ -43306,9 +43327,9 @@ ${svg}
43306
43327
  maxAbsLat = Math.max(maxAbsLat, Math.abs(latLngPathIter.y));
43307
43328
  }
43308
43329
  });
43309
- if (maxAbsLat + angularDist >= 90 - POLAR_BUFFER_MARGIN_DEGREES) {
43330
+ if (maxAbsLat + angularDist >= 90 - POLAR_BUFFER_MARGIN_DEGREES$1) {
43310
43331
  stop$1('Buffering lat-long coordinates near the poles is not supported; ' +
43311
- 'use the polar option for polygons sliced at the antimeridian/poles.');
43332
+ 'positive polygon buffers that reach a pole use experimental polar handling.');
43312
43333
  }
43313
43334
  }
43314
43335
 
@@ -47695,6 +47716,32 @@ ${svg}
47695
47716
 
47696
47717
  function makePolygonBuffer(lyr, dataset, opts) {
47697
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
+ }
47698
47745
  // debug-mosaic is implemented only for line buffers; for polygons it has no
47699
47746
  // handling and would leak into the per-shape dissolve and corrupt output, so
47700
47747
  // drop it and warn rather than silently mislead.
@@ -47752,6 +47799,17 @@ ${svg}
47752
47799
  }
47753
47800
  return debugDataset;
47754
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
+ }
47755
47813
  if (spherical && opts.polar) {
47756
47814
  // Pole/antimeridian-sliced polygons (grow only): keep the seam edges at the
47757
47815
  // extent and clip to the world rectangle instead of wrapping at the
@@ -48263,11 +48321,276 @@ ${svg}
48263
48321
 
48264
48322
  // World rectangle (lng/lat) the polar buffer is clipped to.
48265
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
+ }
48266
48572
 
48267
48573
  // Buffer a polygon sliced at the antimeridian (lng +/-180) and/or a pole
48268
48574
  // (lat +/-90): build the offset (the pole-touching source rings are added back,
48269
- // see makePolygonBufferGeoJSON), dissolve, and clip to the world rectangle
48270
- // 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.
48271
48594
  //
48272
48595
  // Only positive (grow) distances are supported. A negative (erode) buffer would
48273
48596
  // have to keep the artificial seam edges pinned to the extent while only the
@@ -48285,11 +48608,23 @@ ${svg}
48285
48608
  if (output.dissolveAfterSplit) {
48286
48609
  dissolveBufferDataset2(dataset2, opts);
48287
48610
  }
48611
+ if (!sourceReachesAntimeridian(dataset)) {
48612
+ splitAntimeridianBufferDataset(dataset2);
48613
+ }
48288
48614
  clipDatasetToWorldRect(dataset2);
48289
48615
  }
48290
48616
  return dataset2;
48291
48617
  }
48292
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
+
48293
48628
  function polarBufferHasNegativeDistance(lyr, dataset, opts) {
48294
48629
  var distanceFn = getBufferDistanceFunction(lyr, dataset, opts);
48295
48630
  return (lyr.shapes || []).some(function(shape, i) {
@@ -48378,8 +48713,17 @@ ${svg}
48378
48713
  // Combine a buffer geometry with the source polygon's rings into one
48379
48714
  // MultiPolygon (overlapping); a later union dissolve merges them. Used by the
48380
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.
48381
48724
  function appendSourceRings(geom, shape, arcs) {
48382
- var sourceCoords = getPolygonMultiPolygonCoords(shape, arcs);
48725
+ var sourceCoords = getPolygonMultiPolygonCoords(shape, arcs)
48726
+ .filter(polyReachesPole);
48383
48727
  var coords = [];
48384
48728
  if (geom && geom.type == 'MultiPolygon') coords = coords.concat(geom.coordinates);
48385
48729
  else if (geom && geom.type == 'Polygon') coords.push(geom.coordinates);
@@ -48388,6 +48732,16 @@ ${svg}
48388
48732
  return {type: 'MultiPolygon', coordinates: coords};
48389
48733
  }
48390
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
+
48391
48745
  function getPolygonRingBufferMaker(dataset, opts, side, winding) {
48392
48746
  // The band-method escape hatch forces the older non-winding construction even
48393
48747
  // for callers that request winding-fill (see the 'band-method' option).
@@ -55731,6 +56085,185 @@ ${svg}
55731
56085
  compiled(null, defs);
55732
56086
  };
55733
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
+
55734
56267
  // Variable name pattern. Matches simple identifiers: must start with a letter
55735
56268
  // or underscore, followed by letters, digits or underscores.
55736
56269
  var VAR_NAME_RXP = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -66773,6 +67306,9 @@ ${svg}
66773
67306
  } else if (name == 'define') {
66774
67307
  cmd.define(job.catalog, opts);
66775
67308
 
67309
+ } else if (name == 'densify') {
67310
+ cmd.densify(targetDataset, opts, targetLayers);
67311
+
66776
67312
  } else if (name == 'vars') {
66777
67313
  cmd.vars(job, opts);
66778
67314
 
@@ -67113,7 +67649,7 @@ ${svg}
67113
67649
  return name == 'rectangle' || name == 'rectangles' || name == 'filter' && opts.cleanup;
67114
67650
  }
67115
67651
 
67116
- var version = "0.7.38";
67652
+ var version = "0.7.39";
67117
67653
 
67118
67654
  // Parse command line args into commands and run them
67119
67655
  // Function takes an optional Node-style callback. A Promise is returned if no callback is given.