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