@trackunit/geo-json-utils 1.11.91 → 1.12.1

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,146 @@ 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. We do not normalize every
744
+ * vertex to [-180, 180] by itself; that would turn a short step across the dateline into a long wrong chord.
745
+ * Instead, the ring can use longitudes past ±180° until clipping splits it into valid pieces. The western
746
+ * piece is adjusted so the ±180° edge is short, which `mergeAntimeridianFeatures` expects when merging halves.
747
+ *
748
+ * - Returns **null** when the polygon crosses the antimeridian but both hemisphere
621
749
  * intersections yield zero polygons (e.g. very small polygons near the
622
750
  * dateline). In that case, returning the original coordinates would produce
623
- * invalid geometry per RFC 7946.
751
+ * invalid geometry per RFC 7946 Section 3.1.9.
624
752
  */
625
753
  const getPolygonFromPointAndRadius = (point, radius) => {
626
754
  const [lon, lat] = point.coordinates;
755
+ const lonCenter = normalizeLongitude(lon);
627
756
  const pointsCount = Math.max(32, Math.floor(radius / 100));
628
757
  const angleStep = (2 * Math.PI) / pointsCount;
629
758
  const deltaLat = (radius / EARTH_RADIUS) * (180 / Math.PI);
@@ -632,14 +761,30 @@ const getPolygonFromPointAndRadius = (point, radius) => {
632
761
  for (let i = 0; i <= pointsCount; i++) {
633
762
  const angle = i * angleStep;
634
763
  const newLat = Math.max(-90, Math.min(90, lat + deltaLat * Math.sin(angle)));
635
- const newLon = normalizeLongitude(lon + deltaLon * Math.cos(angle));
764
+ const newLon = lonCenter + deltaLon * Math.cos(angle);
636
765
  coordinates.push([newLon, newLat]);
637
766
  }
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);
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) {
642
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));
643
788
  const allParts = [...westPart, ...eastPart];
644
789
  if (allParts.length === 1 && allParts[0]) {
645
790
  return { type: "Polygon", coordinates: allParts[0] };
@@ -647,9 +792,6 @@ const getPolygonFromPointAndRadius = (point, radius) => {
647
792
  if (allParts.length > 1) {
648
793
  return { type: "MultiPolygon", coordinates: allParts };
649
794
  }
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
795
  return null;
654
796
  }
655
797
  return {
@@ -657,6 +799,230 @@ const getPolygonFromPointAndRadius = (point, radius) => {
657
799
  coordinates: [coordinates],
658
800
  };
659
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);
660
1026
  /**
661
1027
  * @description Gets the extreme point of a polygon in a given direction.
662
1028
  * @param {object} params - The parameters object
@@ -1114,6 +1480,8 @@ exports.isGeoJsonPointInPolygon = isGeoJsonPointInPolygon;
1114
1480
  exports.isGeoJsonPositionInLinearRing = isGeoJsonPositionInLinearRing;
1115
1481
  exports.isPositionInsideRing = isPositionInsideRing;
1116
1482
  exports.normalizeLongitudes = normalizeLongitudes;
1483
+ exports.splitPolygonAtAntimeridian = splitPolygonAtAntimeridian;
1484
+ exports.splitPolygonWithHolesAtAntimeridian = splitPolygonWithHolesAtAntimeridian;
1117
1485
  exports.toFeatureCollection = toFeatureCollection;
1118
1486
  exports.toPosition2d = toPosition2d;
1119
1487
  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,146 @@ 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. We do not normalize every
742
+ * vertex to [-180, 180] by itself; that would turn a short step across the dateline into a long wrong chord.
743
+ * Instead, the ring can use longitudes past ±180° until clipping splits it into valid pieces. The western
744
+ * piece is adjusted so the ±180° edge is short, which `mergeAntimeridianFeatures` expects when merging halves.
745
+ *
746
+ * - Returns **null** when the polygon crosses the antimeridian but both hemisphere
619
747
  * intersections yield zero polygons (e.g. very small polygons near the
620
748
  * dateline). In that case, returning the original coordinates would produce
621
- * invalid geometry per RFC 7946.
749
+ * invalid geometry per RFC 7946 Section 3.1.9.
622
750
  */
623
751
  const getPolygonFromPointAndRadius = (point, radius) => {
624
752
  const [lon, lat] = point.coordinates;
753
+ const lonCenter = normalizeLongitude(lon);
625
754
  const pointsCount = Math.max(32, Math.floor(radius / 100));
626
755
  const angleStep = (2 * Math.PI) / pointsCount;
627
756
  const deltaLat = (radius / EARTH_RADIUS) * (180 / Math.PI);
@@ -630,14 +759,30 @@ const getPolygonFromPointAndRadius = (point, radius) => {
630
759
  for (let i = 0; i <= pointsCount; i++) {
631
760
  const angle = i * angleStep;
632
761
  const newLat = Math.max(-90, Math.min(90, lat + deltaLat * Math.sin(angle)));
633
- const newLon = normalizeLongitude(lon + deltaLon * Math.cos(angle));
762
+ const newLon = lonCenter + deltaLon * Math.cos(angle);
634
763
  coordinates.push([newLon, newLat]);
635
764
  }
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);
765
+ const rawLngs = coordinates.map(c => c[0]);
766
+ const minRawLon = Math.min(...rawLngs);
767
+ const maxRawLon = Math.max(...rawLngs);
768
+ const rawPolygon = [coordinates.map(c => [c[0], c[1]])];
769
+ if (maxRawLon > 180) {
640
770
  const eastPart = polygonClipping.intersection(rawPolygon, EASTERN_HEMISPHERE);
771
+ const westOverflow = polygonClipping.intersection(rawPolygon, EAST_OF_ANTIMERIDIAN_CLIP);
772
+ const westPart = westOverflow.map(mapWestOverflowPolygon);
773
+ const allParts = [...westPart, ...eastPart];
774
+ if (allParts.length === 1 && allParts[0]) {
775
+ return { type: "Polygon", coordinates: allParts[0] };
776
+ }
777
+ if (allParts.length > 1) {
778
+ return { type: "MultiPolygon", coordinates: allParts };
779
+ }
780
+ return null;
781
+ }
782
+ if (minRawLon < -180) {
783
+ const westPart = polygonClipping.intersection(rawPolygon, WESTERN_HEMISPHERE);
784
+ const eastOverflow = polygonClipping.intersection(rawPolygon, WEST_OF_ANTIMERIDIAN_CLIP);
785
+ const eastPart = eastOverflow.map(poly => shiftClipPolygonLongitudes(poly, 360));
641
786
  const allParts = [...westPart, ...eastPart];
642
787
  if (allParts.length === 1 && allParts[0]) {
643
788
  return { type: "Polygon", coordinates: allParts[0] };
@@ -645,9 +790,6 @@ const getPolygonFromPointAndRadius = (point, radius) => {
645
790
  if (allParts.length > 1) {
646
791
  return { type: "MultiPolygon", coordinates: allParts };
647
792
  }
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
793
  return null;
652
794
  }
653
795
  return {
@@ -655,6 +797,230 @@ const getPolygonFromPointAndRadius = (point, radius) => {
655
797
  coordinates: [coordinates],
656
798
  };
657
799
  };
800
+ /**
801
+ * Interpolate the latitude where an edge crossing a longitude boundary intersects it.
802
+ */
803
+ const interpolateLatAtBoundary = (from, to, boundaryLng) => {
804
+ const t = (boundaryLng - from[0]) / (to[0] - from[0]);
805
+ const lat = from[1] + t * (to[1] - from[1]);
806
+ return [boundaryLng, lat];
807
+ };
808
+ /**
809
+ * Split a ring at a longitude boundary into an "inside" ring (lng on the keepSide
810
+ * of the boundary) and an "outside" ring (lng on the other side).
811
+ * Returns closed rings with intersection points inserted at boundary crossings.
812
+ */
813
+ const splitRingAtBoundary = (ring, boundary, insideTest) => {
814
+ const insideVertices = [];
815
+ const outsideVertices = [];
816
+ for (let i = 0; i < ring.length - 1; i++) {
817
+ const current = ring[i];
818
+ const next = ring[i + 1];
819
+ if (current === undefined || next === undefined) {
820
+ continue;
821
+ }
822
+ const currentInside = insideTest(current[0]);
823
+ const nextInside = insideTest(next[0]);
824
+ if (currentInside) {
825
+ insideVertices.push(current);
826
+ }
827
+ else {
828
+ outsideVertices.push(current);
829
+ }
830
+ if (currentInside !== nextInside) {
831
+ const intersection = interpolateLatAtBoundary(current, next, boundary);
832
+ insideVertices.push(intersection);
833
+ outsideVertices.push(intersection);
834
+ }
835
+ }
836
+ const firstInside = insideVertices[0];
837
+ if (insideVertices.length > 0 && firstInside !== undefined) {
838
+ insideVertices.push(firstInside);
839
+ }
840
+ const firstOutside = outsideVertices[0];
841
+ if (outsideVertices.length > 0 && firstOutside !== undefined) {
842
+ outsideVertices.push(firstOutside);
843
+ }
844
+ return { inside: insideVertices, outside: outsideVertices };
845
+ };
846
+ /**
847
+ * Shift all longitudes in a ring by a given offset.
848
+ */
849
+ const shiftRingLongitudes = (ring, offset) => ring.map(pos => (pos.length === 3 ? [pos[0] + offset, pos[1], pos[2]] : [pos[0] + offset, pos[1]]));
850
+ /**
851
+ * Removes consecutive duplicate positions from a closed linear ring (including
852
+ * duplicate seam vertices produced when two edges meet at a boundary crossing).
853
+ */
854
+ const dedupeConsecutiveLinearRing = (ring) => {
855
+ if (ring.length <= 2) {
856
+ return ring;
857
+ }
858
+ const seed = ring[0];
859
+ if (seed === undefined) {
860
+ return ring;
861
+ }
862
+ const out = [seed];
863
+ for (let i = 1; i < ring.length; i++) {
864
+ const prev = out[out.length - 1];
865
+ const cur = ring[i];
866
+ if (prev === undefined || cur === undefined) {
867
+ continue;
868
+ }
869
+ const same2d = prev[0] === cur[0] && prev[1] === cur[1];
870
+ const sameZ = prev.length < 3 || cur.length < 3 ? true : prev[2] === cur[2];
871
+ if (!same2d || !sameZ) {
872
+ out.push(cur);
873
+ }
874
+ }
875
+ const first = out[0];
876
+ const last = out[out.length - 1];
877
+ if (first !== undefined && last !== undefined && (first[0] !== last[0] || first[1] !== last[1])) {
878
+ out.push(first);
879
+ }
880
+ return out;
881
+ };
882
+ const ringLongitudeBounds = (ring) => {
883
+ const lngs = ring.map(p => p[0]);
884
+ return { minLng: Math.min(...lngs), maxLng: Math.max(...lngs) };
885
+ };
886
+ /**
887
+ * For a hole ring fully in [-180, 180], assigns it to the first or second
888
+ * MultiPolygon member so hole indices align with merge (east i-th hole pairs with west i-th hole):
889
+ * the i-th hole on the first shell pairs with the i-th hole on the second.
890
+ * Uses longitude midpoint: Pacific / positive → first polygon, Americas / negative → second.
891
+ */
892
+ const oneSidedHoleGoesToFirstMultiPolygonMember = (hole) => {
893
+ const { minLng, maxLng } = ringLongitudeBounds(hole);
894
+ const midLng = (minLng + maxLng) / 2;
895
+ if (midLng > 0) {
896
+ return true;
897
+ }
898
+ if (midLng < 0) {
899
+ return false;
900
+ }
901
+ const firstLng = hole[0]?.[0] ?? 0;
902
+ return firstLng >= 0;
903
+ };
904
+ /**
905
+ * Appends interior rings to east/west shell lists for an antimeridian split.
906
+ * Rings that already lie in [-180, 180] are assigned to exactly one half (see
907
+ * `oneSidedHoleGoesToFirstMultiPolygonMember`). Rings that cross the seam are
908
+ * split via `splitCrossingHole`, which returns false when either fragment has
909
+ * fewer than four positions — invalid for a GeoJSON closed linear ring — in that
910
+ * case this function returns false and the caller must discard the whole split
911
+ * and return the input polygon unchanged.
912
+ */
913
+ const distributeInteriorRingsForAntimeridianSplit = (holeRings, eastHoles, westHoles, splitCrossingHole) => {
914
+ for (const hole of holeRings) {
915
+ const hb = ringLongitudeBounds(hole);
916
+ if (hb.maxLng <= 180 && hb.minLng >= -180) {
917
+ if (oneSidedHoleGoesToFirstMultiPolygonMember(hole)) {
918
+ eastHoles.push(hole);
919
+ }
920
+ else {
921
+ westHoles.push(hole);
922
+ }
923
+ }
924
+ else if (!splitCrossingHole(hole)) {
925
+ return false;
926
+ }
927
+ }
928
+ return true;
929
+ };
930
+ /**
931
+ * @description Splits a polygon (exterior + holes) at the antimeridian (±180°)
932
+ * into a two-member MultiPolygon per RFC 7946 Section 3.1.9, preserving
933
+ * interior rings and pairing east/west hole fragments by stable original order.
934
+ * If the exterior does not cross the antimeridian, the input is returned unchanged
935
+ * (including any hole coordinates).
936
+ *
937
+ * Interior rings that lie entirely in [-180, 180] are assigned to exactly one
938
+ * shell using longitude midpoint (see `oneSidedHoleGoesToFirstMultiPolygonMember`).
939
+ * If splitting the exterior or any crossing hole would produce a fragment with
940
+ * fewer than four positions (not a valid closed ring), the original polygon is
941
+ * returned unchanged.
942
+ */
943
+ const splitPolygonWithHolesAtAntimeridian = (polygon) => {
944
+ const outerRing = polygon.coordinates[0];
945
+ if (outerRing === undefined) {
946
+ return polygon;
947
+ }
948
+ const holeRings = polygon.coordinates.slice(1);
949
+ const { maxLng, minLng } = ringLongitudeBounds(outerRing);
950
+ if (maxLng <= 180 && minLng >= -180) {
951
+ return polygon;
952
+ }
953
+ const eastHoles = [];
954
+ const westHoles = [];
955
+ const pushSplitHolePlus180 = (hole) => {
956
+ const { inside: eastRing, outside: westRing } = splitRingAtBoundary(hole, 180, lng => lng <= 180);
957
+ if (eastRing.length < 4 || westRing.length < 4) {
958
+ return false;
959
+ }
960
+ eastHoles.push(dedupeConsecutiveLinearRing(eastRing));
961
+ westHoles.push(dedupeConsecutiveLinearRing(shiftRingLongitudes(westRing, -360)));
962
+ return true;
963
+ };
964
+ const pushSplitHoleMinus180 = (hole) => {
965
+ const { inside: holeWestInside, outside: holeEastOutside } = splitRingAtBoundary(hole, -180, lng => lng >= -180);
966
+ if (holeEastOutside.length < 4 || holeWestInside.length < 4) {
967
+ return false;
968
+ }
969
+ eastHoles.push(dedupeConsecutiveLinearRing(shiftRingLongitudes(holeEastOutside, 360)));
970
+ westHoles.push(dedupeConsecutiveLinearRing(holeWestInside));
971
+ return true;
972
+ };
973
+ if (maxLng > 180) {
974
+ const { inside: eastRing, outside: westRing } = splitRingAtBoundary(outerRing, 180, lng => lng <= 180);
975
+ if (eastRing.length < 4 || westRing.length < 4) {
976
+ return polygon;
977
+ }
978
+ const eastOuter = dedupeConsecutiveLinearRing(eastRing);
979
+ const westOuter = dedupeConsecutiveLinearRing(shiftRingLongitudes(westRing, -360));
980
+ if (!distributeInteriorRingsForAntimeridianSplit(holeRings, eastHoles, westHoles, pushSplitHolePlus180)) {
981
+ return polygon;
982
+ }
983
+ return {
984
+ type: "MultiPolygon",
985
+ coordinates: [
986
+ [eastOuter, ...eastHoles],
987
+ [westOuter, ...westHoles],
988
+ ],
989
+ };
990
+ }
991
+ const { inside: outerWestInside, outside: outerEastOutside } = splitRingAtBoundary(outerRing, -180, lng => lng >= -180);
992
+ if (outerEastOutside.length < 4 || outerWestInside.length < 4) {
993
+ return polygon;
994
+ }
995
+ const minus180EastOuter = dedupeConsecutiveLinearRing(shiftRingLongitudes(outerEastOutside, 360));
996
+ const minus180WestOuter = dedupeConsecutiveLinearRing(outerWestInside);
997
+ if (!distributeInteriorRingsForAntimeridianSplit(holeRings, eastHoles, westHoles, pushSplitHoleMinus180)) {
998
+ return polygon;
999
+ }
1000
+ return {
1001
+ type: "MultiPolygon",
1002
+ coordinates: [
1003
+ [minus180EastOuter, ...eastHoles],
1004
+ [minus180WestOuter, ...westHoles],
1005
+ ],
1006
+ };
1007
+ };
1008
+ /**
1009
+ * @description Splits a polygon at the antimeridian (±180° longitude) into a
1010
+ * MultiPolygon per RFC 7946 Section 3.1.9. If the polygon does not cross the
1011
+ * antimeridian, it is returned unchanged.
1012
+ *
1013
+ * Accepts unwrapped longitudes (outside [-180, 180]) as input — for example,
1014
+ * coordinates from a map SDK where the user panned past the antimeridian.
1015
+ * The output is always RFC 7946 compliant (all longitudes in [-180, 180]).
1016
+ *
1017
+ * For use in a future polygon draw mode: the user draws a polygon on the map
1018
+ * with coordinates that may wrap past ±180, and this function produces the
1019
+ * RFC 7946-compliant split representation.
1020
+ *
1021
+ * Delegates to {@link splitPolygonWithHolesAtAntimeridian} (holes are preserved).
1022
+ */
1023
+ const splitPolygonAtAntimeridian = (polygon) => splitPolygonWithHolesAtAntimeridian(polygon);
658
1024
  /**
659
1025
  * @description Gets the extreme point of a polygon in a given direction.
660
1026
  * @param {object} params - The parameters object
@@ -1071,4 +1437,4 @@ const tuGeoJsonRectangularBoxPolygonSchema = z
1071
1437
  }
1072
1438
  });
1073
1439
 
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 };
1440
+ 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.1",
4
4
  "repository": "https://github.com/Trackunit/manager",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "engines": {
@@ -13,15 +13,53 @@ 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. We do not normalize every
23
+ * vertex to [-180, 180] by itself; that would turn a short step across the dateline into a long wrong chord.
24
+ * Instead, the ring can use longitudes past ±180° until clipping splits it into valid pieces. The western
25
+ * piece is adjusted so the ±180° edge is short, which `mergeAntimeridianFeatures` expects when merging halves.
26
+ *
27
+ * - Returns **null** when the polygon crosses the antimeridian but both hemisphere
20
28
  * intersections yield zero polygons (e.g. very small polygons near the
21
29
  * dateline). In that case, returning the original coordinates would produce
22
- * invalid geometry per RFC 7946.
30
+ * invalid geometry per RFC 7946 Section 3.1.9.
23
31
  */
24
32
  export declare const getPolygonFromPointAndRadius: (point: GeoJsonPoint, radius: number) => GeoJsonPolygon | GeoJsonMultiPolygon | null;
33
+ /**
34
+ * @description Splits a polygon (exterior + holes) at the antimeridian (±180°)
35
+ * into a two-member MultiPolygon per RFC 7946 Section 3.1.9, preserving
36
+ * interior rings and pairing east/west hole fragments by stable original order.
37
+ * If the exterior does not cross the antimeridian, the input is returned unchanged
38
+ * (including any hole coordinates).
39
+ *
40
+ * Interior rings that lie entirely in [-180, 180] are assigned to exactly one
41
+ * shell using longitude midpoint (see `oneSidedHoleGoesToFirstMultiPolygonMember`).
42
+ * If splitting the exterior or any crossing hole would produce a fragment with
43
+ * fewer than four positions (not a valid closed ring), the original polygon is
44
+ * returned unchanged.
45
+ */
46
+ export declare const splitPolygonWithHolesAtAntimeridian: (polygon: GeoJsonPolygon) => GeoJsonPolygon | GeoJsonMultiPolygon;
47
+ /**
48
+ * @description Splits a polygon at the antimeridian (±180° longitude) into a
49
+ * MultiPolygon per RFC 7946 Section 3.1.9. If the polygon does not cross the
50
+ * antimeridian, it is returned unchanged.
51
+ *
52
+ * Accepts unwrapped longitudes (outside [-180, 180]) as input — for example,
53
+ * coordinates from a map SDK where the user panned past the antimeridian.
54
+ * The output is always RFC 7946 compliant (all longitudes in [-180, 180]).
55
+ *
56
+ * For use in a future polygon draw mode: the user draws a polygon on the map
57
+ * with coordinates that may wrap past ±180, and this function produces the
58
+ * RFC 7946-compliant split representation.
59
+ *
60
+ * Delegates to {@link splitPolygonWithHolesAtAntimeridian} (holes are preserved).
61
+ */
62
+ export declare const splitPolygonAtAntimeridian: (polygon: GeoJsonPolygon) => GeoJsonPolygon | GeoJsonMultiPolygon;
25
63
  /**
26
64
  * @description Gets the extreme point of a polygon in a given direction.
27
65
  * @param {object} params - The parameters object