@trackunit/geo-json-utils 1.11.90 → 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 +379 -11
- package/index.esm.js +378 -12
- package/package.json +1 -1
- package/src/GeoJsonUtils.d.ts +41 -3
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
|
-
*
|
|
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
|
-
*
|
|
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 =
|
|
764
|
+
const newLon = lonCenter + deltaLon * Math.cos(angle);
|
|
636
765
|
coordinates.push([newLon, newLat]);
|
|
637
766
|
}
|
|
638
|
-
const
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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 =
|
|
762
|
+
const newLon = lonCenter + deltaLon * Math.cos(angle);
|
|
634
763
|
coordinates.push([newLon, newLat]);
|
|
635
764
|
}
|
|
636
|
-
const
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
package/src/GeoJsonUtils.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
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
|