@trackunit/geo-json-utils 0.0.4 → 1.0.0
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 +166 -37
- package/index.esm.js +162 -38
- package/package.json +1 -1
- package/src/GeoJsonUtils.d.ts +22 -11
- package/src/index.d.ts +1 -0
- package/src/meridianUtils.d.ts +27 -0
package/index.cjs.js
CHANGED
|
@@ -117,6 +117,59 @@ const geoJsonBboxSchema = zod.z
|
|
|
117
117
|
message: "Invalid bounding box: maxLng should be greater than minLng, and maxLat should be greater than minLat.",
|
|
118
118
|
});
|
|
119
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Utilities for handling coordinates and bounding boxes that cross the 180/-180 meridian.
|
|
122
|
+
*/
|
|
123
|
+
/**
|
|
124
|
+
* Checks if a sequence of longitudes crosses the meridian by looking for large longitude differences.
|
|
125
|
+
*/
|
|
126
|
+
const checkCrossesMeridian = (longitudes) => {
|
|
127
|
+
return longitudes.some((lon, i) => {
|
|
128
|
+
const nextLon = longitudes[i + 1];
|
|
129
|
+
if (nextLon === undefined) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
return Math.abs(lon - nextLon) > 180;
|
|
133
|
+
});
|
|
134
|
+
};
|
|
135
|
+
/**
|
|
136
|
+
* Normalizes longitudes to the 0-360 range.
|
|
137
|
+
*/
|
|
138
|
+
const normalizeLongitudes = (longitudes) => {
|
|
139
|
+
return longitudes.map(lon => (lon < 0 ? lon + 360 : lon));
|
|
140
|
+
};
|
|
141
|
+
/**
|
|
142
|
+
* Converts a longitude from the 0-360 range back to the -180/180 range.
|
|
143
|
+
*/
|
|
144
|
+
const denormalizeLongitude = (lon) => {
|
|
145
|
+
return lon > 180 ? lon - 360 : lon;
|
|
146
|
+
};
|
|
147
|
+
/**
|
|
148
|
+
* Checks if a bounding box crosses the meridian.
|
|
149
|
+
*/
|
|
150
|
+
const boundingBoxCrossesMeridian = (nwLon, seLon) => {
|
|
151
|
+
return Math.abs(nwLon - seLon) > 180;
|
|
152
|
+
};
|
|
153
|
+
/**
|
|
154
|
+
* Gets min/max longitudes handling meridian crossing.
|
|
155
|
+
* Returns longitudes in -180/180 range.
|
|
156
|
+
*/
|
|
157
|
+
const getMinMaxLongitudes = (longitudes) => {
|
|
158
|
+
if (checkCrossesMeridian(longitudes)) {
|
|
159
|
+
const normalizedLongitudes = normalizeLongitudes(longitudes);
|
|
160
|
+
const minLon = Math.min(...normalizedLongitudes);
|
|
161
|
+
const maxLon = Math.max(...normalizedLongitudes);
|
|
162
|
+
return {
|
|
163
|
+
minLon: denormalizeLongitude(minLon),
|
|
164
|
+
maxLon: denormalizeLongitude(maxLon),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
minLon: Math.min(...longitudes),
|
|
169
|
+
maxLon: Math.max(...longitudes),
|
|
170
|
+
};
|
|
171
|
+
};
|
|
172
|
+
|
|
120
173
|
const EARTH_RADIUS = 6378137; // Earth’s mean radius in meters
|
|
121
174
|
/**
|
|
122
175
|
* @description Creates a polygon (with no holes) from a bounding box.
|
|
@@ -137,21 +190,40 @@ const getPolygonFromBbox = (bbox) => {
|
|
|
137
190
|
};
|
|
138
191
|
};
|
|
139
192
|
/**
|
|
140
|
-
* @description Creates a bounding box from a GeoJSON Polygon.
|
|
193
|
+
* @description Creates a bounding box from a GeoJSON Polygon or MultiPolygon.
|
|
194
|
+
* Handles cases where the polygon crosses the 180/-180 meridian.
|
|
141
195
|
*/
|
|
142
196
|
const getBboxFromGeoJsonPolygon = (polygon) => {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
197
|
+
// For MultiPolygon, we need to check all polygons to find the overall bounding box
|
|
198
|
+
const allPoints = [];
|
|
199
|
+
if (polygon.type === "MultiPolygon") {
|
|
200
|
+
polygon.coordinates.forEach(polygonCoords => {
|
|
201
|
+
const outerRing = polygonCoords[0];
|
|
202
|
+
if (outerRing) {
|
|
203
|
+
allPoints.push(...outerRing);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
146
206
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
207
|
+
else {
|
|
208
|
+
const polygonParsed = geoJsonPolygonSchema.safeParse(polygon);
|
|
209
|
+
if (!polygonParsed.success) {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
const points = polygonParsed.data.coordinates[0];
|
|
213
|
+
if (!points) {
|
|
214
|
+
// Should never happen since the schema checks for it
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
allPoints.push(...points);
|
|
218
|
+
}
|
|
219
|
+
if (allPoints.length === 0) {
|
|
150
220
|
return null;
|
|
151
221
|
}
|
|
152
|
-
|
|
153
|
-
const longitudes =
|
|
154
|
-
|
|
222
|
+
// Get all longitudes and check if we cross the meridian
|
|
223
|
+
const longitudes = allPoints.map(point => point[0]);
|
|
224
|
+
const latitudes = allPoints.map(point => point[1]);
|
|
225
|
+
const { minLon, maxLon } = getMinMaxLongitudes(longitudes);
|
|
226
|
+
return [minLon, Math.min(...latitudes), maxLon, Math.max(...latitudes)];
|
|
155
227
|
};
|
|
156
228
|
/**
|
|
157
229
|
* @description Creates a round polygon from a point and a radius.
|
|
@@ -179,35 +251,91 @@ const getPolygonFromPointAndRadius = (point, radius) => {
|
|
|
179
251
|
};
|
|
180
252
|
/**
|
|
181
253
|
* @description Creates a TU bounding box from a GeoJson Polygon.
|
|
254
|
+
* Handles cases where the polygon crosses the 180/-180 meridian.
|
|
182
255
|
*/
|
|
183
256
|
const getBoundingBoxFromGeoJsonPolygon = (polygon) => {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
257
|
+
// For MultiPolygon, we need to check all polygons to find the overall bounding box
|
|
258
|
+
const allPoints = [];
|
|
259
|
+
if (polygon.type === "MultiPolygon") {
|
|
260
|
+
const multiPolygonParsed = geoJsonMultiPolygonSchema.safeParse(polygon);
|
|
261
|
+
if (!multiPolygonParsed.success) {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
multiPolygonParsed.data.coordinates.forEach(polygonCoords => {
|
|
265
|
+
const outerRing = polygonCoords[0];
|
|
266
|
+
if (outerRing) {
|
|
267
|
+
allPoints.push(...outerRing);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
187
270
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
271
|
+
else {
|
|
272
|
+
const polygonParsed = geoJsonPolygonSchema.safeParse(polygon);
|
|
273
|
+
if (!polygonParsed.success) {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
const points = polygonParsed.data.coordinates[0];
|
|
277
|
+
if (!points) {
|
|
278
|
+
// Should never happen since the schema checks for it
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
allPoints.push(...points);
|
|
282
|
+
}
|
|
283
|
+
if (allPoints.length === 0) {
|
|
191
284
|
return null;
|
|
192
285
|
}
|
|
193
|
-
|
|
194
|
-
const longitudes =
|
|
286
|
+
// Get all longitudes and check if we cross the meridian
|
|
287
|
+
const longitudes = allPoints.map(point => point[0]);
|
|
288
|
+
const latitudes = allPoints.map(point => point[1]);
|
|
289
|
+
const { minLon, maxLon } = getMinMaxLongitudes(longitudes);
|
|
195
290
|
return {
|
|
196
291
|
nw: {
|
|
197
292
|
latitude: Math.max(...latitudes),
|
|
198
|
-
longitude:
|
|
293
|
+
longitude: minLon,
|
|
199
294
|
},
|
|
200
295
|
se: {
|
|
201
296
|
latitude: Math.min(...latitudes),
|
|
202
|
-
longitude:
|
|
297
|
+
longitude: maxLon,
|
|
203
298
|
},
|
|
204
299
|
};
|
|
205
300
|
};
|
|
206
301
|
/**
|
|
207
|
-
* @description Creates a GeoJSON
|
|
302
|
+
* @description Creates a GeoJSON MultiPolygon from a TU bounding box.
|
|
303
|
+
* It has to return a MultiPolygon because the polygon may cross the 180/-180 meridian
|
|
304
|
+
* and we need to avoid the polygon being ambiguous about which side of the meridian it wraps around.
|
|
305
|
+
* so if it crosses the meridian, we return a MultiPolygon with two polygons, one for the western hemisphere and one for the eastern hemisphere.
|
|
306
|
+
* @param boundingBox The bounding box to create a polygon from
|
|
307
|
+
* @returns The polygon created from the bounding box
|
|
208
308
|
*/
|
|
209
309
|
const getGeoJsonPolygonFromBoundingBox = (boundingBox) => {
|
|
210
310
|
const { nw, se } = boundingBox;
|
|
311
|
+
if (nw.longitude > se.longitude) {
|
|
312
|
+
// crossing meridian
|
|
313
|
+
return {
|
|
314
|
+
type: "MultiPolygon",
|
|
315
|
+
coordinates: [
|
|
316
|
+
[
|
|
317
|
+
[
|
|
318
|
+
// western hemisphere
|
|
319
|
+
[-180, nw.latitude], // Northwest corner
|
|
320
|
+
[se.longitude, nw.latitude], // Northeast corner
|
|
321
|
+
[se.longitude, se.latitude], // Southeast corner
|
|
322
|
+
[-180, se.latitude], // Southwest corner
|
|
323
|
+
[-180, nw.latitude], // Close the loop back to Northwest corner
|
|
324
|
+
],
|
|
325
|
+
],
|
|
326
|
+
[
|
|
327
|
+
[
|
|
328
|
+
// eastern hemisphere
|
|
329
|
+
[nw.longitude, nw.latitude], // Northwest corner
|
|
330
|
+
[180, nw.latitude], // Northeast corner
|
|
331
|
+
[180, se.latitude], // Southeast corner
|
|
332
|
+
[nw.longitude, se.latitude], // Southwest corner
|
|
333
|
+
[nw.longitude, nw.latitude], // Close the loop back to Northwest corner
|
|
334
|
+
],
|
|
335
|
+
],
|
|
336
|
+
],
|
|
337
|
+
};
|
|
338
|
+
}
|
|
211
339
|
return {
|
|
212
340
|
type: "Polygon",
|
|
213
341
|
coordinates: [
|
|
@@ -304,7 +432,7 @@ const isGeoJsonPointInPolygon = ({ point, polygon, }) => {
|
|
|
304
432
|
return polygonParsed.data.coordinates.some(linearRing => isGeoJsonPositionInLinearRing({ position: point.coordinates, linearRing }));
|
|
305
433
|
};
|
|
306
434
|
/**
|
|
307
|
-
* Checks if polygon1 is fully contained within polygon2
|
|
435
|
+
* Checks if polygon1/multi-polygon1 is fully contained within polygon2/multi-polygon2
|
|
308
436
|
*/
|
|
309
437
|
const isFullyContainedInGeoJsonPolygon = (polygon1, polygon2) => {
|
|
310
438
|
const polygon1Parsed = geoJsonPolygonSchema.safeParse(polygon1);
|
|
@@ -316,25 +444,21 @@ const isFullyContainedInGeoJsonPolygon = (polygon1, polygon2) => {
|
|
|
316
444
|
return polygon1Parsed.data.coordinates.every(linearRing => polygon2Parsed.data.coordinates.some(lr => linearRing.every(position => isGeoJsonPositionInLinearRing({ position, linearRing: lr }))));
|
|
317
445
|
};
|
|
318
446
|
/**
|
|
319
|
-
* @description Gets the intersection between two GeoJSON polygons. If one polygon is fully contained within the other,
|
|
320
|
-
* returns the
|
|
321
|
-
* @param polygon1 The first polygon to check intersection
|
|
322
|
-
* @param polygon2 The second polygon to check intersection
|
|
447
|
+
* @description Gets the intersection between two GeoJSON polygons/multi-polygons. If one polygon/multi-polygon is fully contained within the other,
|
|
448
|
+
* returns either the inner or outer polygon based on containmentPreference. Otherwise returns a MultiPolygon representing the intersection.
|
|
449
|
+
* @param polygon1 The first polygon/multi-polygon to check intersection
|
|
450
|
+
* @param polygon2 The second polygon/multi-polygon to check intersection
|
|
451
|
+
* @param containmentPreference Controls what to return when one polygon contains the other:
|
|
452
|
+
* - "outer": Return the containing polygon
|
|
453
|
+
* - "inner": Return the contained polygon
|
|
454
|
+
* - "none": Skip containment checks and return raw intersection
|
|
323
455
|
* @returns {(GeoJsonMultiPolygon | GeoJsonPolygon)} The intersection as either a Polygon (if one contains the other) or MultiPolygon
|
|
324
456
|
*/
|
|
325
457
|
const getGeoJsonPolygonIntersection = (polygon1, polygon2) => {
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
if (!polygon1Parsed.success || !polygon2Parsed.success) {
|
|
458
|
+
const intersectionResult = polygonClipping.intersection(polygon1.type === "MultiPolygon" ? polygon1.coordinates : [polygon1.coordinates], polygon2.type === "MultiPolygon" ? polygon2.coordinates : [polygon2.coordinates]);
|
|
459
|
+
if (intersectionResult.length === 0) {
|
|
329
460
|
return null;
|
|
330
461
|
}
|
|
331
|
-
if (isFullyContainedInGeoJsonPolygon(polygon1, polygon2)) {
|
|
332
|
-
return polygon1;
|
|
333
|
-
}
|
|
334
|
-
if (isFullyContainedInGeoJsonPolygon(polygon2, polygon1)) {
|
|
335
|
-
return polygon2;
|
|
336
|
-
}
|
|
337
|
-
const intersectionResult = polygonClipping.intersection(polygon1.coordinates, polygon2.coordinates);
|
|
338
462
|
if (intersectionResult.length === 1 && intersectionResult[0]) {
|
|
339
463
|
return {
|
|
340
464
|
type: "Polygon",
|
|
@@ -343,7 +467,7 @@ const getGeoJsonPolygonIntersection = (polygon1, polygon2) => {
|
|
|
343
467
|
}
|
|
344
468
|
return {
|
|
345
469
|
type: "MultiPolygon",
|
|
346
|
-
coordinates:
|
|
470
|
+
coordinates: intersectionResult,
|
|
347
471
|
};
|
|
348
472
|
};
|
|
349
473
|
|
|
@@ -491,7 +615,10 @@ const tuGeoJsonRectangularBoxPolygonSchema = zod.z
|
|
|
491
615
|
});
|
|
492
616
|
|
|
493
617
|
exports.EARTH_RADIUS = EARTH_RADIUS;
|
|
618
|
+
exports.boundingBoxCrossesMeridian = boundingBoxCrossesMeridian;
|
|
619
|
+
exports.checkCrossesMeridian = checkCrossesMeridian;
|
|
494
620
|
exports.coordinatesToStandardFormat = coordinatesToStandardFormat;
|
|
621
|
+
exports.denormalizeLongitude = denormalizeLongitude;
|
|
495
622
|
exports.geoJsonBboxSchema = geoJsonBboxSchema;
|
|
496
623
|
exports.geoJsonGeometrySchema = geoJsonGeometrySchema;
|
|
497
624
|
exports.geoJsonLineStringSchema = geoJsonLineStringSchema;
|
|
@@ -507,6 +634,7 @@ exports.getBoundingBoxFromGeoJsonPolygon = getBoundingBoxFromGeoJsonPolygon;
|
|
|
507
634
|
exports.getExtremeGeoJsonPointFromPolygon = getExtremeGeoJsonPointFromPolygon;
|
|
508
635
|
exports.getGeoJsonPolygonFromBoundingBox = getGeoJsonPolygonFromBoundingBox;
|
|
509
636
|
exports.getGeoJsonPolygonIntersection = getGeoJsonPolygonIntersection;
|
|
637
|
+
exports.getMinMaxLongitudes = getMinMaxLongitudes;
|
|
510
638
|
exports.getMultipleCoordinatesFromGeoJsonObject = getMultipleCoordinatesFromGeoJsonObject;
|
|
511
639
|
exports.getPointCoordinateFromGeoJsonObject = getPointCoordinateFromGeoJsonObject;
|
|
512
640
|
exports.getPointCoordinateFromGeoJsonPoint = getPointCoordinateFromGeoJsonPoint;
|
|
@@ -515,6 +643,7 @@ exports.getPolygonFromPointAndRadius = getPolygonFromPointAndRadius;
|
|
|
515
643
|
exports.isFullyContainedInGeoJsonPolygon = isFullyContainedInGeoJsonPolygon;
|
|
516
644
|
exports.isGeoJsonPointInPolygon = isGeoJsonPointInPolygon;
|
|
517
645
|
exports.isGeoJsonPositionInLinearRing = isGeoJsonPositionInLinearRing;
|
|
646
|
+
exports.normalizeLongitudes = normalizeLongitudes;
|
|
518
647
|
exports.tuGeoJsonPointRadiusSchema = tuGeoJsonPointRadiusSchema;
|
|
519
648
|
exports.tuGeoJsonPolygonNoHolesSchema = tuGeoJsonPolygonNoHolesSchema;
|
|
520
649
|
exports.tuGeoJsonRectangularBoxPolygonSchema = tuGeoJsonRectangularBoxPolygonSchema;
|
package/index.esm.js
CHANGED
|
@@ -115,6 +115,59 @@ const geoJsonBboxSchema = z
|
|
|
115
115
|
message: "Invalid bounding box: maxLng should be greater than minLng, and maxLat should be greater than minLat.",
|
|
116
116
|
});
|
|
117
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Utilities for handling coordinates and bounding boxes that cross the 180/-180 meridian.
|
|
120
|
+
*/
|
|
121
|
+
/**
|
|
122
|
+
* Checks if a sequence of longitudes crosses the meridian by looking for large longitude differences.
|
|
123
|
+
*/
|
|
124
|
+
const checkCrossesMeridian = (longitudes) => {
|
|
125
|
+
return longitudes.some((lon, i) => {
|
|
126
|
+
const nextLon = longitudes[i + 1];
|
|
127
|
+
if (nextLon === undefined) {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
return Math.abs(lon - nextLon) > 180;
|
|
131
|
+
});
|
|
132
|
+
};
|
|
133
|
+
/**
|
|
134
|
+
* Normalizes longitudes to the 0-360 range.
|
|
135
|
+
*/
|
|
136
|
+
const normalizeLongitudes = (longitudes) => {
|
|
137
|
+
return longitudes.map(lon => (lon < 0 ? lon + 360 : lon));
|
|
138
|
+
};
|
|
139
|
+
/**
|
|
140
|
+
* Converts a longitude from the 0-360 range back to the -180/180 range.
|
|
141
|
+
*/
|
|
142
|
+
const denormalizeLongitude = (lon) => {
|
|
143
|
+
return lon > 180 ? lon - 360 : lon;
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* Checks if a bounding box crosses the meridian.
|
|
147
|
+
*/
|
|
148
|
+
const boundingBoxCrossesMeridian = (nwLon, seLon) => {
|
|
149
|
+
return Math.abs(nwLon - seLon) > 180;
|
|
150
|
+
};
|
|
151
|
+
/**
|
|
152
|
+
* Gets min/max longitudes handling meridian crossing.
|
|
153
|
+
* Returns longitudes in -180/180 range.
|
|
154
|
+
*/
|
|
155
|
+
const getMinMaxLongitudes = (longitudes) => {
|
|
156
|
+
if (checkCrossesMeridian(longitudes)) {
|
|
157
|
+
const normalizedLongitudes = normalizeLongitudes(longitudes);
|
|
158
|
+
const minLon = Math.min(...normalizedLongitudes);
|
|
159
|
+
const maxLon = Math.max(...normalizedLongitudes);
|
|
160
|
+
return {
|
|
161
|
+
minLon: denormalizeLongitude(minLon),
|
|
162
|
+
maxLon: denormalizeLongitude(maxLon),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
minLon: Math.min(...longitudes),
|
|
167
|
+
maxLon: Math.max(...longitudes),
|
|
168
|
+
};
|
|
169
|
+
};
|
|
170
|
+
|
|
118
171
|
const EARTH_RADIUS = 6378137; // Earth’s mean radius in meters
|
|
119
172
|
/**
|
|
120
173
|
* @description Creates a polygon (with no holes) from a bounding box.
|
|
@@ -135,21 +188,40 @@ const getPolygonFromBbox = (bbox) => {
|
|
|
135
188
|
};
|
|
136
189
|
};
|
|
137
190
|
/**
|
|
138
|
-
* @description Creates a bounding box from a GeoJSON Polygon.
|
|
191
|
+
* @description Creates a bounding box from a GeoJSON Polygon or MultiPolygon.
|
|
192
|
+
* Handles cases where the polygon crosses the 180/-180 meridian.
|
|
139
193
|
*/
|
|
140
194
|
const getBboxFromGeoJsonPolygon = (polygon) => {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
195
|
+
// For MultiPolygon, we need to check all polygons to find the overall bounding box
|
|
196
|
+
const allPoints = [];
|
|
197
|
+
if (polygon.type === "MultiPolygon") {
|
|
198
|
+
polygon.coordinates.forEach(polygonCoords => {
|
|
199
|
+
const outerRing = polygonCoords[0];
|
|
200
|
+
if (outerRing) {
|
|
201
|
+
allPoints.push(...outerRing);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
144
204
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
205
|
+
else {
|
|
206
|
+
const polygonParsed = geoJsonPolygonSchema.safeParse(polygon);
|
|
207
|
+
if (!polygonParsed.success) {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
const points = polygonParsed.data.coordinates[0];
|
|
211
|
+
if (!points) {
|
|
212
|
+
// Should never happen since the schema checks for it
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
allPoints.push(...points);
|
|
216
|
+
}
|
|
217
|
+
if (allPoints.length === 0) {
|
|
148
218
|
return null;
|
|
149
219
|
}
|
|
150
|
-
|
|
151
|
-
const longitudes =
|
|
152
|
-
|
|
220
|
+
// Get all longitudes and check if we cross the meridian
|
|
221
|
+
const longitudes = allPoints.map(point => point[0]);
|
|
222
|
+
const latitudes = allPoints.map(point => point[1]);
|
|
223
|
+
const { minLon, maxLon } = getMinMaxLongitudes(longitudes);
|
|
224
|
+
return [minLon, Math.min(...latitudes), maxLon, Math.max(...latitudes)];
|
|
153
225
|
};
|
|
154
226
|
/**
|
|
155
227
|
* @description Creates a round polygon from a point and a radius.
|
|
@@ -177,35 +249,91 @@ const getPolygonFromPointAndRadius = (point, radius) => {
|
|
|
177
249
|
};
|
|
178
250
|
/**
|
|
179
251
|
* @description Creates a TU bounding box from a GeoJson Polygon.
|
|
252
|
+
* Handles cases where the polygon crosses the 180/-180 meridian.
|
|
180
253
|
*/
|
|
181
254
|
const getBoundingBoxFromGeoJsonPolygon = (polygon) => {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
255
|
+
// For MultiPolygon, we need to check all polygons to find the overall bounding box
|
|
256
|
+
const allPoints = [];
|
|
257
|
+
if (polygon.type === "MultiPolygon") {
|
|
258
|
+
const multiPolygonParsed = geoJsonMultiPolygonSchema.safeParse(polygon);
|
|
259
|
+
if (!multiPolygonParsed.success) {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
multiPolygonParsed.data.coordinates.forEach(polygonCoords => {
|
|
263
|
+
const outerRing = polygonCoords[0];
|
|
264
|
+
if (outerRing) {
|
|
265
|
+
allPoints.push(...outerRing);
|
|
266
|
+
}
|
|
267
|
+
});
|
|
185
268
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
269
|
+
else {
|
|
270
|
+
const polygonParsed = geoJsonPolygonSchema.safeParse(polygon);
|
|
271
|
+
if (!polygonParsed.success) {
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
const points = polygonParsed.data.coordinates[0];
|
|
275
|
+
if (!points) {
|
|
276
|
+
// Should never happen since the schema checks for it
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
allPoints.push(...points);
|
|
280
|
+
}
|
|
281
|
+
if (allPoints.length === 0) {
|
|
189
282
|
return null;
|
|
190
283
|
}
|
|
191
|
-
|
|
192
|
-
const longitudes =
|
|
284
|
+
// Get all longitudes and check if we cross the meridian
|
|
285
|
+
const longitudes = allPoints.map(point => point[0]);
|
|
286
|
+
const latitudes = allPoints.map(point => point[1]);
|
|
287
|
+
const { minLon, maxLon } = getMinMaxLongitudes(longitudes);
|
|
193
288
|
return {
|
|
194
289
|
nw: {
|
|
195
290
|
latitude: Math.max(...latitudes),
|
|
196
|
-
longitude:
|
|
291
|
+
longitude: minLon,
|
|
197
292
|
},
|
|
198
293
|
se: {
|
|
199
294
|
latitude: Math.min(...latitudes),
|
|
200
|
-
longitude:
|
|
295
|
+
longitude: maxLon,
|
|
201
296
|
},
|
|
202
297
|
};
|
|
203
298
|
};
|
|
204
299
|
/**
|
|
205
|
-
* @description Creates a GeoJSON
|
|
300
|
+
* @description Creates a GeoJSON MultiPolygon from a TU bounding box.
|
|
301
|
+
* It has to return a MultiPolygon because the polygon may cross the 180/-180 meridian
|
|
302
|
+
* and we need to avoid the polygon being ambiguous about which side of the meridian it wraps around.
|
|
303
|
+
* so if it crosses the meridian, we return a MultiPolygon with two polygons, one for the western hemisphere and one for the eastern hemisphere.
|
|
304
|
+
* @param boundingBox The bounding box to create a polygon from
|
|
305
|
+
* @returns The polygon created from the bounding box
|
|
206
306
|
*/
|
|
207
307
|
const getGeoJsonPolygonFromBoundingBox = (boundingBox) => {
|
|
208
308
|
const { nw, se } = boundingBox;
|
|
309
|
+
if (nw.longitude > se.longitude) {
|
|
310
|
+
// crossing meridian
|
|
311
|
+
return {
|
|
312
|
+
type: "MultiPolygon",
|
|
313
|
+
coordinates: [
|
|
314
|
+
[
|
|
315
|
+
[
|
|
316
|
+
// western hemisphere
|
|
317
|
+
[-180, nw.latitude], // Northwest corner
|
|
318
|
+
[se.longitude, nw.latitude], // Northeast corner
|
|
319
|
+
[se.longitude, se.latitude], // Southeast corner
|
|
320
|
+
[-180, se.latitude], // Southwest corner
|
|
321
|
+
[-180, nw.latitude], // Close the loop back to Northwest corner
|
|
322
|
+
],
|
|
323
|
+
],
|
|
324
|
+
[
|
|
325
|
+
[
|
|
326
|
+
// eastern hemisphere
|
|
327
|
+
[nw.longitude, nw.latitude], // Northwest corner
|
|
328
|
+
[180, nw.latitude], // Northeast corner
|
|
329
|
+
[180, se.latitude], // Southeast corner
|
|
330
|
+
[nw.longitude, se.latitude], // Southwest corner
|
|
331
|
+
[nw.longitude, nw.latitude], // Close the loop back to Northwest corner
|
|
332
|
+
],
|
|
333
|
+
],
|
|
334
|
+
],
|
|
335
|
+
};
|
|
336
|
+
}
|
|
209
337
|
return {
|
|
210
338
|
type: "Polygon",
|
|
211
339
|
coordinates: [
|
|
@@ -302,7 +430,7 @@ const isGeoJsonPointInPolygon = ({ point, polygon, }) => {
|
|
|
302
430
|
return polygonParsed.data.coordinates.some(linearRing => isGeoJsonPositionInLinearRing({ position: point.coordinates, linearRing }));
|
|
303
431
|
};
|
|
304
432
|
/**
|
|
305
|
-
* Checks if polygon1 is fully contained within polygon2
|
|
433
|
+
* Checks if polygon1/multi-polygon1 is fully contained within polygon2/multi-polygon2
|
|
306
434
|
*/
|
|
307
435
|
const isFullyContainedInGeoJsonPolygon = (polygon1, polygon2) => {
|
|
308
436
|
const polygon1Parsed = geoJsonPolygonSchema.safeParse(polygon1);
|
|
@@ -314,25 +442,21 @@ const isFullyContainedInGeoJsonPolygon = (polygon1, polygon2) => {
|
|
|
314
442
|
return polygon1Parsed.data.coordinates.every(linearRing => polygon2Parsed.data.coordinates.some(lr => linearRing.every(position => isGeoJsonPositionInLinearRing({ position, linearRing: lr }))));
|
|
315
443
|
};
|
|
316
444
|
/**
|
|
317
|
-
* @description Gets the intersection between two GeoJSON polygons. If one polygon is fully contained within the other,
|
|
318
|
-
* returns the
|
|
319
|
-
* @param polygon1 The first polygon to check intersection
|
|
320
|
-
* @param polygon2 The second polygon to check intersection
|
|
445
|
+
* @description Gets the intersection between two GeoJSON polygons/multi-polygons. If one polygon/multi-polygon is fully contained within the other,
|
|
446
|
+
* returns either the inner or outer polygon based on containmentPreference. Otherwise returns a MultiPolygon representing the intersection.
|
|
447
|
+
* @param polygon1 The first polygon/multi-polygon to check intersection
|
|
448
|
+
* @param polygon2 The second polygon/multi-polygon to check intersection
|
|
449
|
+
* @param containmentPreference Controls what to return when one polygon contains the other:
|
|
450
|
+
* - "outer": Return the containing polygon
|
|
451
|
+
* - "inner": Return the contained polygon
|
|
452
|
+
* - "none": Skip containment checks and return raw intersection
|
|
321
453
|
* @returns {(GeoJsonMultiPolygon | GeoJsonPolygon)} The intersection as either a Polygon (if one contains the other) or MultiPolygon
|
|
322
454
|
*/
|
|
323
455
|
const getGeoJsonPolygonIntersection = (polygon1, polygon2) => {
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
if (!polygon1Parsed.success || !polygon2Parsed.success) {
|
|
456
|
+
const intersectionResult = intersection(polygon1.type === "MultiPolygon" ? polygon1.coordinates : [polygon1.coordinates], polygon2.type === "MultiPolygon" ? polygon2.coordinates : [polygon2.coordinates]);
|
|
457
|
+
if (intersectionResult.length === 0) {
|
|
327
458
|
return null;
|
|
328
459
|
}
|
|
329
|
-
if (isFullyContainedInGeoJsonPolygon(polygon1, polygon2)) {
|
|
330
|
-
return polygon1;
|
|
331
|
-
}
|
|
332
|
-
if (isFullyContainedInGeoJsonPolygon(polygon2, polygon1)) {
|
|
333
|
-
return polygon2;
|
|
334
|
-
}
|
|
335
|
-
const intersectionResult = intersection(polygon1.coordinates, polygon2.coordinates);
|
|
336
460
|
if (intersectionResult.length === 1 && intersectionResult[0]) {
|
|
337
461
|
return {
|
|
338
462
|
type: "Polygon",
|
|
@@ -341,7 +465,7 @@ const getGeoJsonPolygonIntersection = (polygon1, polygon2) => {
|
|
|
341
465
|
}
|
|
342
466
|
return {
|
|
343
467
|
type: "MultiPolygon",
|
|
344
|
-
coordinates:
|
|
468
|
+
coordinates: intersectionResult,
|
|
345
469
|
};
|
|
346
470
|
};
|
|
347
471
|
|
|
@@ -488,4 +612,4 @@ const tuGeoJsonRectangularBoxPolygonSchema = z
|
|
|
488
612
|
}
|
|
489
613
|
});
|
|
490
614
|
|
|
491
|
-
export { EARTH_RADIUS, coordinatesToStandardFormat, geoJsonBboxSchema, geoJsonGeometrySchema, geoJsonLineStringSchema, geoJsonLinearRingSchema, geoJsonMultiLineStringSchema, geoJsonMultiPointSchema, geoJsonMultiPolygonSchema, geoJsonPointSchema, geoJsonPolygonSchema, geoJsonPositionSchema, getBboxFromGeoJsonPolygon, getBoundingBoxFromGeoJsonPolygon, getExtremeGeoJsonPointFromPolygon, getGeoJsonPolygonFromBoundingBox, getGeoJsonPolygonIntersection, getMultipleCoordinatesFromGeoJsonObject, getPointCoordinateFromGeoJsonObject, getPointCoordinateFromGeoJsonPoint, getPolygonFromBbox, getPolygonFromPointAndRadius, isFullyContainedInGeoJsonPolygon, isGeoJsonPointInPolygon, isGeoJsonPositionInLinearRing, tuGeoJsonPointRadiusSchema, tuGeoJsonPolygonNoHolesSchema, tuGeoJsonRectangularBoxPolygonSchema };
|
|
615
|
+
export { EARTH_RADIUS, boundingBoxCrossesMeridian, checkCrossesMeridian, coordinatesToStandardFormat, denormalizeLongitude, geoJsonBboxSchema, geoJsonGeometrySchema, geoJsonLineStringSchema, geoJsonLinearRingSchema, geoJsonMultiLineStringSchema, geoJsonMultiPointSchema, geoJsonMultiPolygonSchema, geoJsonPointSchema, geoJsonPolygonSchema, geoJsonPositionSchema, getBboxFromGeoJsonPolygon, getBoundingBoxFromGeoJsonPolygon, getExtremeGeoJsonPointFromPolygon, getGeoJsonPolygonFromBoundingBox, getGeoJsonPolygonIntersection, getMinMaxLongitudes, getMultipleCoordinatesFromGeoJsonObject, getPointCoordinateFromGeoJsonObject, getPointCoordinateFromGeoJsonPoint, getPolygonFromBbox, getPolygonFromPointAndRadius, isFullyContainedInGeoJsonPolygon, isGeoJsonPointInPolygon, isGeoJsonPositionInLinearRing, normalizeLongitudes, tuGeoJsonPointRadiusSchema, tuGeoJsonPolygonNoHolesSchema, tuGeoJsonRectangularBoxPolygonSchema };
|
package/package.json
CHANGED
package/src/GeoJsonUtils.d.ts
CHANGED
|
@@ -14,21 +14,28 @@ interface TuBoundingBox {
|
|
|
14
14
|
*/
|
|
15
15
|
export declare const getPolygonFromBbox: (bbox: GeoJsonBbox) => TuGeoJsonPolygonNoHoles;
|
|
16
16
|
/**
|
|
17
|
-
* @description Creates a bounding box from a GeoJSON Polygon.
|
|
17
|
+
* @description Creates a bounding box from a GeoJSON Polygon or MultiPolygon.
|
|
18
|
+
* Handles cases where the polygon crosses the 180/-180 meridian.
|
|
18
19
|
*/
|
|
19
|
-
export declare const getBboxFromGeoJsonPolygon: (polygon: GeoJsonPolygon) => GeoJsonBbox | null;
|
|
20
|
+
export declare const getBboxFromGeoJsonPolygon: (polygon: GeoJsonPolygon | GeoJsonMultiPolygon) => GeoJsonBbox | null;
|
|
20
21
|
/**
|
|
21
22
|
* @description Creates a round polygon from a point and a radius.
|
|
22
23
|
*/
|
|
23
24
|
export declare const getPolygonFromPointAndRadius: (point: GeoJsonPoint, radius: number) => GeoJsonPolygon;
|
|
24
25
|
/**
|
|
25
26
|
* @description Creates a TU bounding box from a GeoJson Polygon.
|
|
27
|
+
* Handles cases where the polygon crosses the 180/-180 meridian.
|
|
26
28
|
*/
|
|
27
|
-
export declare const getBoundingBoxFromGeoJsonPolygon: (polygon: GeoJsonPolygon) => TuBoundingBox | null;
|
|
29
|
+
export declare const getBoundingBoxFromGeoJsonPolygon: (polygon: GeoJsonPolygon | GeoJsonMultiPolygon) => TuBoundingBox | null;
|
|
28
30
|
/**
|
|
29
|
-
* @description Creates a GeoJSON
|
|
31
|
+
* @description Creates a GeoJSON MultiPolygon from a TU bounding box.
|
|
32
|
+
* It has to return a MultiPolygon because the polygon may cross the 180/-180 meridian
|
|
33
|
+
* and we need to avoid the polygon being ambiguous about which side of the meridian it wraps around.
|
|
34
|
+
* so if it crosses the meridian, we return a MultiPolygon with two polygons, one for the western hemisphere and one for the eastern hemisphere.
|
|
35
|
+
* @param boundingBox The bounding box to create a polygon from
|
|
36
|
+
* @returns The polygon created from the bounding box
|
|
30
37
|
*/
|
|
31
|
-
export declare const getGeoJsonPolygonFromBoundingBox: (boundingBox: TuBoundingBox) => GeoJsonPolygon;
|
|
38
|
+
export declare const getGeoJsonPolygonFromBoundingBox: (boundingBox: TuBoundingBox) => GeoJsonMultiPolygon | GeoJsonPolygon;
|
|
32
39
|
/**
|
|
33
40
|
* @description Creates TU point coordinate from a GeoJSON Point.
|
|
34
41
|
*/
|
|
@@ -59,15 +66,19 @@ export declare const isGeoJsonPointInPolygon: ({ point, polygon, }: {
|
|
|
59
66
|
polygon: GeoJsonPolygon;
|
|
60
67
|
}) => boolean | null;
|
|
61
68
|
/**
|
|
62
|
-
* Checks if polygon1 is fully contained within polygon2
|
|
69
|
+
* Checks if polygon1/multi-polygon1 is fully contained within polygon2/multi-polygon2
|
|
63
70
|
*/
|
|
64
71
|
export declare const isFullyContainedInGeoJsonPolygon: (polygon1: GeoJsonPolygon, polygon2: GeoJsonPolygon) => boolean | null;
|
|
65
72
|
/**
|
|
66
|
-
* @description Gets the intersection between two GeoJSON polygons. If one polygon is fully contained within the other,
|
|
67
|
-
* returns the
|
|
68
|
-
* @param polygon1 The first polygon to check intersection
|
|
69
|
-
* @param polygon2 The second polygon to check intersection
|
|
73
|
+
* @description Gets the intersection between two GeoJSON polygons/multi-polygons. If one polygon/multi-polygon is fully contained within the other,
|
|
74
|
+
* returns either the inner or outer polygon based on containmentPreference. Otherwise returns a MultiPolygon representing the intersection.
|
|
75
|
+
* @param polygon1 The first polygon/multi-polygon to check intersection
|
|
76
|
+
* @param polygon2 The second polygon/multi-polygon to check intersection
|
|
77
|
+
* @param containmentPreference Controls what to return when one polygon contains the other:
|
|
78
|
+
* - "outer": Return the containing polygon
|
|
79
|
+
* - "inner": Return the contained polygon
|
|
80
|
+
* - "none": Skip containment checks and return raw intersection
|
|
70
81
|
* @returns {(GeoJsonMultiPolygon | GeoJsonPolygon)} The intersection as either a Polygon (if one contains the other) or MultiPolygon
|
|
71
82
|
*/
|
|
72
|
-
export declare const getGeoJsonPolygonIntersection: (polygon1: GeoJsonPolygon, polygon2: GeoJsonPolygon) => GeoJsonMultiPolygon | GeoJsonPolygon | null;
|
|
83
|
+
export declare const getGeoJsonPolygonIntersection: (polygon1: GeoJsonPolygon | GeoJsonMultiPolygon, polygon2: GeoJsonPolygon | GeoJsonMultiPolygon) => GeoJsonMultiPolygon | GeoJsonPolygon | null;
|
|
73
84
|
export {};
|
package/src/index.d.ts
CHANGED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utilities for handling coordinates and bounding boxes that cross the 180/-180 meridian.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Checks if a sequence of longitudes crosses the meridian by looking for large longitude differences.
|
|
6
|
+
*/
|
|
7
|
+
export declare const checkCrossesMeridian: (longitudes: number[]) => boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Normalizes longitudes to the 0-360 range.
|
|
10
|
+
*/
|
|
11
|
+
export declare const normalizeLongitudes: (longitudes: number[]) => number[];
|
|
12
|
+
/**
|
|
13
|
+
* Converts a longitude from the 0-360 range back to the -180/180 range.
|
|
14
|
+
*/
|
|
15
|
+
export declare const denormalizeLongitude: (lon: number) => number;
|
|
16
|
+
/**
|
|
17
|
+
* Checks if a bounding box crosses the meridian.
|
|
18
|
+
*/
|
|
19
|
+
export declare const boundingBoxCrossesMeridian: (nwLon: number, seLon: number) => boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Gets min/max longitudes handling meridian crossing.
|
|
22
|
+
* Returns longitudes in -180/180 range.
|
|
23
|
+
*/
|
|
24
|
+
export declare const getMinMaxLongitudes: (longitudes: number[]) => {
|
|
25
|
+
minLon: number;
|
|
26
|
+
maxLon: number;
|
|
27
|
+
};
|