@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 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
- const polygonParsed = geoJsonPolygonSchema.safeParse(polygon);
144
- if (!polygonParsed.success) {
145
- return null;
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
- const points = polygonParsed.data.coordinates[0];
148
- if (!points) {
149
- // Should never happen since the schema checks for it
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
- const latitudes = points.map(point => point[1]);
153
- const longitudes = points.map(point => point[0]);
154
- return [Math.min(...longitudes), Math.min(...latitudes), Math.max(...longitudes), Math.max(...latitudes)];
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
- const polygonParsed = geoJsonPolygonSchema.safeParse(polygon);
185
- if (!polygonParsed.success) {
186
- return null;
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
- const points = polygonParsed.data.coordinates[0];
189
- if (!points) {
190
- // Should never happen since the schema checks for it
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
- const latitudes = points.map(point => point[1]);
194
- const longitudes = points.map(point => point[0]);
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: Math.min(...longitudes),
293
+ longitude: minLon,
199
294
  },
200
295
  se: {
201
296
  latitude: Math.min(...latitudes),
202
- longitude: Math.max(...longitudes),
297
+ longitude: maxLon,
203
298
  },
204
299
  };
205
300
  };
206
301
  /**
207
- * @description Creates a GeoJSON Polygon from a TU bounding box.
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 contained polygon. Otherwise returns a MultiPolygon representing the intersection.
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 polygon1Parsed = geoJsonPolygonSchema.safeParse(polygon1);
327
- const polygon2Parsed = geoJsonPolygonSchema.safeParse(polygon2);
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: polygonClipping.intersection(polygon1.coordinates, polygon2.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
- const polygonParsed = geoJsonPolygonSchema.safeParse(polygon);
142
- if (!polygonParsed.success) {
143
- return null;
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
- const points = polygonParsed.data.coordinates[0];
146
- if (!points) {
147
- // Should never happen since the schema checks for it
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
- const latitudes = points.map(point => point[1]);
151
- const longitudes = points.map(point => point[0]);
152
- return [Math.min(...longitudes), Math.min(...latitudes), Math.max(...longitudes), Math.max(...latitudes)];
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
- const polygonParsed = geoJsonPolygonSchema.safeParse(polygon);
183
- if (!polygonParsed.success) {
184
- return null;
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
- const points = polygonParsed.data.coordinates[0];
187
- if (!points) {
188
- // Should never happen since the schema checks for it
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
- const latitudes = points.map(point => point[1]);
192
- const longitudes = points.map(point => point[0]);
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: Math.min(...longitudes),
291
+ longitude: minLon,
197
292
  },
198
293
  se: {
199
294
  latitude: Math.min(...latitudes),
200
- longitude: Math.max(...longitudes),
295
+ longitude: maxLon,
201
296
  },
202
297
  };
203
298
  };
204
299
  /**
205
- * @description Creates a GeoJSON Polygon from a TU bounding box.
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 contained polygon. Otherwise returns a MultiPolygon representing the intersection.
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 polygon1Parsed = geoJsonPolygonSchema.safeParse(polygon1);
325
- const polygon2Parsed = geoJsonPolygonSchema.safeParse(polygon2);
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: intersection(polygon1.coordinates, polygon2.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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trackunit/geo-json-utils",
3
- "version": "0.0.4",
3
+ "version": "1.0.0",
4
4
  "repository": "https://github.com/Trackunit/manager",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "engines": {
@@ -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 Polygon from a TU bounding box.
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 contained polygon. Otherwise returns a MultiPolygon representing the intersection.
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
@@ -1,4 +1,5 @@
1
1
  export * from "./GeoJsonSchemas";
2
2
  export * from "./GeoJsonUtils";
3
+ export * from "./meridianUtils";
3
4
  export * from "./TUGeoJsonObjectBridgeUtils";
4
5
  export * from "./TuGeoJsonSchemas";
@@ -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
+ };