@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 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 mergedCoordinates = mergeMultiPolygonGeometry(feature.geometry);
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
- if (mergedGeometry === feature.geometry) {
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: mergedGeometry,
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
- * - MultiPolygon features that represent a single polygon split at the antimeridian
533
- * (per RFC 7946 Section 3.1.9) are merged back into single Polygons with
534
- * unwrapped coordinates (lng values may exceed 180). Interior rings that are also
535
- * split at ±180° with matching seam latitudes are stitched the same way as exteriors.
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
- * - MultiLineString features that represent one route split at ±180° are merged
538
- * into a single LineString with the same unwrapping. This eliminates visible strokes
539
- * at the antimeridian and ensures hover/selection treats each shape as one entity.
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 merging
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
- * Run this **before** `mergeAntimeridianFeatures` — antimeridian-merged
812
- * polygons may carry longitudes outside `[-180, 180]`, which would corrupt
813
- * the `atan2` result inside {@link intermediatePoint}.
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 mergedCoordinates = mergeMultiPolygonGeometry(feature.geometry);
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
- if (mergedGeometry === feature.geometry) {
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: mergedGeometry,
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
- * - MultiPolygon features that represent a single polygon split at the antimeridian
531
- * (per RFC 7946 Section 3.1.9) are merged back into single Polygons with
532
- * unwrapped coordinates (lng values may exceed 180). Interior rings that are also
533
- * split at ±180° with matching seam latitudes are stitched the same way as exteriors.
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
- * - MultiLineString features that represent one route split at ±180° are merged
536
- * into a single LineString with the same unwrapping. This eliminates visible strokes
537
- * at the antimeridian and ensures hover/selection treats each shape as one entity.
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 merging
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
- * Run this **before** `mergeAntimeridianFeatures` — antimeridian-merged
810
- * polygons may carry longitudes outside `[-180, 180]`, which would corrupt
811
- * the `atan2` result inside {@link intermediatePoint}.
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.119",
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.99",
11
- "@trackunit/geo-json-utils": "1.15.38",
12
- "@trackunit/ui-design-tokens": "1.15.25",
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
- * - MultiPolygon features that represent a single polygon split at the antimeridian
6
- * (per RFC 7946 Section 3.1.9) are merged back into single Polygons with
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
- * - MultiLineString features that represent one route split at ±180° are merged
11
- * into a single LineString with the same unwrapping. This eliminates visible strokes
12
- * at the antimeridian and ensures hover/selection treats each shape as one entity.
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
- * The input collection is never mutated. Features that don't need merging
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
@@ -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
- * Run this **before** `mergeAntimeridianFeatures` — antimeridian-merged
31
- * polygons may carry longitudes outside `[-180, 180]`, which would corrupt
32
- * the `atan2` result inside {@link intermediatePoint}.
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`).
@@ -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;