@trackunit/geo-json-utils 1.11.91 → 1.12.2

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/index.cjs.js CHANGED
@@ -593,6 +593,7 @@ const getBboxFromGeoJsonPolygon = (polygon) => {
593
593
  const { minLon, maxLon } = getMinMaxLongitudes(longitudes);
594
594
  return [minLon, Math.min(...latitudes), maxLon, Math.max(...latitudes)];
595
595
  };
596
+ /** Folds any longitude to the [-180, 180] range. */
596
597
  const normalizeLongitude = (lon) => ((lon + 540) % 360) - 180;
597
598
  const WESTERN_HEMISPHERE = [
598
599
  [
@@ -612,18 +613,148 @@ const EASTERN_HEMISPHERE = [
612
613
  [0, -90],
613
614
  ],
614
615
  ];
616
+ /** Unwrapped longitudes in (180°, 360°] — western half of a shape that crossed +180°. */
617
+ const EAST_OF_ANTIMERIDIAN_CLIP = [
618
+ [
619
+ [180, -90],
620
+ [360, -90],
621
+ [360, 90],
622
+ [180, 90],
623
+ [180, -90],
624
+ ],
625
+ ];
626
+ /** Unwrapped longitudes in [-360°, -180°) — eastern half of a shape that crossed -180°. */
627
+ const WEST_OF_ANTIMERIDIAN_CLIP = [
628
+ [
629
+ [-360, -90],
630
+ [-180, -90],
631
+ [-180, 90],
632
+ [-360, 90],
633
+ [-360, -90],
634
+ ],
635
+ ];
636
+ const shiftClipPolygonLongitudes = (polygon, offset) => polygon.map(ring => ring.map(([x, y]) => [x + offset, y]));
637
+ const signedAreaOfClosedClipRing = (ring) => {
638
+ let sum = 0;
639
+ for (let i = 0; i < ring.length - 1; i++) {
640
+ const a = ring[i];
641
+ const b = ring[i + 1];
642
+ if (a === undefined || b === undefined) {
643
+ continue;
644
+ }
645
+ sum += a[0] * b[1] - b[0] * a[1];
646
+ }
647
+ return sum / 2;
648
+ };
649
+ /**
650
+ * Intersection with [180°, 360°] often traces the dateline as thousands of
651
+ * near-180° samples instead of a straight ±180° seam. That breaks
652
+ * antimeridian stitching (no consecutive ±180 vertices). Collapse the long
653
+ * near-dateline detour and keep the true western bulk arc.
654
+ */
655
+ const repairWesternDatelineOverflowExteriorRing = (ring) => {
656
+ const openLen = ring.length - 1;
657
+ if (openLen < 4) {
658
+ return ring;
659
+ }
660
+ const antimeridianTol = 1e-5;
661
+ const amIndices = [];
662
+ for (let i = 0; i < openLen; i++) {
663
+ const lng = ring[i]?.[0];
664
+ if (lng !== undefined && Math.abs(lng + 180) < antimeridianTol) {
665
+ amIndices.push(i);
666
+ }
667
+ }
668
+ if (amIndices.length < 2) {
669
+ return ring;
670
+ }
671
+ const firstAm = amIndices[0];
672
+ const lastAm = amIndices[amIndices.length - 1];
673
+ if (firstAm === undefined || lastAm === undefined) {
674
+ return ring;
675
+ }
676
+ const vFirst = ring[firstAm];
677
+ const vLast = ring[lastAm];
678
+ if (vFirst === undefined || vLast === undefined) {
679
+ return ring;
680
+ }
681
+ const southIdx = vFirst[1] <= vLast[1] ? firstAm : lastAm;
682
+ const northIdx = vFirst[1] <= vLast[1] ? lastAm : firstAm;
683
+ const mainLonThreshold = -179.5;
684
+ const mainIndices = [];
685
+ for (let i = 0; i < openLen; i++) {
686
+ if (i === southIdx || i === northIdx) {
687
+ continue;
688
+ }
689
+ const lng = ring[i]?.[0];
690
+ if (lng !== undefined && lng > mainLonThreshold) {
691
+ mainIndices.push(i);
692
+ }
693
+ }
694
+ if (mainIndices.length === 0) {
695
+ return ring;
696
+ }
697
+ const vertexCountBetweenSouthAndNorth = northIdx > southIdx ? northIdx - southIdx - 1 : openLen - southIdx + northIdx - 1;
698
+ if (vertexCountBetweenSouthAndNorth < 100) {
699
+ return ring;
700
+ }
701
+ const south = ring[southIdx];
702
+ const north = ring[northIdx];
703
+ if (south === undefined || north === undefined) {
704
+ return ring;
705
+ }
706
+ const orderedMain = mainIndices.toSorted((a, b) => a - b);
707
+ const middle = [];
708
+ for (const idx of orderedMain) {
709
+ const pt = ring[idx];
710
+ if (pt !== undefined) {
711
+ middle.push(pt);
712
+ }
713
+ }
714
+ const originalSign = Math.sign(signedAreaOfClosedClipRing(ring));
715
+ // Seam on the western piece must run north→south at lng=-180 so latitudes are the reverse of
716
+ // the eastern piece at +180; mergeAntimeridianFeatures matches east vs reversed-west runs.
717
+ let candidate = [north, south, ...middle, north];
718
+ if (originalSign !== 0 && Math.sign(signedAreaOfClosedClipRing(candidate)) !== originalSign) {
719
+ const open = candidate.slice(0, -1).toReversed();
720
+ const first = open[0];
721
+ if (first !== undefined) {
722
+ candidate = [...open, first];
723
+ }
724
+ }
725
+ return candidate;
726
+ };
727
+ const mapWestOverflowPolygon = (poly) => {
728
+ const shifted = shiftClipPolygonLongitudes(poly, -360);
729
+ const [ext, ...holes] = shifted;
730
+ if (ext === undefined) {
731
+ return shifted;
732
+ }
733
+ return [repairWesternDatelineOverflowExteriorRing(ext), ...holes];
734
+ };
615
735
  /**
616
736
  * @description Creates a round polygon from a point and a radius.
617
- * Handles antimeridian crossing per RFC 7946 Section 3.1.9 by splitting
737
+ *
738
+ * - Handles **antimeridian crossing** per RFC 7946 Section 3.1.9 by splitting
618
739
  * into a MultiPolygon. Clamps polar latitudes to [-90, 90].
619
740
  *
620
- * Returns null when the polygon crosses the antimeridian but both hemisphere
741
+ * - **Center longitude:** Map inputs may be outside [-180, 180] (e.g. 540°). The center is folded once by
742
+ * whole 360° turns into that range—same meridian on the ground; only the number used for math changes.
743
+ * - **Sampling the ring:** Each vertex is `lonCenter + offset` along the loop. Vertices use **continuous**
744
+ * longitude (no per-point normalization to [-180, 180]) so the planar ring does not self-intersect at the
745
+ * dateline; each hemisphere is clipped separately. We do not normalize every vertex to [-180, 180] by
746
+ * itself; that would turn a short step across the dateline into a long wrong chord. The western overflow
747
+ * piece is repaired so the ±180° seam is a short edge, which allows consumers such as
748
+ * `mergeAntimeridianFeatures` to merge halves for rendering.
749
+ *
750
+ * Returns **null** when the polygon crosses the antimeridian but both hemisphere
621
751
  * intersections yield zero polygons (e.g. very small polygons near the
622
752
  * dateline). In that case, returning the original coordinates would produce
623
- * invalid geometry per RFC 7946.
753
+ * invalid geometry per RFC 7946 Section 3.1.9.
624
754
  */
625
755
  const getPolygonFromPointAndRadius = (point, radius) => {
626
756
  const [lon, lat] = point.coordinates;
757
+ const lonCenter = normalizeLongitude(lon);
627
758
  const pointsCount = Math.max(32, Math.floor(radius / 100));
628
759
  const angleStep = (2 * Math.PI) / pointsCount;
629
760
  const deltaLat = (radius / EARTH_RADIUS) * (180 / Math.PI);
@@ -632,14 +763,30 @@ const getPolygonFromPointAndRadius = (point, radius) => {
632
763
  for (let i = 0; i <= pointsCount; i++) {
633
764
  const angle = i * angleStep;
634
765
  const newLat = Math.max(-90, Math.min(90, lat + deltaLat * Math.sin(angle)));
635
- const newLon = normalizeLongitude(lon + deltaLon * Math.cos(angle));
766
+ const newLon = lonCenter + deltaLon * Math.cos(angle);
636
767
  coordinates.push([newLon, newLat]);
637
768
  }
638
- const longitudes = coordinates.map(c => c[0]);
639
- if (checkCrossesMeridian(longitudes)) {
640
- const rawPolygon = [coordinates.map(c => [c[0], c[1]])];
641
- const westPart = polygonClipping.intersection(rawPolygon, WESTERN_HEMISPHERE);
769
+ const rawLngs = coordinates.map(c => c[0]);
770
+ const minRawLon = Math.min(...rawLngs);
771
+ const maxRawLon = Math.max(...rawLngs);
772
+ const rawPolygon = [coordinates.map(c => [c[0], c[1]])];
773
+ if (maxRawLon > 180) {
642
774
  const eastPart = polygonClipping.intersection(rawPolygon, EASTERN_HEMISPHERE);
775
+ const westOverflow = polygonClipping.intersection(rawPolygon, EAST_OF_ANTIMERIDIAN_CLIP);
776
+ const westPart = westOverflow.map(mapWestOverflowPolygon);
777
+ const allParts = [...westPart, ...eastPart];
778
+ if (allParts.length === 1 && allParts[0]) {
779
+ return { type: "Polygon", coordinates: allParts[0] };
780
+ }
781
+ if (allParts.length > 1) {
782
+ return { type: "MultiPolygon", coordinates: allParts };
783
+ }
784
+ return null;
785
+ }
786
+ if (minRawLon < -180) {
787
+ const westPart = polygonClipping.intersection(rawPolygon, WESTERN_HEMISPHERE);
788
+ const eastOverflow = polygonClipping.intersection(rawPolygon, WEST_OF_ANTIMERIDIAN_CLIP);
789
+ const eastPart = eastOverflow.map(poly => shiftClipPolygonLongitudes(poly, 360));
643
790
  const allParts = [...westPart, ...eastPart];
644
791
  if (allParts.length === 1 && allParts[0]) {
645
792
  return { type: "Polygon", coordinates: allParts[0] };
@@ -647,9 +794,6 @@ const getPolygonFromPointAndRadius = (point, radius) => {
647
794
  if (allParts.length > 1) {
648
795
  return { type: "MultiPolygon", coordinates: allParts };
649
796
  }
650
- // Both hemisphere intersections produced zero polygons — degenerate case
651
- // (e.g. very small polygon near antimeridian). Returning the original
652
- // coordinates would produce invalid geometry per RFC 7946 Section 3.1.9.
653
797
  return null;
654
798
  }
655
799
  return {
@@ -657,6 +801,230 @@ const getPolygonFromPointAndRadius = (point, radius) => {
657
801
  coordinates: [coordinates],
658
802
  };
659
803
  };
804
+ /**
805
+ * Interpolate the latitude where an edge crossing a longitude boundary intersects it.
806
+ */
807
+ const interpolateLatAtBoundary = (from, to, boundaryLng) => {
808
+ const t = (boundaryLng - from[0]) / (to[0] - from[0]);
809
+ const lat = from[1] + t * (to[1] - from[1]);
810
+ return [boundaryLng, lat];
811
+ };
812
+ /**
813
+ * Split a ring at a longitude boundary into an "inside" ring (lng on the keepSide
814
+ * of the boundary) and an "outside" ring (lng on the other side).
815
+ * Returns closed rings with intersection points inserted at boundary crossings.
816
+ */
817
+ const splitRingAtBoundary = (ring, boundary, insideTest) => {
818
+ const insideVertices = [];
819
+ const outsideVertices = [];
820
+ for (let i = 0; i < ring.length - 1; i++) {
821
+ const current = ring[i];
822
+ const next = ring[i + 1];
823
+ if (current === undefined || next === undefined) {
824
+ continue;
825
+ }
826
+ const currentInside = insideTest(current[0]);
827
+ const nextInside = insideTest(next[0]);
828
+ if (currentInside) {
829
+ insideVertices.push(current);
830
+ }
831
+ else {
832
+ outsideVertices.push(current);
833
+ }
834
+ if (currentInside !== nextInside) {
835
+ const intersection = interpolateLatAtBoundary(current, next, boundary);
836
+ insideVertices.push(intersection);
837
+ outsideVertices.push(intersection);
838
+ }
839
+ }
840
+ const firstInside = insideVertices[0];
841
+ if (insideVertices.length > 0 && firstInside !== undefined) {
842
+ insideVertices.push(firstInside);
843
+ }
844
+ const firstOutside = outsideVertices[0];
845
+ if (outsideVertices.length > 0 && firstOutside !== undefined) {
846
+ outsideVertices.push(firstOutside);
847
+ }
848
+ return { inside: insideVertices, outside: outsideVertices };
849
+ };
850
+ /**
851
+ * Shift all longitudes in a ring by a given offset.
852
+ */
853
+ const shiftRingLongitudes = (ring, offset) => ring.map(pos => (pos.length === 3 ? [pos[0] + offset, pos[1], pos[2]] : [pos[0] + offset, pos[1]]));
854
+ /**
855
+ * Removes consecutive duplicate positions from a closed linear ring (including
856
+ * duplicate seam vertices produced when two edges meet at a boundary crossing).
857
+ */
858
+ const dedupeConsecutiveLinearRing = (ring) => {
859
+ if (ring.length <= 2) {
860
+ return ring;
861
+ }
862
+ const seed = ring[0];
863
+ if (seed === undefined) {
864
+ return ring;
865
+ }
866
+ const out = [seed];
867
+ for (let i = 1; i < ring.length; i++) {
868
+ const prev = out[out.length - 1];
869
+ const cur = ring[i];
870
+ if (prev === undefined || cur === undefined) {
871
+ continue;
872
+ }
873
+ const same2d = prev[0] === cur[0] && prev[1] === cur[1];
874
+ const sameZ = prev.length < 3 || cur.length < 3 ? true : prev[2] === cur[2];
875
+ if (!same2d || !sameZ) {
876
+ out.push(cur);
877
+ }
878
+ }
879
+ const first = out[0];
880
+ const last = out[out.length - 1];
881
+ if (first !== undefined && last !== undefined && (first[0] !== last[0] || first[1] !== last[1])) {
882
+ out.push(first);
883
+ }
884
+ return out;
885
+ };
886
+ const ringLongitudeBounds = (ring) => {
887
+ const lngs = ring.map(p => p[0]);
888
+ return { minLng: Math.min(...lngs), maxLng: Math.max(...lngs) };
889
+ };
890
+ /**
891
+ * For a hole ring fully in [-180, 180], assigns it to the first or second
892
+ * MultiPolygon member so hole indices align with merge (east i-th hole pairs with west i-th hole):
893
+ * the i-th hole on the first shell pairs with the i-th hole on the second.
894
+ * Uses longitude midpoint: Pacific / positive → first polygon, Americas / negative → second.
895
+ */
896
+ const oneSidedHoleGoesToFirstMultiPolygonMember = (hole) => {
897
+ const { minLng, maxLng } = ringLongitudeBounds(hole);
898
+ const midLng = (minLng + maxLng) / 2;
899
+ if (midLng > 0) {
900
+ return true;
901
+ }
902
+ if (midLng < 0) {
903
+ return false;
904
+ }
905
+ const firstLng = hole[0]?.[0] ?? 0;
906
+ return firstLng >= 0;
907
+ };
908
+ /**
909
+ * Appends interior rings to east/west shell lists for an antimeridian split.
910
+ * Rings that already lie in [-180, 180] are assigned to exactly one half (see
911
+ * `oneSidedHoleGoesToFirstMultiPolygonMember`). Rings that cross the seam are
912
+ * split via `splitCrossingHole`, which returns false when either fragment has
913
+ * fewer than four positions — invalid for a GeoJSON closed linear ring — in that
914
+ * case this function returns false and the caller must discard the whole split
915
+ * and return the input polygon unchanged.
916
+ */
917
+ const distributeInteriorRingsForAntimeridianSplit = (holeRings, eastHoles, westHoles, splitCrossingHole) => {
918
+ for (const hole of holeRings) {
919
+ const hb = ringLongitudeBounds(hole);
920
+ if (hb.maxLng <= 180 && hb.minLng >= -180) {
921
+ if (oneSidedHoleGoesToFirstMultiPolygonMember(hole)) {
922
+ eastHoles.push(hole);
923
+ }
924
+ else {
925
+ westHoles.push(hole);
926
+ }
927
+ }
928
+ else if (!splitCrossingHole(hole)) {
929
+ return false;
930
+ }
931
+ }
932
+ return true;
933
+ };
934
+ /**
935
+ * @description Splits a polygon (exterior + holes) at the antimeridian (±180°)
936
+ * into a two-member MultiPolygon per RFC 7946 Section 3.1.9, preserving
937
+ * interior rings and pairing east/west hole fragments by stable original order.
938
+ * If the exterior does not cross the antimeridian, the input is returned unchanged
939
+ * (including any hole coordinates).
940
+ *
941
+ * Interior rings that lie entirely in [-180, 180] are assigned to exactly one
942
+ * shell using longitude midpoint (see `oneSidedHoleGoesToFirstMultiPolygonMember`).
943
+ * If splitting the exterior or any crossing hole would produce a fragment with
944
+ * fewer than four positions (not a valid closed ring), the original polygon is
945
+ * returned unchanged.
946
+ */
947
+ const splitPolygonWithHolesAtAntimeridian = (polygon) => {
948
+ const outerRing = polygon.coordinates[0];
949
+ if (outerRing === undefined) {
950
+ return polygon;
951
+ }
952
+ const holeRings = polygon.coordinates.slice(1);
953
+ const { maxLng, minLng } = ringLongitudeBounds(outerRing);
954
+ if (maxLng <= 180 && minLng >= -180) {
955
+ return polygon;
956
+ }
957
+ const eastHoles = [];
958
+ const westHoles = [];
959
+ const pushSplitHolePlus180 = (hole) => {
960
+ const { inside: eastRing, outside: westRing } = splitRingAtBoundary(hole, 180, lng => lng <= 180);
961
+ if (eastRing.length < 4 || westRing.length < 4) {
962
+ return false;
963
+ }
964
+ eastHoles.push(dedupeConsecutiveLinearRing(eastRing));
965
+ westHoles.push(dedupeConsecutiveLinearRing(shiftRingLongitudes(westRing, -360)));
966
+ return true;
967
+ };
968
+ const pushSplitHoleMinus180 = (hole) => {
969
+ const { inside: holeWestInside, outside: holeEastOutside } = splitRingAtBoundary(hole, -180, lng => lng >= -180);
970
+ if (holeEastOutside.length < 4 || holeWestInside.length < 4) {
971
+ return false;
972
+ }
973
+ eastHoles.push(dedupeConsecutiveLinearRing(shiftRingLongitudes(holeEastOutside, 360)));
974
+ westHoles.push(dedupeConsecutiveLinearRing(holeWestInside));
975
+ return true;
976
+ };
977
+ if (maxLng > 180) {
978
+ const { inside: eastRing, outside: westRing } = splitRingAtBoundary(outerRing, 180, lng => lng <= 180);
979
+ if (eastRing.length < 4 || westRing.length < 4) {
980
+ return polygon;
981
+ }
982
+ const eastOuter = dedupeConsecutiveLinearRing(eastRing);
983
+ const westOuter = dedupeConsecutiveLinearRing(shiftRingLongitudes(westRing, -360));
984
+ if (!distributeInteriorRingsForAntimeridianSplit(holeRings, eastHoles, westHoles, pushSplitHolePlus180)) {
985
+ return polygon;
986
+ }
987
+ return {
988
+ type: "MultiPolygon",
989
+ coordinates: [
990
+ [eastOuter, ...eastHoles],
991
+ [westOuter, ...westHoles],
992
+ ],
993
+ };
994
+ }
995
+ const { inside: outerWestInside, outside: outerEastOutside } = splitRingAtBoundary(outerRing, -180, lng => lng >= -180);
996
+ if (outerEastOutside.length < 4 || outerWestInside.length < 4) {
997
+ return polygon;
998
+ }
999
+ const minus180EastOuter = dedupeConsecutiveLinearRing(shiftRingLongitudes(outerEastOutside, 360));
1000
+ const minus180WestOuter = dedupeConsecutiveLinearRing(outerWestInside);
1001
+ if (!distributeInteriorRingsForAntimeridianSplit(holeRings, eastHoles, westHoles, pushSplitHoleMinus180)) {
1002
+ return polygon;
1003
+ }
1004
+ return {
1005
+ type: "MultiPolygon",
1006
+ coordinates: [
1007
+ [minus180EastOuter, ...eastHoles],
1008
+ [minus180WestOuter, ...westHoles],
1009
+ ],
1010
+ };
1011
+ };
1012
+ /**
1013
+ * @description Splits a polygon at the antimeridian (±180° longitude) into a
1014
+ * MultiPolygon per RFC 7946 Section 3.1.9. If the polygon does not cross the
1015
+ * antimeridian, it is returned unchanged.
1016
+ *
1017
+ * Accepts unwrapped longitudes (outside [-180, 180]) as input — for example,
1018
+ * coordinates from a map SDK where the user panned past the antimeridian.
1019
+ * The output is always RFC 7946 compliant (all longitudes in [-180, 180]).
1020
+ *
1021
+ * For use in a future polygon draw mode: the user draws a polygon on the map
1022
+ * with coordinates that may wrap past ±180, and this function produces the
1023
+ * RFC 7946-compliant split representation.
1024
+ *
1025
+ * Delegates to {@link splitPolygonWithHolesAtAntimeridian} (holes are preserved).
1026
+ */
1027
+ const splitPolygonAtAntimeridian = (polygon) => splitPolygonWithHolesAtAntimeridian(polygon);
660
1028
  /**
661
1029
  * @description Gets the extreme point of a polygon in a given direction.
662
1030
  * @param {object} params - The parameters object
@@ -1114,6 +1482,8 @@ exports.isGeoJsonPointInPolygon = isGeoJsonPointInPolygon;
1114
1482
  exports.isGeoJsonPositionInLinearRing = isGeoJsonPositionInLinearRing;
1115
1483
  exports.isPositionInsideRing = isPositionInsideRing;
1116
1484
  exports.normalizeLongitudes = normalizeLongitudes;
1485
+ exports.splitPolygonAtAntimeridian = splitPolygonAtAntimeridian;
1486
+ exports.splitPolygonWithHolesAtAntimeridian = splitPolygonWithHolesAtAntimeridian;
1117
1487
  exports.toFeatureCollection = toFeatureCollection;
1118
1488
  exports.toPosition2d = toPosition2d;
1119
1489
  exports.tuGeoJsonPointRadiusSchema = tuGeoJsonPointRadiusSchema;
package/index.esm.js CHANGED
@@ -591,6 +591,7 @@ const getBboxFromGeoJsonPolygon = (polygon) => {
591
591
  const { minLon, maxLon } = getMinMaxLongitudes(longitudes);
592
592
  return [minLon, Math.min(...latitudes), maxLon, Math.max(...latitudes)];
593
593
  };
594
+ /** Folds any longitude to the [-180, 180] range. */
594
595
  const normalizeLongitude = (lon) => ((lon + 540) % 360) - 180;
595
596
  const WESTERN_HEMISPHERE = [
596
597
  [
@@ -610,18 +611,148 @@ const EASTERN_HEMISPHERE = [
610
611
  [0, -90],
611
612
  ],
612
613
  ];
614
+ /** Unwrapped longitudes in (180°, 360°] — western half of a shape that crossed +180°. */
615
+ const EAST_OF_ANTIMERIDIAN_CLIP = [
616
+ [
617
+ [180, -90],
618
+ [360, -90],
619
+ [360, 90],
620
+ [180, 90],
621
+ [180, -90],
622
+ ],
623
+ ];
624
+ /** Unwrapped longitudes in [-360°, -180°) — eastern half of a shape that crossed -180°. */
625
+ const WEST_OF_ANTIMERIDIAN_CLIP = [
626
+ [
627
+ [-360, -90],
628
+ [-180, -90],
629
+ [-180, 90],
630
+ [-360, 90],
631
+ [-360, -90],
632
+ ],
633
+ ];
634
+ const shiftClipPolygonLongitudes = (polygon, offset) => polygon.map(ring => ring.map(([x, y]) => [x + offset, y]));
635
+ const signedAreaOfClosedClipRing = (ring) => {
636
+ let sum = 0;
637
+ for (let i = 0; i < ring.length - 1; i++) {
638
+ const a = ring[i];
639
+ const b = ring[i + 1];
640
+ if (a === undefined || b === undefined) {
641
+ continue;
642
+ }
643
+ sum += a[0] * b[1] - b[0] * a[1];
644
+ }
645
+ return sum / 2;
646
+ };
647
+ /**
648
+ * Intersection with [180°, 360°] often traces the dateline as thousands of
649
+ * near-180° samples instead of a straight ±180° seam. That breaks
650
+ * antimeridian stitching (no consecutive ±180 vertices). Collapse the long
651
+ * near-dateline detour and keep the true western bulk arc.
652
+ */
653
+ const repairWesternDatelineOverflowExteriorRing = (ring) => {
654
+ const openLen = ring.length - 1;
655
+ if (openLen < 4) {
656
+ return ring;
657
+ }
658
+ const antimeridianTol = 1e-5;
659
+ const amIndices = [];
660
+ for (let i = 0; i < openLen; i++) {
661
+ const lng = ring[i]?.[0];
662
+ if (lng !== undefined && Math.abs(lng + 180) < antimeridianTol) {
663
+ amIndices.push(i);
664
+ }
665
+ }
666
+ if (amIndices.length < 2) {
667
+ return ring;
668
+ }
669
+ const firstAm = amIndices[0];
670
+ const lastAm = amIndices[amIndices.length - 1];
671
+ if (firstAm === undefined || lastAm === undefined) {
672
+ return ring;
673
+ }
674
+ const vFirst = ring[firstAm];
675
+ const vLast = ring[lastAm];
676
+ if (vFirst === undefined || vLast === undefined) {
677
+ return ring;
678
+ }
679
+ const southIdx = vFirst[1] <= vLast[1] ? firstAm : lastAm;
680
+ const northIdx = vFirst[1] <= vLast[1] ? lastAm : firstAm;
681
+ const mainLonThreshold = -179.5;
682
+ const mainIndices = [];
683
+ for (let i = 0; i < openLen; i++) {
684
+ if (i === southIdx || i === northIdx) {
685
+ continue;
686
+ }
687
+ const lng = ring[i]?.[0];
688
+ if (lng !== undefined && lng > mainLonThreshold) {
689
+ mainIndices.push(i);
690
+ }
691
+ }
692
+ if (mainIndices.length === 0) {
693
+ return ring;
694
+ }
695
+ const vertexCountBetweenSouthAndNorth = northIdx > southIdx ? northIdx - southIdx - 1 : openLen - southIdx + northIdx - 1;
696
+ if (vertexCountBetweenSouthAndNorth < 100) {
697
+ return ring;
698
+ }
699
+ const south = ring[southIdx];
700
+ const north = ring[northIdx];
701
+ if (south === undefined || north === undefined) {
702
+ return ring;
703
+ }
704
+ const orderedMain = mainIndices.toSorted((a, b) => a - b);
705
+ const middle = [];
706
+ for (const idx of orderedMain) {
707
+ const pt = ring[idx];
708
+ if (pt !== undefined) {
709
+ middle.push(pt);
710
+ }
711
+ }
712
+ const originalSign = Math.sign(signedAreaOfClosedClipRing(ring));
713
+ // Seam on the western piece must run north→south at lng=-180 so latitudes are the reverse of
714
+ // the eastern piece at +180; mergeAntimeridianFeatures matches east vs reversed-west runs.
715
+ let candidate = [north, south, ...middle, north];
716
+ if (originalSign !== 0 && Math.sign(signedAreaOfClosedClipRing(candidate)) !== originalSign) {
717
+ const open = candidate.slice(0, -1).toReversed();
718
+ const first = open[0];
719
+ if (first !== undefined) {
720
+ candidate = [...open, first];
721
+ }
722
+ }
723
+ return candidate;
724
+ };
725
+ const mapWestOverflowPolygon = (poly) => {
726
+ const shifted = shiftClipPolygonLongitudes(poly, -360);
727
+ const [ext, ...holes] = shifted;
728
+ if (ext === undefined) {
729
+ return shifted;
730
+ }
731
+ return [repairWesternDatelineOverflowExteriorRing(ext), ...holes];
732
+ };
613
733
  /**
614
734
  * @description Creates a round polygon from a point and a radius.
615
- * Handles antimeridian crossing per RFC 7946 Section 3.1.9 by splitting
735
+ *
736
+ * - Handles **antimeridian crossing** per RFC 7946 Section 3.1.9 by splitting
616
737
  * into a MultiPolygon. Clamps polar latitudes to [-90, 90].
617
738
  *
618
- * Returns null when the polygon crosses the antimeridian but both hemisphere
739
+ * - **Center longitude:** Map inputs may be outside [-180, 180] (e.g. 540°). The center is folded once by
740
+ * whole 360° turns into that range—same meridian on the ground; only the number used for math changes.
741
+ * - **Sampling the ring:** Each vertex is `lonCenter + offset` along the loop. Vertices use **continuous**
742
+ * longitude (no per-point normalization to [-180, 180]) so the planar ring does not self-intersect at the
743
+ * dateline; each hemisphere is clipped separately. We do not normalize every vertex to [-180, 180] by
744
+ * itself; that would turn a short step across the dateline into a long wrong chord. The western overflow
745
+ * piece is repaired so the ±180° seam is a short edge, which allows consumers such as
746
+ * `mergeAntimeridianFeatures` to merge halves for rendering.
747
+ *
748
+ * Returns **null** when the polygon crosses the antimeridian but both hemisphere
619
749
  * intersections yield zero polygons (e.g. very small polygons near the
620
750
  * dateline). In that case, returning the original coordinates would produce
621
- * invalid geometry per RFC 7946.
751
+ * invalid geometry per RFC 7946 Section 3.1.9.
622
752
  */
623
753
  const getPolygonFromPointAndRadius = (point, radius) => {
624
754
  const [lon, lat] = point.coordinates;
755
+ const lonCenter = normalizeLongitude(lon);
625
756
  const pointsCount = Math.max(32, Math.floor(radius / 100));
626
757
  const angleStep = (2 * Math.PI) / pointsCount;
627
758
  const deltaLat = (radius / EARTH_RADIUS) * (180 / Math.PI);
@@ -630,14 +761,30 @@ const getPolygonFromPointAndRadius = (point, radius) => {
630
761
  for (let i = 0; i <= pointsCount; i++) {
631
762
  const angle = i * angleStep;
632
763
  const newLat = Math.max(-90, Math.min(90, lat + deltaLat * Math.sin(angle)));
633
- const newLon = normalizeLongitude(lon + deltaLon * Math.cos(angle));
764
+ const newLon = lonCenter + deltaLon * Math.cos(angle);
634
765
  coordinates.push([newLon, newLat]);
635
766
  }
636
- const longitudes = coordinates.map(c => c[0]);
637
- if (checkCrossesMeridian(longitudes)) {
638
- const rawPolygon = [coordinates.map(c => [c[0], c[1]])];
639
- const westPart = polygonClipping.intersection(rawPolygon, WESTERN_HEMISPHERE);
767
+ const rawLngs = coordinates.map(c => c[0]);
768
+ const minRawLon = Math.min(...rawLngs);
769
+ const maxRawLon = Math.max(...rawLngs);
770
+ const rawPolygon = [coordinates.map(c => [c[0], c[1]])];
771
+ if (maxRawLon > 180) {
640
772
  const eastPart = polygonClipping.intersection(rawPolygon, EASTERN_HEMISPHERE);
773
+ const westOverflow = polygonClipping.intersection(rawPolygon, EAST_OF_ANTIMERIDIAN_CLIP);
774
+ const westPart = westOverflow.map(mapWestOverflowPolygon);
775
+ const allParts = [...westPart, ...eastPart];
776
+ if (allParts.length === 1 && allParts[0]) {
777
+ return { type: "Polygon", coordinates: allParts[0] };
778
+ }
779
+ if (allParts.length > 1) {
780
+ return { type: "MultiPolygon", coordinates: allParts };
781
+ }
782
+ return null;
783
+ }
784
+ if (minRawLon < -180) {
785
+ const westPart = polygonClipping.intersection(rawPolygon, WESTERN_HEMISPHERE);
786
+ const eastOverflow = polygonClipping.intersection(rawPolygon, WEST_OF_ANTIMERIDIAN_CLIP);
787
+ const eastPart = eastOverflow.map(poly => shiftClipPolygonLongitudes(poly, 360));
641
788
  const allParts = [...westPart, ...eastPart];
642
789
  if (allParts.length === 1 && allParts[0]) {
643
790
  return { type: "Polygon", coordinates: allParts[0] };
@@ -645,9 +792,6 @@ const getPolygonFromPointAndRadius = (point, radius) => {
645
792
  if (allParts.length > 1) {
646
793
  return { type: "MultiPolygon", coordinates: allParts };
647
794
  }
648
- // Both hemisphere intersections produced zero polygons — degenerate case
649
- // (e.g. very small polygon near antimeridian). Returning the original
650
- // coordinates would produce invalid geometry per RFC 7946 Section 3.1.9.
651
795
  return null;
652
796
  }
653
797
  return {
@@ -655,6 +799,230 @@ const getPolygonFromPointAndRadius = (point, radius) => {
655
799
  coordinates: [coordinates],
656
800
  };
657
801
  };
802
+ /**
803
+ * Interpolate the latitude where an edge crossing a longitude boundary intersects it.
804
+ */
805
+ const interpolateLatAtBoundary = (from, to, boundaryLng) => {
806
+ const t = (boundaryLng - from[0]) / (to[0] - from[0]);
807
+ const lat = from[1] + t * (to[1] - from[1]);
808
+ return [boundaryLng, lat];
809
+ };
810
+ /**
811
+ * Split a ring at a longitude boundary into an "inside" ring (lng on the keepSide
812
+ * of the boundary) and an "outside" ring (lng on the other side).
813
+ * Returns closed rings with intersection points inserted at boundary crossings.
814
+ */
815
+ const splitRingAtBoundary = (ring, boundary, insideTest) => {
816
+ const insideVertices = [];
817
+ const outsideVertices = [];
818
+ for (let i = 0; i < ring.length - 1; i++) {
819
+ const current = ring[i];
820
+ const next = ring[i + 1];
821
+ if (current === undefined || next === undefined) {
822
+ continue;
823
+ }
824
+ const currentInside = insideTest(current[0]);
825
+ const nextInside = insideTest(next[0]);
826
+ if (currentInside) {
827
+ insideVertices.push(current);
828
+ }
829
+ else {
830
+ outsideVertices.push(current);
831
+ }
832
+ if (currentInside !== nextInside) {
833
+ const intersection = interpolateLatAtBoundary(current, next, boundary);
834
+ insideVertices.push(intersection);
835
+ outsideVertices.push(intersection);
836
+ }
837
+ }
838
+ const firstInside = insideVertices[0];
839
+ if (insideVertices.length > 0 && firstInside !== undefined) {
840
+ insideVertices.push(firstInside);
841
+ }
842
+ const firstOutside = outsideVertices[0];
843
+ if (outsideVertices.length > 0 && firstOutside !== undefined) {
844
+ outsideVertices.push(firstOutside);
845
+ }
846
+ return { inside: insideVertices, outside: outsideVertices };
847
+ };
848
+ /**
849
+ * Shift all longitudes in a ring by a given offset.
850
+ */
851
+ const shiftRingLongitudes = (ring, offset) => ring.map(pos => (pos.length === 3 ? [pos[0] + offset, pos[1], pos[2]] : [pos[0] + offset, pos[1]]));
852
+ /**
853
+ * Removes consecutive duplicate positions from a closed linear ring (including
854
+ * duplicate seam vertices produced when two edges meet at a boundary crossing).
855
+ */
856
+ const dedupeConsecutiveLinearRing = (ring) => {
857
+ if (ring.length <= 2) {
858
+ return ring;
859
+ }
860
+ const seed = ring[0];
861
+ if (seed === undefined) {
862
+ return ring;
863
+ }
864
+ const out = [seed];
865
+ for (let i = 1; i < ring.length; i++) {
866
+ const prev = out[out.length - 1];
867
+ const cur = ring[i];
868
+ if (prev === undefined || cur === undefined) {
869
+ continue;
870
+ }
871
+ const same2d = prev[0] === cur[0] && prev[1] === cur[1];
872
+ const sameZ = prev.length < 3 || cur.length < 3 ? true : prev[2] === cur[2];
873
+ if (!same2d || !sameZ) {
874
+ out.push(cur);
875
+ }
876
+ }
877
+ const first = out[0];
878
+ const last = out[out.length - 1];
879
+ if (first !== undefined && last !== undefined && (first[0] !== last[0] || first[1] !== last[1])) {
880
+ out.push(first);
881
+ }
882
+ return out;
883
+ };
884
+ const ringLongitudeBounds = (ring) => {
885
+ const lngs = ring.map(p => p[0]);
886
+ return { minLng: Math.min(...lngs), maxLng: Math.max(...lngs) };
887
+ };
888
+ /**
889
+ * For a hole ring fully in [-180, 180], assigns it to the first or second
890
+ * MultiPolygon member so hole indices align with merge (east i-th hole pairs with west i-th hole):
891
+ * the i-th hole on the first shell pairs with the i-th hole on the second.
892
+ * Uses longitude midpoint: Pacific / positive → first polygon, Americas / negative → second.
893
+ */
894
+ const oneSidedHoleGoesToFirstMultiPolygonMember = (hole) => {
895
+ const { minLng, maxLng } = ringLongitudeBounds(hole);
896
+ const midLng = (minLng + maxLng) / 2;
897
+ if (midLng > 0) {
898
+ return true;
899
+ }
900
+ if (midLng < 0) {
901
+ return false;
902
+ }
903
+ const firstLng = hole[0]?.[0] ?? 0;
904
+ return firstLng >= 0;
905
+ };
906
+ /**
907
+ * Appends interior rings to east/west shell lists for an antimeridian split.
908
+ * Rings that already lie in [-180, 180] are assigned to exactly one half (see
909
+ * `oneSidedHoleGoesToFirstMultiPolygonMember`). Rings that cross the seam are
910
+ * split via `splitCrossingHole`, which returns false when either fragment has
911
+ * fewer than four positions — invalid for a GeoJSON closed linear ring — in that
912
+ * case this function returns false and the caller must discard the whole split
913
+ * and return the input polygon unchanged.
914
+ */
915
+ const distributeInteriorRingsForAntimeridianSplit = (holeRings, eastHoles, westHoles, splitCrossingHole) => {
916
+ for (const hole of holeRings) {
917
+ const hb = ringLongitudeBounds(hole);
918
+ if (hb.maxLng <= 180 && hb.minLng >= -180) {
919
+ if (oneSidedHoleGoesToFirstMultiPolygonMember(hole)) {
920
+ eastHoles.push(hole);
921
+ }
922
+ else {
923
+ westHoles.push(hole);
924
+ }
925
+ }
926
+ else if (!splitCrossingHole(hole)) {
927
+ return false;
928
+ }
929
+ }
930
+ return true;
931
+ };
932
+ /**
933
+ * @description Splits a polygon (exterior + holes) at the antimeridian (±180°)
934
+ * into a two-member MultiPolygon per RFC 7946 Section 3.1.9, preserving
935
+ * interior rings and pairing east/west hole fragments by stable original order.
936
+ * If the exterior does not cross the antimeridian, the input is returned unchanged
937
+ * (including any hole coordinates).
938
+ *
939
+ * Interior rings that lie entirely in [-180, 180] are assigned to exactly one
940
+ * shell using longitude midpoint (see `oneSidedHoleGoesToFirstMultiPolygonMember`).
941
+ * If splitting the exterior or any crossing hole would produce a fragment with
942
+ * fewer than four positions (not a valid closed ring), the original polygon is
943
+ * returned unchanged.
944
+ */
945
+ const splitPolygonWithHolesAtAntimeridian = (polygon) => {
946
+ const outerRing = polygon.coordinates[0];
947
+ if (outerRing === undefined) {
948
+ return polygon;
949
+ }
950
+ const holeRings = polygon.coordinates.slice(1);
951
+ const { maxLng, minLng } = ringLongitudeBounds(outerRing);
952
+ if (maxLng <= 180 && minLng >= -180) {
953
+ return polygon;
954
+ }
955
+ const eastHoles = [];
956
+ const westHoles = [];
957
+ const pushSplitHolePlus180 = (hole) => {
958
+ const { inside: eastRing, outside: westRing } = splitRingAtBoundary(hole, 180, lng => lng <= 180);
959
+ if (eastRing.length < 4 || westRing.length < 4) {
960
+ return false;
961
+ }
962
+ eastHoles.push(dedupeConsecutiveLinearRing(eastRing));
963
+ westHoles.push(dedupeConsecutiveLinearRing(shiftRingLongitudes(westRing, -360)));
964
+ return true;
965
+ };
966
+ const pushSplitHoleMinus180 = (hole) => {
967
+ const { inside: holeWestInside, outside: holeEastOutside } = splitRingAtBoundary(hole, -180, lng => lng >= -180);
968
+ if (holeEastOutside.length < 4 || holeWestInside.length < 4) {
969
+ return false;
970
+ }
971
+ eastHoles.push(dedupeConsecutiveLinearRing(shiftRingLongitudes(holeEastOutside, 360)));
972
+ westHoles.push(dedupeConsecutiveLinearRing(holeWestInside));
973
+ return true;
974
+ };
975
+ if (maxLng > 180) {
976
+ const { inside: eastRing, outside: westRing } = splitRingAtBoundary(outerRing, 180, lng => lng <= 180);
977
+ if (eastRing.length < 4 || westRing.length < 4) {
978
+ return polygon;
979
+ }
980
+ const eastOuter = dedupeConsecutiveLinearRing(eastRing);
981
+ const westOuter = dedupeConsecutiveLinearRing(shiftRingLongitudes(westRing, -360));
982
+ if (!distributeInteriorRingsForAntimeridianSplit(holeRings, eastHoles, westHoles, pushSplitHolePlus180)) {
983
+ return polygon;
984
+ }
985
+ return {
986
+ type: "MultiPolygon",
987
+ coordinates: [
988
+ [eastOuter, ...eastHoles],
989
+ [westOuter, ...westHoles],
990
+ ],
991
+ };
992
+ }
993
+ const { inside: outerWestInside, outside: outerEastOutside } = splitRingAtBoundary(outerRing, -180, lng => lng >= -180);
994
+ if (outerEastOutside.length < 4 || outerWestInside.length < 4) {
995
+ return polygon;
996
+ }
997
+ const minus180EastOuter = dedupeConsecutiveLinearRing(shiftRingLongitudes(outerEastOutside, 360));
998
+ const minus180WestOuter = dedupeConsecutiveLinearRing(outerWestInside);
999
+ if (!distributeInteriorRingsForAntimeridianSplit(holeRings, eastHoles, westHoles, pushSplitHoleMinus180)) {
1000
+ return polygon;
1001
+ }
1002
+ return {
1003
+ type: "MultiPolygon",
1004
+ coordinates: [
1005
+ [minus180EastOuter, ...eastHoles],
1006
+ [minus180WestOuter, ...westHoles],
1007
+ ],
1008
+ };
1009
+ };
1010
+ /**
1011
+ * @description Splits a polygon at the antimeridian (±180° longitude) into a
1012
+ * MultiPolygon per RFC 7946 Section 3.1.9. If the polygon does not cross the
1013
+ * antimeridian, it is returned unchanged.
1014
+ *
1015
+ * Accepts unwrapped longitudes (outside [-180, 180]) as input — for example,
1016
+ * coordinates from a map SDK where the user panned past the antimeridian.
1017
+ * The output is always RFC 7946 compliant (all longitudes in [-180, 180]).
1018
+ *
1019
+ * For use in a future polygon draw mode: the user draws a polygon on the map
1020
+ * with coordinates that may wrap past ±180, and this function produces the
1021
+ * RFC 7946-compliant split representation.
1022
+ *
1023
+ * Delegates to {@link splitPolygonWithHolesAtAntimeridian} (holes are preserved).
1024
+ */
1025
+ const splitPolygonAtAntimeridian = (polygon) => splitPolygonWithHolesAtAntimeridian(polygon);
658
1026
  /**
659
1027
  * @description Gets the extreme point of a polygon in a given direction.
660
1028
  * @param {object} params - The parameters object
@@ -1071,4 +1439,4 @@ const tuGeoJsonRectangularBoxPolygonSchema = z
1071
1439
  }
1072
1440
  });
1073
1441
 
1074
- export { EARTH_RADIUS, boundingBoxCrossesMeridian, checkCrossesMeridian, computeGeometryCentroid, coordinatesToStandardFormat, denormalizeLongitude, extractEdges, extractFirstPointCoordinate, extractPositionsFromGeometry, geoJsonBboxSchema, geoJsonFeatureCollectionSchema, geoJsonFeatureSchema, geoJsonGeometryCollectionSchema, geoJsonGeometrySchema, geoJsonLineStringSchema, geoJsonLinearRingSchema, geoJsonMultiLineStringSchema, geoJsonMultiPointSchema, geoJsonMultiPolygonSchema, geoJsonPointSchema, geoJsonPolygonSchema, geoJsonPosition2dSchema, geoJsonPositionSchema, getBboxFromGeoJsonPolygon, getBoundingBoxFromGeoJsonBbox, getBoundingBoxFromGeoJsonPolygon, getExtremeGeoJsonPointFromPolygon, getGeoJsonPolygonFromBoundingBox, getGeoJsonPolygonIntersection, getMinMaxLongitudes, getMultipleCoordinatesFromGeoJsonObject, getPointCoordinateFromGeoJsonObject, getPointCoordinateFromGeoJsonPoint, getPolygonFromBbox, getPolygonFromPointAndRadius, isBboxInsideFeatureCollection, isFullyContainedInGeoJsonPolygon, isGeoJsonPointInPolygon, isGeoJsonPositionInLinearRing, isPositionInsideRing, normalizeLongitudes, toFeatureCollection, toPosition2d, tuGeoJsonPointRadiusSchema, tuGeoJsonPolygonNoHolesSchema, tuGeoJsonRectangularBoxPolygonSchema };
1442
+ export { EARTH_RADIUS, boundingBoxCrossesMeridian, checkCrossesMeridian, computeGeometryCentroid, coordinatesToStandardFormat, denormalizeLongitude, extractEdges, extractFirstPointCoordinate, extractPositionsFromGeometry, geoJsonBboxSchema, geoJsonFeatureCollectionSchema, geoJsonFeatureSchema, geoJsonGeometryCollectionSchema, geoJsonGeometrySchema, geoJsonLineStringSchema, geoJsonLinearRingSchema, geoJsonMultiLineStringSchema, geoJsonMultiPointSchema, geoJsonMultiPolygonSchema, geoJsonPointSchema, geoJsonPolygonSchema, geoJsonPosition2dSchema, geoJsonPositionSchema, getBboxFromGeoJsonPolygon, getBoundingBoxFromGeoJsonBbox, getBoundingBoxFromGeoJsonPolygon, getExtremeGeoJsonPointFromPolygon, getGeoJsonPolygonFromBoundingBox, getGeoJsonPolygonIntersection, getMinMaxLongitudes, getMultipleCoordinatesFromGeoJsonObject, getPointCoordinateFromGeoJsonObject, getPointCoordinateFromGeoJsonPoint, getPolygonFromBbox, getPolygonFromPointAndRadius, isBboxInsideFeatureCollection, isFullyContainedInGeoJsonPolygon, isGeoJsonPointInPolygon, isGeoJsonPositionInLinearRing, isPositionInsideRing, normalizeLongitudes, splitPolygonAtAntimeridian, splitPolygonWithHolesAtAntimeridian, toFeatureCollection, toPosition2d, tuGeoJsonPointRadiusSchema, tuGeoJsonPolygonNoHolesSchema, tuGeoJsonRectangularBoxPolygonSchema };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trackunit/geo-json-utils",
3
- "version": "1.11.91",
3
+ "version": "1.12.2",
4
4
  "repository": "https://github.com/Trackunit/manager",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "engines": {
@@ -13,15 +13,55 @@ export declare const getPolygonFromBbox: (bbox: GeoJsonBbox) => GeoJsonPolygon |
13
13
  export declare const getBboxFromGeoJsonPolygon: (polygon: GeoJsonPolygon | GeoJsonMultiPolygon) => GeoJsonBbox | null;
14
14
  /**
15
15
  * @description Creates a round polygon from a point and a radius.
16
- * Handles antimeridian crossing per RFC 7946 Section 3.1.9 by splitting
16
+ *
17
+ * - Handles **antimeridian crossing** per RFC 7946 Section 3.1.9 by splitting
17
18
  * into a MultiPolygon. Clamps polar latitudes to [-90, 90].
18
19
  *
19
- * Returns null when the polygon crosses the antimeridian but both hemisphere
20
+ * - **Center longitude:** Map inputs may be outside [-180, 180] (e.g. 540°). The center is folded once by
21
+ * whole 360° turns into that range—same meridian on the ground; only the number used for math changes.
22
+ * - **Sampling the ring:** Each vertex is `lonCenter + offset` along the loop. Vertices use **continuous**
23
+ * longitude (no per-point normalization to [-180, 180]) so the planar ring does not self-intersect at the
24
+ * dateline; each hemisphere is clipped separately. We do not normalize every vertex to [-180, 180] by
25
+ * itself; that would turn a short step across the dateline into a long wrong chord. The western overflow
26
+ * piece is repaired so the ±180° seam is a short edge, which allows consumers such as
27
+ * `mergeAntimeridianFeatures` to merge halves for rendering.
28
+ *
29
+ * Returns **null** when the polygon crosses the antimeridian but both hemisphere
20
30
  * intersections yield zero polygons (e.g. very small polygons near the
21
31
  * dateline). In that case, returning the original coordinates would produce
22
- * invalid geometry per RFC 7946.
32
+ * invalid geometry per RFC 7946 Section 3.1.9.
23
33
  */
24
34
  export declare const getPolygonFromPointAndRadius: (point: GeoJsonPoint, radius: number) => GeoJsonPolygon | GeoJsonMultiPolygon | null;
35
+ /**
36
+ * @description Splits a polygon (exterior + holes) at the antimeridian (±180°)
37
+ * into a two-member MultiPolygon per RFC 7946 Section 3.1.9, preserving
38
+ * interior rings and pairing east/west hole fragments by stable original order.
39
+ * If the exterior does not cross the antimeridian, the input is returned unchanged
40
+ * (including any hole coordinates).
41
+ *
42
+ * Interior rings that lie entirely in [-180, 180] are assigned to exactly one
43
+ * shell using longitude midpoint (see `oneSidedHoleGoesToFirstMultiPolygonMember`).
44
+ * If splitting the exterior or any crossing hole would produce a fragment with
45
+ * fewer than four positions (not a valid closed ring), the original polygon is
46
+ * returned unchanged.
47
+ */
48
+ export declare const splitPolygonWithHolesAtAntimeridian: (polygon: GeoJsonPolygon) => GeoJsonPolygon | GeoJsonMultiPolygon;
49
+ /**
50
+ * @description Splits a polygon at the antimeridian (±180° longitude) into a
51
+ * MultiPolygon per RFC 7946 Section 3.1.9. If the polygon does not cross the
52
+ * antimeridian, it is returned unchanged.
53
+ *
54
+ * Accepts unwrapped longitudes (outside [-180, 180]) as input — for example,
55
+ * coordinates from a map SDK where the user panned past the antimeridian.
56
+ * The output is always RFC 7946 compliant (all longitudes in [-180, 180]).
57
+ *
58
+ * For use in a future polygon draw mode: the user draws a polygon on the map
59
+ * with coordinates that may wrap past ±180, and this function produces the
60
+ * RFC 7946-compliant split representation.
61
+ *
62
+ * Delegates to {@link splitPolygonWithHolesAtAntimeridian} (holes are preserved).
63
+ */
64
+ export declare const splitPolygonAtAntimeridian: (polygon: GeoJsonPolygon) => GeoJsonPolygon | GeoJsonMultiPolygon;
25
65
  /**
26
66
  * @description Gets the extreme point of a polygon in a given direction.
27
67
  * @param {object} params - The parameters object