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 +543 -7
- package/package.json +1 -1
- package/www/mapshaper.js +543 -7
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
|
-
'
|
|
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
|
|
48270
|
-
//
|
|
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.
|
|
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
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
|
-
'
|
|
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
|
|
48270
|
-
//
|
|
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.
|
|
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.
|