@trackunit/react-map-adapter-shared 0.0.119 → 0.0.121
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/README.md +3 -1
- package/index.cjs.js +243 -67
- package/index.esm.js +243 -68
- package/package.json +4 -4
- package/src/antimeridianMerge.d.ts +15 -8
- package/src/geodesicDensify.d.ts +5 -3
- package/src/sphericalMath.d.ts +16 -0
package/README.md
CHANGED
|
@@ -36,8 +36,10 @@ The canonical example is geodesic arcs. Google Maps draws great-circle edges nat
|
|
|
36
36
|
| Export | Source | Role |
|
|
37
37
|
| --- | --- | --- |
|
|
38
38
|
| `densifyGeodesicFeatures` | `geodesicDensify.ts` | Inserts intermediate coordinates along polygon/line edges so a non-geodesic renderer draws great-circle arcs |
|
|
39
|
-
| `intermediatePoint` | `sphericalMath.ts` | Spherical interpolation (slerp) between two coordinates on the WGS-84 sphere |
|
|
39
|
+
| `intermediatePoint` | `sphericalMath.ts` | Spherical interpolation (slerp) between two coordinates on the WGS-84 sphere. The returned longitude is continuous with the segment start, so it may fall outside `[-180, 180]` and will not pass `geoJsonPositionSchema` — treat it as render-only, like the antimeridian helpers below |
|
|
40
40
|
| `angularDistance` | `sphericalMath.ts` | Great-circle (haversine) distance between two coordinates |
|
|
41
|
+
| `unwrapLongitudeNear` | `sphericalMath.ts` | Picks the 360°-periodic longitude branch closest to a reference, so coordinate sequences stay continuous across the antimeridian |
|
|
42
|
+
| `mergeAntimeridianFeatures` | `antimeridianMerge.ts` | Rejoins RFC 7946 split halves and unwraps unsplit dateline crossings, so a Mercator renderer draws the seam edge across ±180° instead of around the world |
|
|
41
43
|
|
|
42
44
|
If you build a new adapter and hit a provider gap, prefer adding the cross-provider patch here over working around it at the call site.
|
|
43
45
|
|
package/index.cjs.js
CHANGED
|
@@ -37,6 +37,83 @@ const defineAdapter = (factory) => {
|
|
|
37
37
|
return wrappedFactory;
|
|
38
38
|
};
|
|
39
39
|
|
|
40
|
+
// ============================================================================
|
|
41
|
+
// Unit conversion
|
|
42
|
+
// ============================================================================
|
|
43
|
+
const toRad = (deg) => (deg * Math.PI) / 180;
|
|
44
|
+
const toDeg = (rad) => (rad * 180) / Math.PI;
|
|
45
|
+
// ============================================================================
|
|
46
|
+
// Longitude branch selection
|
|
47
|
+
// ============================================================================
|
|
48
|
+
/**
|
|
49
|
+
* Re-express `longitude` in the 360°-periodic branch nearest `reference`, so the two
|
|
50
|
+
* never differ by more than 180°.
|
|
51
|
+
*
|
|
52
|
+
* The result may fall outside `[-180, 180]` — it describes a position continuously
|
|
53
|
+
* rather than canonically, which is what Mapbox and Google Maps need to draw an edge
|
|
54
|
+
* across the antimeridian instead of back around the world. Do not persist it or feed
|
|
55
|
+
* it to a GeoJSON validator.
|
|
56
|
+
*/
|
|
57
|
+
const unwrapLongitudeNear = (longitude, reference) => {
|
|
58
|
+
const candidate = longitude + 360 * Math.round((reference - longitude) / 360);
|
|
59
|
+
// Exactly 180° apart both branches sit the same distance from the reference and there is
|
|
60
|
+
// no shorter path to pick, so leave the longitude where it is instead of flipping it.
|
|
61
|
+
return Math.abs(candidate - reference) === Math.abs(longitude - reference) ? longitude : candidate;
|
|
62
|
+
};
|
|
63
|
+
// ============================================================================
|
|
64
|
+
// Great-circle math (sphere model — WGS-84 mean radius)
|
|
65
|
+
// ============================================================================
|
|
66
|
+
/**
|
|
67
|
+
* Haversine angular distance in radians between two lng/lat points.
|
|
68
|
+
* Returns a value in [0, π].
|
|
69
|
+
*/
|
|
70
|
+
const angularDistance = (lng1, lat1, lng2, lat2) => {
|
|
71
|
+
const φ1 = toRad(lat1);
|
|
72
|
+
const φ2 = toRad(lat2);
|
|
73
|
+
const Δφ = toRad(lat2 - lat1);
|
|
74
|
+
const Δλ = toRad(lng2 - lng1);
|
|
75
|
+
const a = Math.sin(Δφ / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) ** 2;
|
|
76
|
+
const clampedA = Math.max(0, Math.min(1, a));
|
|
77
|
+
return 2 * Math.atan2(Math.sqrt(clampedA), Math.sqrt(1 - clampedA));
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* Spherical-interpolation intermediate point at fraction `f` (0 = start, 1 = end)
|
|
81
|
+
* along the great-circle arc defined by `delta` (pre-computed angular distance).
|
|
82
|
+
*
|
|
83
|
+
* Formula: http://www.movable-type.co.uk/scripts/latlong.html#intermediate-point
|
|
84
|
+
*
|
|
85
|
+
* If `start` carries an altitude the result linearly interpolates it; otherwise
|
|
86
|
+
* the result is 2-D.
|
|
87
|
+
*
|
|
88
|
+
* The returned longitude is continuous with `start[0]` rather than canonical, so
|
|
89
|
+
* interpolating across the antimeridian yields 175° → 180° → 185° instead of
|
|
90
|
+
* 175° → 180° → −175°. That keeps consecutive samples monotonic for callers that
|
|
91
|
+
* difference them (densification, tangent angles) and makes this safe to call on
|
|
92
|
+
* already-unwrapped input. See {@link unwrapLongitudeNear}.
|
|
93
|
+
*/
|
|
94
|
+
const intermediatePoint = (start, end, f, delta) => {
|
|
95
|
+
const sinDelta = Math.sin(delta);
|
|
96
|
+
// Degenerate edge (co-located points) — return start unchanged.
|
|
97
|
+
if (sinDelta === 0)
|
|
98
|
+
return start;
|
|
99
|
+
const A = Math.sin((1 - f) * delta) / sinDelta;
|
|
100
|
+
const B = Math.sin(f * delta) / sinDelta;
|
|
101
|
+
const φ1 = toRad(start[1]);
|
|
102
|
+
const λ1 = toRad(start[0]);
|
|
103
|
+
const φ2 = toRad(end[1]);
|
|
104
|
+
const λ2 = toRad(end[0]);
|
|
105
|
+
const x = A * Math.cos(φ1) * Math.cos(λ1) + B * Math.cos(φ2) * Math.cos(λ2);
|
|
106
|
+
const y = A * Math.cos(φ1) * Math.sin(λ1) + B * Math.cos(φ2) * Math.sin(λ2);
|
|
107
|
+
const z = A * Math.sin(φ1) + B * Math.sin(φ2);
|
|
108
|
+
const lat = toDeg(Math.atan2(z, Math.sqrt(x * x + y * y)));
|
|
109
|
+
const lng = unwrapLongitudeNear(toDeg(Math.atan2(y, x)), start[0]);
|
|
110
|
+
if (start.length === 3 && end.length === 3) {
|
|
111
|
+
const alt = start[2] * (1 - f) + end[2] * f;
|
|
112
|
+
return [lng, lat, alt];
|
|
113
|
+
}
|
|
114
|
+
return [lng, lat];
|
|
115
|
+
};
|
|
116
|
+
|
|
40
117
|
// ============================================================================
|
|
41
118
|
// Constants
|
|
42
119
|
// ============================================================================
|
|
@@ -471,6 +548,130 @@ const mergeMultiLineStringGeometry = (geometry) => {
|
|
|
471
548
|
};
|
|
472
549
|
};
|
|
473
550
|
// ============================================================================
|
|
551
|
+
// Unsplit crossing geometry
|
|
552
|
+
// ============================================================================
|
|
553
|
+
/** Tolerance for deciding an unwrapped ring still closes on its start longitude. */
|
|
554
|
+
const RING_CLOSURE_LONGITUDE_TOLERANCE = 1e-9;
|
|
555
|
+
/**
|
|
556
|
+
* Rewrite a coordinate sequence so each consecutive longitude pair differs by at most
|
|
557
|
+
* 180°, continuing past ±180° instead of snapping back.
|
|
558
|
+
*
|
|
559
|
+
* RFC 7946 Section 3.1.9 defines a segment as the *shorter* of the two paths between its
|
|
560
|
+
* endpoints, so an adjacent jump wider than 180° always means the edge crosses the seam.
|
|
561
|
+
* Geometry that reaches us unsplit (a single Polygon with vertices on both sides of the
|
|
562
|
+
* dateline) carries exactly that jump, and Mapbox would otherwise paint the edge straight
|
|
563
|
+
* across the whole Mercator world.
|
|
564
|
+
*
|
|
565
|
+
* `firstLongitudeReference` anchors the *first* position too, for sequences that have to land in
|
|
566
|
+
* the same branch as something else (see {@link unwrapPolygonRings}). Without it the first
|
|
567
|
+
* position is taken as given and only the rest follow from it.
|
|
568
|
+
*
|
|
569
|
+
* Returns null when every longitude was already continuous, so callers can skip the copy.
|
|
570
|
+
*/
|
|
571
|
+
const unwrapPositionSequence = (positions, firstLongitudeReference) => {
|
|
572
|
+
const first = positions[0];
|
|
573
|
+
if (first === undefined)
|
|
574
|
+
return null;
|
|
575
|
+
let modified = false;
|
|
576
|
+
let anchor = first;
|
|
577
|
+
if (firstLongitudeReference !== undefined) {
|
|
578
|
+
const longitude = unwrapLongitudeNear(first[0], firstLongitudeReference);
|
|
579
|
+
if (longitude !== first[0]) {
|
|
580
|
+
anchor = shiftPosition(first, longitude - first[0]);
|
|
581
|
+
modified = true;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
const result = [anchor];
|
|
585
|
+
let previousLongitude = anchor[0];
|
|
586
|
+
for (let i = 1; i < positions.length; i++) {
|
|
587
|
+
const position = positions[i];
|
|
588
|
+
if (position === undefined)
|
|
589
|
+
return null;
|
|
590
|
+
const longitude = unwrapLongitudeNear(position[0], previousLongitude);
|
|
591
|
+
if (longitude === position[0]) {
|
|
592
|
+
result.push(position);
|
|
593
|
+
}
|
|
594
|
+
else {
|
|
595
|
+
result.push(shiftPosition(position, longitude - position[0]));
|
|
596
|
+
modified = true;
|
|
597
|
+
}
|
|
598
|
+
previousLongitude = longitude;
|
|
599
|
+
}
|
|
600
|
+
return modified ? result : null;
|
|
601
|
+
};
|
|
602
|
+
/**
|
|
603
|
+
* Unwrap a closed ring, or null when it was already continuous or cannot be unwrapped.
|
|
604
|
+
*
|
|
605
|
+
* A ring whose unwrapped end no longer meets its start winds all the way around the globe
|
|
606
|
+
* and has no continuous representation, so it is left for the renderer to handle as-is.
|
|
607
|
+
*
|
|
608
|
+
* Note that null covers both "already continuous" and "cannot be unwrapped"; callers treat them
|
|
609
|
+
* the same, keeping the original ring either way.
|
|
610
|
+
*/
|
|
611
|
+
const unwrapLinearRing = (ring, firstLongitudeReference) => {
|
|
612
|
+
const unwrapped = unwrapPositionSequence(ring, firstLongitudeReference);
|
|
613
|
+
if (unwrapped === null)
|
|
614
|
+
return null;
|
|
615
|
+
const first = unwrapped[0];
|
|
616
|
+
const last = unwrapped[unwrapped.length - 1];
|
|
617
|
+
if (first === undefined || last === undefined)
|
|
618
|
+
return null;
|
|
619
|
+
if (Math.abs(last[0] - first[0]) > RING_CLOSURE_LONGITUDE_TOLERANCE)
|
|
620
|
+
return null;
|
|
621
|
+
return unwrapped;
|
|
622
|
+
};
|
|
623
|
+
/**
|
|
624
|
+
* Unwrap every ring of one polygon, or null when none of them moved.
|
|
625
|
+
*
|
|
626
|
+
* Interior rings are anchored on the exterior's branch instead of on their own first vertex. A
|
|
627
|
+
* hole lying wholly on the far side of the seam carries no >180° jump itself, so left to its own
|
|
628
|
+
* devices it stays put while the exterior moves — ending up 360° away and no longer inside the
|
|
629
|
+
* ring it is meant to cut, which neither renderer accepts.
|
|
630
|
+
*/
|
|
631
|
+
const unwrapPolygonRings = (rings) => {
|
|
632
|
+
const exterior = rings[0];
|
|
633
|
+
if (exterior === undefined)
|
|
634
|
+
return null;
|
|
635
|
+
const unwrappedExterior = unwrapLinearRing(exterior);
|
|
636
|
+
const resolvedExterior = unwrappedExterior ?? exterior;
|
|
637
|
+
const exteriorAnchor = resolvedExterior[0];
|
|
638
|
+
if (exteriorAnchor === undefined)
|
|
639
|
+
return null;
|
|
640
|
+
const result = [resolvedExterior];
|
|
641
|
+
let modified = unwrappedExterior !== null;
|
|
642
|
+
for (let i = 1; i < rings.length; i++) {
|
|
643
|
+
const interior = rings[i];
|
|
644
|
+
if (interior === undefined)
|
|
645
|
+
continue;
|
|
646
|
+
const unwrapped = unwrapLinearRing(interior, exteriorAnchor[0]);
|
|
647
|
+
if (unwrapped === null) {
|
|
648
|
+
result.push(interior);
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
result.push(unwrapped);
|
|
652
|
+
modified = true;
|
|
653
|
+
}
|
|
654
|
+
return modified ? result : null;
|
|
655
|
+
};
|
|
656
|
+
const unwrapLineStringGeometry = (geometry) => {
|
|
657
|
+
const coordinates = unwrapPositionSequence(geometry.coordinates);
|
|
658
|
+
return coordinates === null ? geometry : { ...geometry, coordinates };
|
|
659
|
+
};
|
|
660
|
+
const unwrapMultiLineStringGeometry = (geometry) => {
|
|
661
|
+
const coordinates = [];
|
|
662
|
+
let modified = false;
|
|
663
|
+
for (const line of geometry.coordinates) {
|
|
664
|
+
const unwrapped = unwrapPositionSequence(line);
|
|
665
|
+
if (unwrapped === null) {
|
|
666
|
+
coordinates.push(line);
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
coordinates.push(unwrapped);
|
|
670
|
+
modified = true;
|
|
671
|
+
}
|
|
672
|
+
return modified ? { ...geometry, coordinates } : geometry;
|
|
673
|
+
};
|
|
674
|
+
// ============================================================================
|
|
474
675
|
// Feature-level transformation
|
|
475
676
|
// ============================================================================
|
|
476
677
|
const mergeFeature = (feature) => {
|
|
@@ -478,8 +679,23 @@ const mergeFeature = (feature) => {
|
|
|
478
679
|
return feature;
|
|
479
680
|
}
|
|
480
681
|
switch (feature.geometry.type) {
|
|
682
|
+
case "Polygon": {
|
|
683
|
+
const coordinates = unwrapPolygonRings(feature.geometry.coordinates);
|
|
684
|
+
if (coordinates === null)
|
|
685
|
+
return feature;
|
|
686
|
+
return { ...feature, geometry: { ...feature.geometry, coordinates } };
|
|
687
|
+
}
|
|
688
|
+
case "LineString": {
|
|
689
|
+
const unwrappedGeometry = unwrapLineStringGeometry(feature.geometry);
|
|
690
|
+
if (unwrappedGeometry === feature.geometry)
|
|
691
|
+
return feature;
|
|
692
|
+
return { ...feature, geometry: unwrappedGeometry };
|
|
693
|
+
}
|
|
481
694
|
case "MultiPolygon": {
|
|
482
|
-
const
|
|
695
|
+
const merged = mergeMultiPolygonGeometry(feature.geometry);
|
|
696
|
+
// Stitched sub-polygons are already continuous, so unwrapping them is a no-op;
|
|
697
|
+
// this catches the parts that arrived unsplit and crossing on their own.
|
|
698
|
+
const mergedCoordinates = merged.map(rings => unwrapPolygonRings(rings) ?? rings);
|
|
483
699
|
const firstMerged = mergedCoordinates[0];
|
|
484
700
|
if (mergedCoordinates.length === 1 && firstMerged !== undefined) {
|
|
485
701
|
return {
|
|
@@ -511,12 +727,15 @@ const mergeFeature = (feature) => {
|
|
|
511
727
|
}
|
|
512
728
|
case "MultiLineString": {
|
|
513
729
|
const mergedGeometry = mergeMultiLineStringGeometry(feature.geometry);
|
|
514
|
-
|
|
730
|
+
const unwrappedGeometry = mergedGeometry.type === "LineString"
|
|
731
|
+
? unwrapLineStringGeometry(mergedGeometry)
|
|
732
|
+
: unwrapMultiLineStringGeometry(mergedGeometry);
|
|
733
|
+
if (unwrappedGeometry === feature.geometry) {
|
|
515
734
|
return feature;
|
|
516
735
|
}
|
|
517
736
|
return {
|
|
518
737
|
...feature,
|
|
519
|
-
geometry:
|
|
738
|
+
geometry: unwrappedGeometry,
|
|
520
739
|
};
|
|
521
740
|
}
|
|
522
741
|
default:
|
|
@@ -529,16 +748,23 @@ const mergeFeature = (feature) => {
|
|
|
529
748
|
/**
|
|
530
749
|
* Pre-process a GeoJSON FeatureCollection for antimeridian-aware rendering.
|
|
531
750
|
*
|
|
532
|
-
*
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
*
|
|
751
|
+
* Two independent problems are fixed here, both producing continuous (unwrapped)
|
|
752
|
+
* longitudes:
|
|
753
|
+
*
|
|
754
|
+
* - **Split geometry is rejoined.** MultiPolygon features that represent a single polygon
|
|
755
|
+
* split at the antimeridian (per RFC 7946 Section 3.1.9) are merged back into single
|
|
756
|
+
* Polygons. Interior rings that are also split at ±180° with matching seam latitudes are
|
|
757
|
+
* stitched the same way as exteriors. MultiLineString features that represent one route
|
|
758
|
+
* split at ±180° are merged into a single LineString. This eliminates visible strokes at
|
|
759
|
+
* the antimeridian and ensures hover/selection treats each shape as one entity.
|
|
536
760
|
*
|
|
537
|
-
* -
|
|
538
|
-
*
|
|
539
|
-
*
|
|
761
|
+
* - **Unsplit crossing geometry is unwrapped.** A Polygon or LineString whose coordinates
|
|
762
|
+
* simply straddle the dateline — which is what the site geofence API returns, rather than
|
|
763
|
+
* the RFC's split form — has its longitudes continued past ±180° instead of snapping back.
|
|
764
|
+
* Without this, Mapbox paints the seam edge as a straight Mercator line spanning the whole
|
|
765
|
+
* world; Google Maps is unaffected because `geodesic: true` already takes the shorter path.
|
|
540
766
|
*
|
|
541
|
-
* The input collection is never mutated. Features that don't need
|
|
767
|
+
* The input collection is never mutated. Features that don't need changing
|
|
542
768
|
* are returned by reference.
|
|
543
769
|
*
|
|
544
770
|
* **Important:** The output contains coordinates outside [-180, 180] and is
|
|
@@ -629,59 +855,6 @@ const mercatorCenterFromBounds = (bounds) => {
|
|
|
629
855
|
return [centerLon, centerLat];
|
|
630
856
|
};
|
|
631
857
|
|
|
632
|
-
// ============================================================================
|
|
633
|
-
// Unit conversion
|
|
634
|
-
// ============================================================================
|
|
635
|
-
const toRad = (deg) => (deg * Math.PI) / 180;
|
|
636
|
-
const toDeg = (rad) => (rad * 180) / Math.PI;
|
|
637
|
-
// ============================================================================
|
|
638
|
-
// Great-circle math (sphere model — WGS-84 mean radius)
|
|
639
|
-
// ============================================================================
|
|
640
|
-
/**
|
|
641
|
-
* Haversine angular distance in radians between two lng/lat points.
|
|
642
|
-
* Returns a value in [0, π].
|
|
643
|
-
*/
|
|
644
|
-
const angularDistance = (lng1, lat1, lng2, lat2) => {
|
|
645
|
-
const φ1 = toRad(lat1);
|
|
646
|
-
const φ2 = toRad(lat2);
|
|
647
|
-
const Δφ = toRad(lat2 - lat1);
|
|
648
|
-
const Δλ = toRad(lng2 - lng1);
|
|
649
|
-
const a = Math.sin(Δφ / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) ** 2;
|
|
650
|
-
const clampedA = Math.max(0, Math.min(1, a));
|
|
651
|
-
return 2 * Math.atan2(Math.sqrt(clampedA), Math.sqrt(1 - clampedA));
|
|
652
|
-
};
|
|
653
|
-
/**
|
|
654
|
-
* Spherical-interpolation intermediate point at fraction `f` (0 = start, 1 = end)
|
|
655
|
-
* along the great-circle arc defined by `delta` (pre-computed angular distance).
|
|
656
|
-
*
|
|
657
|
-
* Formula: http://www.movable-type.co.uk/scripts/latlong.html#intermediate-point
|
|
658
|
-
*
|
|
659
|
-
* If `start` carries an altitude the result linearly interpolates it; otherwise
|
|
660
|
-
* the result is 2-D.
|
|
661
|
-
*/
|
|
662
|
-
const intermediatePoint = (start, end, f, delta) => {
|
|
663
|
-
const sinDelta = Math.sin(delta);
|
|
664
|
-
// Degenerate edge (co-located points) — return start unchanged.
|
|
665
|
-
if (sinDelta === 0)
|
|
666
|
-
return start;
|
|
667
|
-
const A = Math.sin((1 - f) * delta) / sinDelta;
|
|
668
|
-
const B = Math.sin(f * delta) / sinDelta;
|
|
669
|
-
const φ1 = toRad(start[1]);
|
|
670
|
-
const λ1 = toRad(start[0]);
|
|
671
|
-
const φ2 = toRad(end[1]);
|
|
672
|
-
const λ2 = toRad(end[0]);
|
|
673
|
-
const x = A * Math.cos(φ1) * Math.cos(λ1) + B * Math.cos(φ2) * Math.cos(λ2);
|
|
674
|
-
const y = A * Math.cos(φ1) * Math.sin(λ1) + B * Math.cos(φ2) * Math.sin(λ2);
|
|
675
|
-
const z = A * Math.sin(φ1) + B * Math.sin(φ2);
|
|
676
|
-
const lat = toDeg(Math.atan2(z, Math.sqrt(x * x + y * y)));
|
|
677
|
-
const lng = toDeg(Math.atan2(y, x));
|
|
678
|
-
if (start.length === 3 && end.length === 3) {
|
|
679
|
-
const alt = start[2] * (1 - f) + end[2] * f;
|
|
680
|
-
return [lng, lat, alt];
|
|
681
|
-
}
|
|
682
|
-
return [lng, lat];
|
|
683
|
-
};
|
|
684
|
-
|
|
685
858
|
// ============================================================================
|
|
686
859
|
// Constants
|
|
687
860
|
// ============================================================================
|
|
@@ -808,9 +981,11 @@ const densifyFeature = (feature, maxSegmentKm) => {
|
|
|
808
981
|
* The function returns the original collection reference when no feature is
|
|
809
982
|
* modified, avoiding unnecessary downstream work.
|
|
810
983
|
*
|
|
811
|
-
*
|
|
812
|
-
*
|
|
813
|
-
*
|
|
984
|
+
* Order relative to `mergeAntimeridianFeatures` is no longer a correctness
|
|
985
|
+
* constraint: {@link intermediatePoint} returns longitudes continuous with the
|
|
986
|
+
* segment start, so densifying already-unwrapped geometry (longitudes outside
|
|
987
|
+
* `[-180, 180]`) is safe, and densifying a still-wrapped dateline crossing
|
|
988
|
+
* leaves a seam that the merge step unwraps afterwards.
|
|
814
989
|
*
|
|
815
990
|
* @param features - Source GeoJSON feature collection (RFC 7946, unmodified).
|
|
816
991
|
* @param layerGeodesic - Layer-level geodesic flag (default `true`).
|
|
@@ -2526,5 +2701,6 @@ exports.resolveSymbolDescriptor = resolveSymbolDescriptor;
|
|
|
2526
2701
|
exports.safePolygon = safePolygon;
|
|
2527
2702
|
exports.toDeg = toDeg;
|
|
2528
2703
|
exports.toRad = toRad;
|
|
2704
|
+
exports.unwrapLongitudeNear = unwrapLongitudeNear;
|
|
2529
2705
|
exports.validateInitialViewport = validateInitialViewport;
|
|
2530
2706
|
exports.watchSafeAreaLeave = watchSafeAreaLeave;
|
package/index.esm.js
CHANGED
|
@@ -35,6 +35,83 @@ const defineAdapter = (factory) => {
|
|
|
35
35
|
return wrappedFactory;
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
+
// ============================================================================
|
|
39
|
+
// Unit conversion
|
|
40
|
+
// ============================================================================
|
|
41
|
+
const toRad = (deg) => (deg * Math.PI) / 180;
|
|
42
|
+
const toDeg = (rad) => (rad * 180) / Math.PI;
|
|
43
|
+
// ============================================================================
|
|
44
|
+
// Longitude branch selection
|
|
45
|
+
// ============================================================================
|
|
46
|
+
/**
|
|
47
|
+
* Re-express `longitude` in the 360°-periodic branch nearest `reference`, so the two
|
|
48
|
+
* never differ by more than 180°.
|
|
49
|
+
*
|
|
50
|
+
* The result may fall outside `[-180, 180]` — it describes a position continuously
|
|
51
|
+
* rather than canonically, which is what Mapbox and Google Maps need to draw an edge
|
|
52
|
+
* across the antimeridian instead of back around the world. Do not persist it or feed
|
|
53
|
+
* it to a GeoJSON validator.
|
|
54
|
+
*/
|
|
55
|
+
const unwrapLongitudeNear = (longitude, reference) => {
|
|
56
|
+
const candidate = longitude + 360 * Math.round((reference - longitude) / 360);
|
|
57
|
+
// Exactly 180° apart both branches sit the same distance from the reference and there is
|
|
58
|
+
// no shorter path to pick, so leave the longitude where it is instead of flipping it.
|
|
59
|
+
return Math.abs(candidate - reference) === Math.abs(longitude - reference) ? longitude : candidate;
|
|
60
|
+
};
|
|
61
|
+
// ============================================================================
|
|
62
|
+
// Great-circle math (sphere model — WGS-84 mean radius)
|
|
63
|
+
// ============================================================================
|
|
64
|
+
/**
|
|
65
|
+
* Haversine angular distance in radians between two lng/lat points.
|
|
66
|
+
* Returns a value in [0, π].
|
|
67
|
+
*/
|
|
68
|
+
const angularDistance = (lng1, lat1, lng2, lat2) => {
|
|
69
|
+
const φ1 = toRad(lat1);
|
|
70
|
+
const φ2 = toRad(lat2);
|
|
71
|
+
const Δφ = toRad(lat2 - lat1);
|
|
72
|
+
const Δλ = toRad(lng2 - lng1);
|
|
73
|
+
const a = Math.sin(Δφ / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) ** 2;
|
|
74
|
+
const clampedA = Math.max(0, Math.min(1, a));
|
|
75
|
+
return 2 * Math.atan2(Math.sqrt(clampedA), Math.sqrt(1 - clampedA));
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* Spherical-interpolation intermediate point at fraction `f` (0 = start, 1 = end)
|
|
79
|
+
* along the great-circle arc defined by `delta` (pre-computed angular distance).
|
|
80
|
+
*
|
|
81
|
+
* Formula: http://www.movable-type.co.uk/scripts/latlong.html#intermediate-point
|
|
82
|
+
*
|
|
83
|
+
* If `start` carries an altitude the result linearly interpolates it; otherwise
|
|
84
|
+
* the result is 2-D.
|
|
85
|
+
*
|
|
86
|
+
* The returned longitude is continuous with `start[0]` rather than canonical, so
|
|
87
|
+
* interpolating across the antimeridian yields 175° → 180° → 185° instead of
|
|
88
|
+
* 175° → 180° → −175°. That keeps consecutive samples monotonic for callers that
|
|
89
|
+
* difference them (densification, tangent angles) and makes this safe to call on
|
|
90
|
+
* already-unwrapped input. See {@link unwrapLongitudeNear}.
|
|
91
|
+
*/
|
|
92
|
+
const intermediatePoint = (start, end, f, delta) => {
|
|
93
|
+
const sinDelta = Math.sin(delta);
|
|
94
|
+
// Degenerate edge (co-located points) — return start unchanged.
|
|
95
|
+
if (sinDelta === 0)
|
|
96
|
+
return start;
|
|
97
|
+
const A = Math.sin((1 - f) * delta) / sinDelta;
|
|
98
|
+
const B = Math.sin(f * delta) / sinDelta;
|
|
99
|
+
const φ1 = toRad(start[1]);
|
|
100
|
+
const λ1 = toRad(start[0]);
|
|
101
|
+
const φ2 = toRad(end[1]);
|
|
102
|
+
const λ2 = toRad(end[0]);
|
|
103
|
+
const x = A * Math.cos(φ1) * Math.cos(λ1) + B * Math.cos(φ2) * Math.cos(λ2);
|
|
104
|
+
const y = A * Math.cos(φ1) * Math.sin(λ1) + B * Math.cos(φ2) * Math.sin(λ2);
|
|
105
|
+
const z = A * Math.sin(φ1) + B * Math.sin(φ2);
|
|
106
|
+
const lat = toDeg(Math.atan2(z, Math.sqrt(x * x + y * y)));
|
|
107
|
+
const lng = unwrapLongitudeNear(toDeg(Math.atan2(y, x)), start[0]);
|
|
108
|
+
if (start.length === 3 && end.length === 3) {
|
|
109
|
+
const alt = start[2] * (1 - f) + end[2] * f;
|
|
110
|
+
return [lng, lat, alt];
|
|
111
|
+
}
|
|
112
|
+
return [lng, lat];
|
|
113
|
+
};
|
|
114
|
+
|
|
38
115
|
// ============================================================================
|
|
39
116
|
// Constants
|
|
40
117
|
// ============================================================================
|
|
@@ -469,6 +546,130 @@ const mergeMultiLineStringGeometry = (geometry) => {
|
|
|
469
546
|
};
|
|
470
547
|
};
|
|
471
548
|
// ============================================================================
|
|
549
|
+
// Unsplit crossing geometry
|
|
550
|
+
// ============================================================================
|
|
551
|
+
/** Tolerance for deciding an unwrapped ring still closes on its start longitude. */
|
|
552
|
+
const RING_CLOSURE_LONGITUDE_TOLERANCE = 1e-9;
|
|
553
|
+
/**
|
|
554
|
+
* Rewrite a coordinate sequence so each consecutive longitude pair differs by at most
|
|
555
|
+
* 180°, continuing past ±180° instead of snapping back.
|
|
556
|
+
*
|
|
557
|
+
* RFC 7946 Section 3.1.9 defines a segment as the *shorter* of the two paths between its
|
|
558
|
+
* endpoints, so an adjacent jump wider than 180° always means the edge crosses the seam.
|
|
559
|
+
* Geometry that reaches us unsplit (a single Polygon with vertices on both sides of the
|
|
560
|
+
* dateline) carries exactly that jump, and Mapbox would otherwise paint the edge straight
|
|
561
|
+
* across the whole Mercator world.
|
|
562
|
+
*
|
|
563
|
+
* `firstLongitudeReference` anchors the *first* position too, for sequences that have to land in
|
|
564
|
+
* the same branch as something else (see {@link unwrapPolygonRings}). Without it the first
|
|
565
|
+
* position is taken as given and only the rest follow from it.
|
|
566
|
+
*
|
|
567
|
+
* Returns null when every longitude was already continuous, so callers can skip the copy.
|
|
568
|
+
*/
|
|
569
|
+
const unwrapPositionSequence = (positions, firstLongitudeReference) => {
|
|
570
|
+
const first = positions[0];
|
|
571
|
+
if (first === undefined)
|
|
572
|
+
return null;
|
|
573
|
+
let modified = false;
|
|
574
|
+
let anchor = first;
|
|
575
|
+
if (firstLongitudeReference !== undefined) {
|
|
576
|
+
const longitude = unwrapLongitudeNear(first[0], firstLongitudeReference);
|
|
577
|
+
if (longitude !== first[0]) {
|
|
578
|
+
anchor = shiftPosition(first, longitude - first[0]);
|
|
579
|
+
modified = true;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
const result = [anchor];
|
|
583
|
+
let previousLongitude = anchor[0];
|
|
584
|
+
for (let i = 1; i < positions.length; i++) {
|
|
585
|
+
const position = positions[i];
|
|
586
|
+
if (position === undefined)
|
|
587
|
+
return null;
|
|
588
|
+
const longitude = unwrapLongitudeNear(position[0], previousLongitude);
|
|
589
|
+
if (longitude === position[0]) {
|
|
590
|
+
result.push(position);
|
|
591
|
+
}
|
|
592
|
+
else {
|
|
593
|
+
result.push(shiftPosition(position, longitude - position[0]));
|
|
594
|
+
modified = true;
|
|
595
|
+
}
|
|
596
|
+
previousLongitude = longitude;
|
|
597
|
+
}
|
|
598
|
+
return modified ? result : null;
|
|
599
|
+
};
|
|
600
|
+
/**
|
|
601
|
+
* Unwrap a closed ring, or null when it was already continuous or cannot be unwrapped.
|
|
602
|
+
*
|
|
603
|
+
* A ring whose unwrapped end no longer meets its start winds all the way around the globe
|
|
604
|
+
* and has no continuous representation, so it is left for the renderer to handle as-is.
|
|
605
|
+
*
|
|
606
|
+
* Note that null covers both "already continuous" and "cannot be unwrapped"; callers treat them
|
|
607
|
+
* the same, keeping the original ring either way.
|
|
608
|
+
*/
|
|
609
|
+
const unwrapLinearRing = (ring, firstLongitudeReference) => {
|
|
610
|
+
const unwrapped = unwrapPositionSequence(ring, firstLongitudeReference);
|
|
611
|
+
if (unwrapped === null)
|
|
612
|
+
return null;
|
|
613
|
+
const first = unwrapped[0];
|
|
614
|
+
const last = unwrapped[unwrapped.length - 1];
|
|
615
|
+
if (first === undefined || last === undefined)
|
|
616
|
+
return null;
|
|
617
|
+
if (Math.abs(last[0] - first[0]) > RING_CLOSURE_LONGITUDE_TOLERANCE)
|
|
618
|
+
return null;
|
|
619
|
+
return unwrapped;
|
|
620
|
+
};
|
|
621
|
+
/**
|
|
622
|
+
* Unwrap every ring of one polygon, or null when none of them moved.
|
|
623
|
+
*
|
|
624
|
+
* Interior rings are anchored on the exterior's branch instead of on their own first vertex. A
|
|
625
|
+
* hole lying wholly on the far side of the seam carries no >180° jump itself, so left to its own
|
|
626
|
+
* devices it stays put while the exterior moves — ending up 360° away and no longer inside the
|
|
627
|
+
* ring it is meant to cut, which neither renderer accepts.
|
|
628
|
+
*/
|
|
629
|
+
const unwrapPolygonRings = (rings) => {
|
|
630
|
+
const exterior = rings[0];
|
|
631
|
+
if (exterior === undefined)
|
|
632
|
+
return null;
|
|
633
|
+
const unwrappedExterior = unwrapLinearRing(exterior);
|
|
634
|
+
const resolvedExterior = unwrappedExterior ?? exterior;
|
|
635
|
+
const exteriorAnchor = resolvedExterior[0];
|
|
636
|
+
if (exteriorAnchor === undefined)
|
|
637
|
+
return null;
|
|
638
|
+
const result = [resolvedExterior];
|
|
639
|
+
let modified = unwrappedExterior !== null;
|
|
640
|
+
for (let i = 1; i < rings.length; i++) {
|
|
641
|
+
const interior = rings[i];
|
|
642
|
+
if (interior === undefined)
|
|
643
|
+
continue;
|
|
644
|
+
const unwrapped = unwrapLinearRing(interior, exteriorAnchor[0]);
|
|
645
|
+
if (unwrapped === null) {
|
|
646
|
+
result.push(interior);
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
result.push(unwrapped);
|
|
650
|
+
modified = true;
|
|
651
|
+
}
|
|
652
|
+
return modified ? result : null;
|
|
653
|
+
};
|
|
654
|
+
const unwrapLineStringGeometry = (geometry) => {
|
|
655
|
+
const coordinates = unwrapPositionSequence(geometry.coordinates);
|
|
656
|
+
return coordinates === null ? geometry : { ...geometry, coordinates };
|
|
657
|
+
};
|
|
658
|
+
const unwrapMultiLineStringGeometry = (geometry) => {
|
|
659
|
+
const coordinates = [];
|
|
660
|
+
let modified = false;
|
|
661
|
+
for (const line of geometry.coordinates) {
|
|
662
|
+
const unwrapped = unwrapPositionSequence(line);
|
|
663
|
+
if (unwrapped === null) {
|
|
664
|
+
coordinates.push(line);
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
coordinates.push(unwrapped);
|
|
668
|
+
modified = true;
|
|
669
|
+
}
|
|
670
|
+
return modified ? { ...geometry, coordinates } : geometry;
|
|
671
|
+
};
|
|
672
|
+
// ============================================================================
|
|
472
673
|
// Feature-level transformation
|
|
473
674
|
// ============================================================================
|
|
474
675
|
const mergeFeature = (feature) => {
|
|
@@ -476,8 +677,23 @@ const mergeFeature = (feature) => {
|
|
|
476
677
|
return feature;
|
|
477
678
|
}
|
|
478
679
|
switch (feature.geometry.type) {
|
|
680
|
+
case "Polygon": {
|
|
681
|
+
const coordinates = unwrapPolygonRings(feature.geometry.coordinates);
|
|
682
|
+
if (coordinates === null)
|
|
683
|
+
return feature;
|
|
684
|
+
return { ...feature, geometry: { ...feature.geometry, coordinates } };
|
|
685
|
+
}
|
|
686
|
+
case "LineString": {
|
|
687
|
+
const unwrappedGeometry = unwrapLineStringGeometry(feature.geometry);
|
|
688
|
+
if (unwrappedGeometry === feature.geometry)
|
|
689
|
+
return feature;
|
|
690
|
+
return { ...feature, geometry: unwrappedGeometry };
|
|
691
|
+
}
|
|
479
692
|
case "MultiPolygon": {
|
|
480
|
-
const
|
|
693
|
+
const merged = mergeMultiPolygonGeometry(feature.geometry);
|
|
694
|
+
// Stitched sub-polygons are already continuous, so unwrapping them is a no-op;
|
|
695
|
+
// this catches the parts that arrived unsplit and crossing on their own.
|
|
696
|
+
const mergedCoordinates = merged.map(rings => unwrapPolygonRings(rings) ?? rings);
|
|
481
697
|
const firstMerged = mergedCoordinates[0];
|
|
482
698
|
if (mergedCoordinates.length === 1 && firstMerged !== undefined) {
|
|
483
699
|
return {
|
|
@@ -509,12 +725,15 @@ const mergeFeature = (feature) => {
|
|
|
509
725
|
}
|
|
510
726
|
case "MultiLineString": {
|
|
511
727
|
const mergedGeometry = mergeMultiLineStringGeometry(feature.geometry);
|
|
512
|
-
|
|
728
|
+
const unwrappedGeometry = mergedGeometry.type === "LineString"
|
|
729
|
+
? unwrapLineStringGeometry(mergedGeometry)
|
|
730
|
+
: unwrapMultiLineStringGeometry(mergedGeometry);
|
|
731
|
+
if (unwrappedGeometry === feature.geometry) {
|
|
513
732
|
return feature;
|
|
514
733
|
}
|
|
515
734
|
return {
|
|
516
735
|
...feature,
|
|
517
|
-
geometry:
|
|
736
|
+
geometry: unwrappedGeometry,
|
|
518
737
|
};
|
|
519
738
|
}
|
|
520
739
|
default:
|
|
@@ -527,16 +746,23 @@ const mergeFeature = (feature) => {
|
|
|
527
746
|
/**
|
|
528
747
|
* Pre-process a GeoJSON FeatureCollection for antimeridian-aware rendering.
|
|
529
748
|
*
|
|
530
|
-
*
|
|
531
|
-
*
|
|
532
|
-
*
|
|
533
|
-
*
|
|
749
|
+
* Two independent problems are fixed here, both producing continuous (unwrapped)
|
|
750
|
+
* longitudes:
|
|
751
|
+
*
|
|
752
|
+
* - **Split geometry is rejoined.** MultiPolygon features that represent a single polygon
|
|
753
|
+
* split at the antimeridian (per RFC 7946 Section 3.1.9) are merged back into single
|
|
754
|
+
* Polygons. Interior rings that are also split at ±180° with matching seam latitudes are
|
|
755
|
+
* stitched the same way as exteriors. MultiLineString features that represent one route
|
|
756
|
+
* split at ±180° are merged into a single LineString. This eliminates visible strokes at
|
|
757
|
+
* the antimeridian and ensures hover/selection treats each shape as one entity.
|
|
534
758
|
*
|
|
535
|
-
* -
|
|
536
|
-
*
|
|
537
|
-
*
|
|
759
|
+
* - **Unsplit crossing geometry is unwrapped.** A Polygon or LineString whose coordinates
|
|
760
|
+
* simply straddle the dateline — which is what the site geofence API returns, rather than
|
|
761
|
+
* the RFC's split form — has its longitudes continued past ±180° instead of snapping back.
|
|
762
|
+
* Without this, Mapbox paints the seam edge as a straight Mercator line spanning the whole
|
|
763
|
+
* world; Google Maps is unaffected because `geodesic: true` already takes the shorter path.
|
|
538
764
|
*
|
|
539
|
-
* The input collection is never mutated. Features that don't need
|
|
765
|
+
* The input collection is never mutated. Features that don't need changing
|
|
540
766
|
* are returned by reference.
|
|
541
767
|
*
|
|
542
768
|
* **Important:** The output contains coordinates outside [-180, 180] and is
|
|
@@ -627,59 +853,6 @@ const mercatorCenterFromBounds = (bounds) => {
|
|
|
627
853
|
return [centerLon, centerLat];
|
|
628
854
|
};
|
|
629
855
|
|
|
630
|
-
// ============================================================================
|
|
631
|
-
// Unit conversion
|
|
632
|
-
// ============================================================================
|
|
633
|
-
const toRad = (deg) => (deg * Math.PI) / 180;
|
|
634
|
-
const toDeg = (rad) => (rad * 180) / Math.PI;
|
|
635
|
-
// ============================================================================
|
|
636
|
-
// Great-circle math (sphere model — WGS-84 mean radius)
|
|
637
|
-
// ============================================================================
|
|
638
|
-
/**
|
|
639
|
-
* Haversine angular distance in radians between two lng/lat points.
|
|
640
|
-
* Returns a value in [0, π].
|
|
641
|
-
*/
|
|
642
|
-
const angularDistance = (lng1, lat1, lng2, lat2) => {
|
|
643
|
-
const φ1 = toRad(lat1);
|
|
644
|
-
const φ2 = toRad(lat2);
|
|
645
|
-
const Δφ = toRad(lat2 - lat1);
|
|
646
|
-
const Δλ = toRad(lng2 - lng1);
|
|
647
|
-
const a = Math.sin(Δφ / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) ** 2;
|
|
648
|
-
const clampedA = Math.max(0, Math.min(1, a));
|
|
649
|
-
return 2 * Math.atan2(Math.sqrt(clampedA), Math.sqrt(1 - clampedA));
|
|
650
|
-
};
|
|
651
|
-
/**
|
|
652
|
-
* Spherical-interpolation intermediate point at fraction `f` (0 = start, 1 = end)
|
|
653
|
-
* along the great-circle arc defined by `delta` (pre-computed angular distance).
|
|
654
|
-
*
|
|
655
|
-
* Formula: http://www.movable-type.co.uk/scripts/latlong.html#intermediate-point
|
|
656
|
-
*
|
|
657
|
-
* If `start` carries an altitude the result linearly interpolates it; otherwise
|
|
658
|
-
* the result is 2-D.
|
|
659
|
-
*/
|
|
660
|
-
const intermediatePoint = (start, end, f, delta) => {
|
|
661
|
-
const sinDelta = Math.sin(delta);
|
|
662
|
-
// Degenerate edge (co-located points) — return start unchanged.
|
|
663
|
-
if (sinDelta === 0)
|
|
664
|
-
return start;
|
|
665
|
-
const A = Math.sin((1 - f) * delta) / sinDelta;
|
|
666
|
-
const B = Math.sin(f * delta) / sinDelta;
|
|
667
|
-
const φ1 = toRad(start[1]);
|
|
668
|
-
const λ1 = toRad(start[0]);
|
|
669
|
-
const φ2 = toRad(end[1]);
|
|
670
|
-
const λ2 = toRad(end[0]);
|
|
671
|
-
const x = A * Math.cos(φ1) * Math.cos(λ1) + B * Math.cos(φ2) * Math.cos(λ2);
|
|
672
|
-
const y = A * Math.cos(φ1) * Math.sin(λ1) + B * Math.cos(φ2) * Math.sin(λ2);
|
|
673
|
-
const z = A * Math.sin(φ1) + B * Math.sin(φ2);
|
|
674
|
-
const lat = toDeg(Math.atan2(z, Math.sqrt(x * x + y * y)));
|
|
675
|
-
const lng = toDeg(Math.atan2(y, x));
|
|
676
|
-
if (start.length === 3 && end.length === 3) {
|
|
677
|
-
const alt = start[2] * (1 - f) + end[2] * f;
|
|
678
|
-
return [lng, lat, alt];
|
|
679
|
-
}
|
|
680
|
-
return [lng, lat];
|
|
681
|
-
};
|
|
682
|
-
|
|
683
856
|
// ============================================================================
|
|
684
857
|
// Constants
|
|
685
858
|
// ============================================================================
|
|
@@ -806,9 +979,11 @@ const densifyFeature = (feature, maxSegmentKm) => {
|
|
|
806
979
|
* The function returns the original collection reference when no feature is
|
|
807
980
|
* modified, avoiding unnecessary downstream work.
|
|
808
981
|
*
|
|
809
|
-
*
|
|
810
|
-
*
|
|
811
|
-
*
|
|
982
|
+
* Order relative to `mergeAntimeridianFeatures` is no longer a correctness
|
|
983
|
+
* constraint: {@link intermediatePoint} returns longitudes continuous with the
|
|
984
|
+
* segment start, so densifying already-unwrapped geometry (longitudes outside
|
|
985
|
+
* `[-180, 180]`) is safe, and densifying a still-wrapped dateline crossing
|
|
986
|
+
* leaves a seam that the merge step unwraps afterwards.
|
|
812
987
|
*
|
|
813
988
|
* @param features - Source GeoJSON feature collection (RFC 7946, unmodified).
|
|
814
989
|
* @param layerGeodesic - Layer-level geodesic flag (default `true`).
|
|
@@ -2443,4 +2618,4 @@ const resolveStrokeColors = (style, shapeType, theme) => {
|
|
|
2443
2618
|
};
|
|
2444
2619
|
};
|
|
2445
2620
|
|
|
2446
|
-
export { ANCHOR_SELECTOR, CIRCLE_SYMBOL_DEFAULT_DIAMETER_PX, CIRCLE_SYMBOL_DEFAULT_OPACITY, DEFAULT_CENTER, DEFAULT_MAP_APPEARANCE, DEFAULT_ZOOM, GEODESIC_MAX_SEGMENT_KM, HIT_SURFACE_SELECTOR, INITIAL_CAMERA_STATE, INITIAL_INTERACTION_STATE, INITIAL_MAP_STATE, INITIAL_MAP_STATUS, KEYBOARD_PAN_AMOUNT, KEYBOARD_ZOOM_AMOUNT, LAYER_FADE_DURATION_MS, MAP_CURSORS, MAX_ZOOM, MIN_ZOOM, SAFE_AREA_DEFAULT_BUFFER_PX, WORLD_BBOX, allocSafeAreaDebugId, anchorFromBottomCenter, angularDistance, attachSafeAreaHoverListeners, bboxEquals, bufferRectCorners, buildAdaptiveDomEntries, buildAdaptiveDomRenderFn, buildAdaptiveSymbolStyleFn, cameraStateEquals, canPatchAdaptiveMarker, canPatchAdaptiveViewport, canPatchMarkerInPlace, clearSafeArea, collectFeatureIdSet, computeInitialState, computeMarkerDomPortalZIndex, convexHull, createClusterPinElement, createDefaultClusterElement, createSymbolDotElement, defineAdapter, densifyGeodesicFeatures, disableSafeAreaDebug, discriminateRenderResult, enableSafeAreaDebug, estimateZoomFromBounds, extractLineCoordinates, extractPointCoordinates, extractPolygonPaths, extractSourceData, fadeInElement, filterRectsByCursorDirection, geometryTypeToShapeType, getAdaptiveDomFeatureIds, getAnchorRect, getEffectiveRestrictBounds, getHitSurfaceRect, hasSameFeatureIds, intermediatePoint, isCanvasMarkerMode, isEventOfType, isSafeAreaDebugEnabled, mapAppearanceSchema, mapStateEquals, mapThemeSchema, mapTypeSchema, mercatorCenterFromBounds, mergeAntimeridianFeatures, patchPortalDescriptors, pointInPolygon, removeGoneIndexedMarkers, renderSafeArea, resolveCircleSymbolDefaults, resolveHoveredStyle, resolveSelectedStyle, resolveStrokeColors, resolveSymbolDescriptor, safePolygon, toDeg, toRad, validateInitialViewport, watchSafeAreaLeave };
|
|
2621
|
+
export { ANCHOR_SELECTOR, CIRCLE_SYMBOL_DEFAULT_DIAMETER_PX, CIRCLE_SYMBOL_DEFAULT_OPACITY, DEFAULT_CENTER, DEFAULT_MAP_APPEARANCE, DEFAULT_ZOOM, GEODESIC_MAX_SEGMENT_KM, HIT_SURFACE_SELECTOR, INITIAL_CAMERA_STATE, INITIAL_INTERACTION_STATE, INITIAL_MAP_STATE, INITIAL_MAP_STATUS, KEYBOARD_PAN_AMOUNT, KEYBOARD_ZOOM_AMOUNT, LAYER_FADE_DURATION_MS, MAP_CURSORS, MAX_ZOOM, MIN_ZOOM, SAFE_AREA_DEFAULT_BUFFER_PX, WORLD_BBOX, allocSafeAreaDebugId, anchorFromBottomCenter, angularDistance, attachSafeAreaHoverListeners, bboxEquals, bufferRectCorners, buildAdaptiveDomEntries, buildAdaptiveDomRenderFn, buildAdaptiveSymbolStyleFn, cameraStateEquals, canPatchAdaptiveMarker, canPatchAdaptiveViewport, canPatchMarkerInPlace, clearSafeArea, collectFeatureIdSet, computeInitialState, computeMarkerDomPortalZIndex, convexHull, createClusterPinElement, createDefaultClusterElement, createSymbolDotElement, defineAdapter, densifyGeodesicFeatures, disableSafeAreaDebug, discriminateRenderResult, enableSafeAreaDebug, estimateZoomFromBounds, extractLineCoordinates, extractPointCoordinates, extractPolygonPaths, extractSourceData, fadeInElement, filterRectsByCursorDirection, geometryTypeToShapeType, getAdaptiveDomFeatureIds, getAnchorRect, getEffectiveRestrictBounds, getHitSurfaceRect, hasSameFeatureIds, intermediatePoint, isCanvasMarkerMode, isEventOfType, isSafeAreaDebugEnabled, mapAppearanceSchema, mapStateEquals, mapThemeSchema, mapTypeSchema, mercatorCenterFromBounds, mergeAntimeridianFeatures, patchPortalDescriptors, pointInPolygon, removeGoneIndexedMarkers, renderSafeArea, resolveCircleSymbolDefaults, resolveHoveredStyle, resolveSelectedStyle, resolveStrokeColors, resolveSymbolDescriptor, safePolygon, toDeg, toRad, unwrapLongitudeNear, validateInitialViewport, watchSafeAreaLeave };
|
package/package.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trackunit/react-map-adapter-shared",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.121",
|
|
4
4
|
"repository": "https://github.com/Trackunit/manager",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.txt",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=24.x"
|
|
8
8
|
},
|
|
9
9
|
"dependencies": {
|
|
10
|
-
"@trackunit/react-map-color-utils": "0.0.
|
|
11
|
-
"@trackunit/geo-json-utils": "1.15.
|
|
12
|
-
"@trackunit/ui-design-tokens": "1.15.
|
|
10
|
+
"@trackunit/react-map-color-utils": "0.0.100",
|
|
11
|
+
"@trackunit/geo-json-utils": "1.15.39",
|
|
12
|
+
"@trackunit/ui-design-tokens": "1.15.26",
|
|
13
13
|
"es-toolkit": "^1.39.10",
|
|
14
14
|
"zod": "^3.25.76"
|
|
15
15
|
},
|
|
@@ -2,16 +2,23 @@ import type { GeoJsonFeatureCollection } from "@trackunit/geo-json-utils";
|
|
|
2
2
|
/**
|
|
3
3
|
* Pre-process a GeoJSON FeatureCollection for antimeridian-aware rendering.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* unwrapped coordinates (lng values may exceed 180). Interior rings that are also
|
|
8
|
-
* split at ±180° with matching seam latitudes are stitched the same way as exteriors.
|
|
5
|
+
* Two independent problems are fixed here, both producing continuous (unwrapped)
|
|
6
|
+
* longitudes:
|
|
9
7
|
*
|
|
10
|
-
* -
|
|
11
|
-
*
|
|
12
|
-
*
|
|
8
|
+
* - **Split geometry is rejoined.** MultiPolygon features that represent a single polygon
|
|
9
|
+
* split at the antimeridian (per RFC 7946 Section 3.1.9) are merged back into single
|
|
10
|
+
* Polygons. Interior rings that are also split at ±180° with matching seam latitudes are
|
|
11
|
+
* stitched the same way as exteriors. MultiLineString features that represent one route
|
|
12
|
+
* split at ±180° are merged into a single LineString. This eliminates visible strokes at
|
|
13
|
+
* the antimeridian and ensures hover/selection treats each shape as one entity.
|
|
13
14
|
*
|
|
14
|
-
*
|
|
15
|
+
* - **Unsplit crossing geometry is unwrapped.** A Polygon or LineString whose coordinates
|
|
16
|
+
* simply straddle the dateline — which is what the site geofence API returns, rather than
|
|
17
|
+
* the RFC's split form — has its longitudes continued past ±180° instead of snapping back.
|
|
18
|
+
* Without this, Mapbox paints the seam edge as a straight Mercator line spanning the whole
|
|
19
|
+
* world; Google Maps is unaffected because `geodesic: true` already takes the shorter path.
|
|
20
|
+
*
|
|
21
|
+
* The input collection is never mutated. Features that don't need changing
|
|
15
22
|
* are returned by reference.
|
|
16
23
|
*
|
|
17
24
|
* **Important:** The output contains coordinates outside [-180, 180] and is
|
package/src/geodesicDensify.d.ts
CHANGED
|
@@ -27,9 +27,11 @@ export declare const GEODESIC_MAX_SEGMENT_KM = 100;
|
|
|
27
27
|
* The function returns the original collection reference when no feature is
|
|
28
28
|
* modified, avoiding unnecessary downstream work.
|
|
29
29
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
30
|
+
* Order relative to `mergeAntimeridianFeatures` is no longer a correctness
|
|
31
|
+
* constraint: {@link intermediatePoint} returns longitudes continuous with the
|
|
32
|
+
* segment start, so densifying already-unwrapped geometry (longitudes outside
|
|
33
|
+
* `[-180, 180]`) is safe, and densifying a still-wrapped dateline crossing
|
|
34
|
+
* leaves a seam that the merge step unwraps afterwards.
|
|
33
35
|
*
|
|
34
36
|
* @param features - Source GeoJSON feature collection (RFC 7946, unmodified).
|
|
35
37
|
* @param layerGeodesic - Layer-level geodesic flag (default `true`).
|
package/src/sphericalMath.d.ts
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import type { GeoJsonPosition } from "@trackunit/geo-json-utils";
|
|
2
2
|
export declare const toRad: (deg: number) => number;
|
|
3
3
|
export declare const toDeg: (rad: number) => number;
|
|
4
|
+
/**
|
|
5
|
+
* Re-express `longitude` in the 360°-periodic branch nearest `reference`, so the two
|
|
6
|
+
* never differ by more than 180°.
|
|
7
|
+
*
|
|
8
|
+
* The result may fall outside `[-180, 180]` — it describes a position continuously
|
|
9
|
+
* rather than canonically, which is what Mapbox and Google Maps need to draw an edge
|
|
10
|
+
* across the antimeridian instead of back around the world. Do not persist it or feed
|
|
11
|
+
* it to a GeoJSON validator.
|
|
12
|
+
*/
|
|
13
|
+
export declare const unwrapLongitudeNear: (longitude: number, reference: number) => number;
|
|
4
14
|
/**
|
|
5
15
|
* Haversine angular distance in radians between two lng/lat points.
|
|
6
16
|
* Returns a value in [0, π].
|
|
@@ -14,5 +24,11 @@ export declare const angularDistance: (lng1: number, lat1: number, lng2: number,
|
|
|
14
24
|
*
|
|
15
25
|
* If `start` carries an altitude the result linearly interpolates it; otherwise
|
|
16
26
|
* the result is 2-D.
|
|
27
|
+
*
|
|
28
|
+
* The returned longitude is continuous with `start[0]` rather than canonical, so
|
|
29
|
+
* interpolating across the antimeridian yields 175° → 180° → 185° instead of
|
|
30
|
+
* 175° → 180° → −175°. That keeps consecutive samples monotonic for callers that
|
|
31
|
+
* difference them (densification, tangent angles) and makes this safe to call on
|
|
32
|
+
* already-unwrapped input. See {@link unwrapLongitudeNear}.
|
|
17
33
|
*/
|
|
18
34
|
export declare const intermediatePoint: (start: GeoJsonPosition, end: GeoJsonPosition, f: number, delta: number) => GeoJsonPosition;
|