@trackunit/geo-json-utils 1.11.57 → 1.11.58

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.esm.js CHANGED
@@ -1,44 +1,95 @@
1
1
  import { z } from 'zod';
2
2
  import polygonClipping from 'polygon-clipping';
3
3
 
4
- // * NOTE: For simplicity these tools are built for 2D coordinate space only!
4
+ //* -------- Position -------- *//
5
+ /**
6
+ * A Position is an array of coordinates. [longitude, latitude] or [longitude, latitude, altitude]
7
+ * RFC 7946 Section 3.1.1: "There MUST be two or more elements. The first two elements
8
+ * are longitude and latitude [...] Altitude or elevation MAY be included as an optional
9
+ * third element."
10
+ */
11
+ const geoJsonPosition2dSchema = z.tuple([z.number(), z.number()]);
12
+ const geoJsonPosition3dSchema = z.tuple([z.number(), z.number(), z.number()]);
13
+ const geoJsonPositionSchema = z
14
+ .union([geoJsonPosition2dSchema, geoJsonPosition3dSchema])
15
+ .refine(([lng, lat]) => lng >= -180 && lng <= 180 && lat >= -90 && lat <= 90, {
16
+ message: "Invalid position: longitude must be between -180 and 180, latitude must be between -90 and 90 (RFC 7946 Section 4).",
17
+ });
18
+ //* -------- Bbox -------- *//
5
19
  /**
6
- * A Position is an array of coordinates. [x, y]
7
- * https://tools.ietf.org/html/rfc7946#section-3.1.1
20
+ * 2D bounding box of the GeoJSON object.
21
+ * The value of the bbox member is an array of length 4.
22
+ *
23
+ * [west, south, east, north]
24
+ *
25
+ * Note: west may be greater than east for bboxes that cross the
26
+ * antimeridian (RFC 7946 Section 5.2).
27
+ *
28
+ * Zero-height bboxes (north === south) are rejected per RFC 7946 Section 5.2:
29
+ * "The latitude of the northeast corner is always greater than the latitude
30
+ * of the southwest corner." Zero-width bboxes (west === east) are not explicitly
31
+ * forbidden by the RFC for non-antimeridian cases, so they pass.
8
32
  */
9
- const geoJsonPositionSchema = z.tuple([z.number(), z.number()]);
33
+ const geoJsonBboxSchema = z
34
+ .tuple([z.number(), z.number(), z.number(), z.number()])
35
+ .refine(([, south, , north]) => north > south, {
36
+ message: "Invalid bounding box: north latitude must be greater than south latitude (RFC 7946 Section 5.2).",
37
+ })
38
+ .refine(([, south, , north]) => {
39
+ const latInRange = (lat) => lat >= -90 && lat <= 90;
40
+ return latInRange(south) && latInRange(north);
41
+ }, {
42
+ message: "Invalid bounding box: latitude must be between -90 and 90 (RFC 7946 Section 5.3).",
43
+ })
44
+ .refine(([west, , east]) => {
45
+ const lngInRange = (lng) => lng >= -180 && lng <= 180;
46
+ return lngInRange(west) && lngInRange(east);
47
+ }, {
48
+ message: "Invalid bounding box: longitude must be between -180 and 180 (RFC 7946 Section 5).",
49
+ });
50
+ //* -------- Geometry Objects -------- *//
10
51
  /**
11
52
  * Point geometry object.
53
+ * Uses z.object() (not strictObject) so RFC 7946 Section 6.1 "foreign members"
54
+ * (crs, vendor extensions, etc.) are accepted and stripped during parse.
12
55
  * https://tools.ietf.org/html/rfc7946#section-3.1.2
13
56
  */
14
- const geoJsonPointSchema = z.strictObject({
57
+ const geoJsonPointSchema = z.object({
15
58
  type: z.literal("Point"),
16
59
  coordinates: geoJsonPositionSchema,
60
+ bbox: geoJsonBboxSchema.optional(),
17
61
  });
18
62
  /**
19
63
  * MultiPoint geometry object.
20
- * https://tools.ietf.org/html/rfc7946#section-3.1.3
64
+ * Foreign members (RFC 7946 Section 6.1) are stripped during parse.
65
+ * https://tools.ietf.org/html/rfc7946#section-3.1.3
21
66
  */
22
- const geoJsonMultiPointSchema = z.strictObject({
67
+ const geoJsonMultiPointSchema = z.object({
23
68
  type: z.literal("MultiPoint"),
24
69
  coordinates: z.array(geoJsonPositionSchema),
70
+ bbox: geoJsonBboxSchema.optional(),
25
71
  });
26
72
  /**
27
73
  * LineString geometry object.
28
74
  * Minimum length of 2 positions.
75
+ * Foreign members (RFC 7946 Section 6.1) are stripped during parse.
29
76
  * https://tools.ietf.org/html/rfc7946#section-3.1.4
30
77
  */
31
- const geoJsonLineStringSchema = z.strictObject({
78
+ const geoJsonLineStringSchema = z.object({
32
79
  type: z.literal("LineString"),
33
80
  coordinates: z.array(geoJsonPositionSchema).min(2),
81
+ bbox: geoJsonBboxSchema.optional(),
34
82
  });
35
83
  /**
36
84
  * MultiLineString geometry object.
85
+ * Each inner array is a LineString coordinate array (>= 2 positions per RFC 7946 Section 3.1.5).
86
+ * Foreign members (RFC 7946 Section 6.1) are stripped during parse.
37
87
  * https://tools.ietf.org/html/rfc7946#section-3.1.5
38
88
  */
39
- const geoJsonMultiLineStringSchema = z.strictObject({
89
+ const geoJsonMultiLineStringSchema = z.object({
40
90
  type: z.literal("MultiLineString"),
41
- coordinates: z.array(z.array(geoJsonPositionSchema)),
91
+ coordinates: z.array(z.array(geoJsonPositionSchema).min(2)),
92
+ bbox: geoJsonBboxSchema.optional(),
42
93
  });
43
94
  /**
44
95
  * Helper type for reuse across polygon schemas.
@@ -60,14 +111,12 @@ const geoJsonLinearRingSchema = z
60
111
  .superRefine((coords, ctx) => {
61
112
  const first = coords[0];
62
113
  const last = coords[coords.length - 1];
63
- // Check if first and last coordinates match
64
114
  if (JSON.stringify(first) !== JSON.stringify(last)) {
65
115
  ctx.addIssue({
66
116
  code: z.ZodIssueCode.custom,
67
117
  message: "First and last coordinate positions must be identical (to close the linear ring aka polygon).",
68
118
  });
69
119
  }
70
- // Check if consecutive points are identical (excluding first and last)
71
120
  for (let i = 1; i < coords.length - 1; i++) {
72
121
  if (JSON.stringify(coords[i]) === JSON.stringify(coords[i - 1])) {
73
122
  ctx.addIssue({
@@ -79,21 +128,34 @@ const geoJsonLinearRingSchema = z
79
128
  });
80
129
  /**
81
130
  * Polygon geometry object.
131
+ * Must have at least one ring (the exterior ring) per RFC 7946 Section 3.1.6.
132
+ * Foreign members (RFC 7946 Section 6.1) are stripped during parse.
82
133
  * https://tools.ietf.org/html/rfc7946#section-3.1.6
83
134
  */
84
- const geoJsonPolygonSchema = z.strictObject({
135
+ const geoJsonPolygonSchema = z.object({
85
136
  type: z.literal("Polygon"),
86
- coordinates: z.array(geoJsonLinearRingSchema),
137
+ coordinates: z.array(geoJsonLinearRingSchema).min(1),
138
+ bbox: geoJsonBboxSchema.optional(),
87
139
  });
88
140
  /**
89
141
  * MultiPolygon geometry object.
142
+ * Foreign members (RFC 7946 Section 6.1) are stripped during parse.
90
143
  * https://tools.ietf.org/html/rfc7946#section-3.1.7
91
144
  */
92
- const geoJsonMultiPolygonSchema = z.strictObject({
145
+ const geoJsonMultiPolygonSchema = z.object({
93
146
  type: z.literal("MultiPolygon"),
94
147
  coordinates: z.array(z.array(geoJsonLinearRingSchema)),
148
+ bbox: geoJsonBboxSchema.optional(),
149
+ });
150
+ /**
151
+ * GeometryCollection schema.
152
+ * Foreign members (RFC 7946 Section 6.1) are stripped during parse.
153
+ */
154
+ const geoJsonGeometryCollectionSchema = z.object({
155
+ type: z.literal("GeometryCollection"),
156
+ geometries: z.array(z.lazy(() => geoJsonGeometrySchema)),
157
+ bbox: geoJsonBboxSchema.optional(),
95
158
  });
96
- // The same for Geometry, GeometryCollection, GeoJsonProperties, Feature, FeatureCollection, etc.
97
159
  const geoJsonGeometrySchema = z.union([
98
160
  geoJsonPointSchema,
99
161
  geoJsonMultiPointSchema,
@@ -101,18 +163,32 @@ const geoJsonGeometrySchema = z.union([
101
163
  geoJsonMultiLineStringSchema,
102
164
  geoJsonPolygonSchema,
103
165
  geoJsonMultiPolygonSchema,
166
+ geoJsonGeometryCollectionSchema,
104
167
  ]);
105
- //* -------- Bbox -------- *//
168
+ //* -------- Feature -------- *//
169
+ /**
170
+ * Feature object.
171
+ * Geometry may be null for unlocated features (RFC 7946 Section 3.2).
172
+ * Foreign members (RFC 7946 Section 6.1) are stripped during parse.
173
+ * https://tools.ietf.org/html/rfc7946#section-3.2
174
+ */
175
+ const geoJsonFeatureSchema = z.object({
176
+ type: z.literal("Feature"),
177
+ geometry: geoJsonGeometrySchema.nullable(),
178
+ properties: z.record(z.string(), z.unknown()).nullable(),
179
+ id: z.union([z.string(), z.number()]).optional(),
180
+ bbox: geoJsonBboxSchema.optional(),
181
+ });
182
+ //* -------- FeatureCollection -------- *//
106
183
  /**
107
- * 2D bounding box of the GeoJSON object.
108
- * The value of the Bbox member is an array of length 4.
109
- *
110
- * [min_lon, min_lat, max_lon, max_lat]
184
+ * FeatureCollection object.
185
+ * Foreign members (RFC 7946 Section 6.1) are stripped during parse.
186
+ * https://tools.ietf.org/html/rfc7946#section-3.3
111
187
  */
112
- const geoJsonBboxSchema = z
113
- .tuple([z.number(), z.number(), z.number(), z.number()])
114
- .refine(([minLng, minLat, maxLng, maxLat]) => maxLng > minLng && maxLat > minLat, {
115
- message: "Invalid bounding box: maxLng should be greater than minLng, and maxLat should be greater than minLat.",
188
+ const geoJsonFeatureCollectionSchema = z.object({
189
+ type: z.literal("FeatureCollection"),
190
+ features: z.array(geoJsonFeatureSchema),
191
+ bbox: geoJsonBboxSchema.optional(),
116
192
  });
117
193
 
118
194
  /**
@@ -168,21 +244,50 @@ const getMinMaxLongitudes = (longitudes) => {
168
244
  };
169
245
  };
170
246
 
171
- const EARTH_RADIUS = 6378137; // Earths mean radius in meters
247
+ const EARTH_RADIUS = 6378137; // Earth's mean radius in meters
248
+ const toClipPolygon = (coords) => coords.map(ring => ring.map(([lng, lat]) => [lng, lat]));
249
+ const toClipMultiPolygon = (coords) => coords.map(polygon => toClipPolygon(polygon));
172
250
  /**
173
- * @description Creates a polygon (with no holes) from a bounding box.
251
+ * @description Creates a polygon from a bounding box.
252
+ * Handles antimeridian-crossing bboxes (where west > east) by splitting into
253
+ * a MultiPolygon with one polygon per hemisphere.
174
254
  */
175
255
  const getPolygonFromBbox = (bbox) => {
176
- const [minLon, minLat, maxLon, maxLat] = bbox;
256
+ const [west, south, east, north] = bbox;
257
+ if (west > east) {
258
+ return {
259
+ type: "MultiPolygon",
260
+ coordinates: [
261
+ [
262
+ [
263
+ [west, south],
264
+ [180, south],
265
+ [180, north],
266
+ [west, north],
267
+ [west, south],
268
+ ],
269
+ ],
270
+ [
271
+ [
272
+ [-180, south],
273
+ [east, south],
274
+ [east, north],
275
+ [-180, north],
276
+ [-180, south],
277
+ ],
278
+ ],
279
+ ],
280
+ };
281
+ }
177
282
  return {
178
283
  type: "Polygon",
179
284
  coordinates: [
180
285
  [
181
- [minLon, minLat],
182
- [maxLon, minLat],
183
- [maxLon, maxLat],
184
- [minLon, maxLat],
185
- [minLon, minLat],
286
+ [west, south],
287
+ [east, south],
288
+ [east, north],
289
+ [west, north],
290
+ [west, south],
186
291
  ],
187
292
  ],
188
293
  };
@@ -192,10 +297,13 @@ const getPolygonFromBbox = (bbox) => {
192
297
  * Handles cases where the polygon crosses the 180/-180 meridian.
193
298
  */
194
299
  const getBboxFromGeoJsonPolygon = (polygon) => {
195
- // For MultiPolygon, we need to check all polygons to find the overall bounding box
196
300
  const allPoints = [];
197
301
  if (polygon.type === "MultiPolygon") {
198
- polygon.coordinates.forEach(polygonCoords => {
302
+ const multiPolygonParsed = geoJsonMultiPolygonSchema.safeParse(polygon);
303
+ if (!multiPolygonParsed.success) {
304
+ return null;
305
+ }
306
+ multiPolygonParsed.data.coordinates.forEach(polygonCoords => {
199
307
  const outerRing = polygonCoords[0];
200
308
  if (outerRing) {
201
309
  allPoints.push(...outerRing);
@@ -209,7 +317,6 @@ const getBboxFromGeoJsonPolygon = (polygon) => {
209
317
  }
210
318
  const points = polygonParsed.data.coordinates[0];
211
319
  if (!points) {
212
- // Should never happen since the schema checks for it
213
320
  return null;
214
321
  }
215
322
  allPoints.push(...points);
@@ -217,142 +324,75 @@ const getBboxFromGeoJsonPolygon = (polygon) => {
217
324
  if (allPoints.length === 0) {
218
325
  return null;
219
326
  }
220
- // Get all longitudes and check if we cross the meridian
221
327
  const longitudes = allPoints.map(point => point[0]);
222
328
  const latitudes = allPoints.map(point => point[1]);
223
329
  const { minLon, maxLon } = getMinMaxLongitudes(longitudes);
224
330
  return [minLon, Math.min(...latitudes), maxLon, Math.max(...latitudes)];
225
331
  };
332
+ const normalizeLongitude = (lon) => ((lon + 540) % 360) - 180;
333
+ const WESTERN_HEMISPHERE = [
334
+ [
335
+ [-180, -90],
336
+ [0, -90],
337
+ [0, 90],
338
+ [-180, 90],
339
+ [-180, -90],
340
+ ],
341
+ ];
342
+ const EASTERN_HEMISPHERE = [
343
+ [
344
+ [0, -90],
345
+ [180, -90],
346
+ [180, 90],
347
+ [0, 90],
348
+ [0, -90],
349
+ ],
350
+ ];
226
351
  /**
227
352
  * @description Creates a round polygon from a point and a radius.
353
+ * Handles antimeridian crossing per RFC 7946 Section 3.1.9 by splitting
354
+ * into a MultiPolygon. Clamps polar latitudes to [-90, 90].
355
+ *
356
+ * Returns null when the polygon crosses the antimeridian but both hemisphere
357
+ * intersections yield zero polygons (e.g. very small polygons near the
358
+ * dateline). In that case, returning the original coordinates would produce
359
+ * invalid geometry per RFC 7946.
228
360
  */
229
361
  const getPolygonFromPointAndRadius = (point, radius) => {
230
362
  const [lon, lat] = point.coordinates;
231
- // Adjust the number of points based on radius (resolution)
232
- const pointsCount = Math.max(32, Math.floor(radius / 100)); // More points for larger radius
363
+ const pointsCount = Math.max(32, Math.floor(radius / 100));
233
364
  const angleStep = (2 * Math.PI) / pointsCount;
365
+ const deltaLat = (radius / EARTH_RADIUS) * (180 / Math.PI);
366
+ const deltaLon = deltaLat / Math.cos((lat * Math.PI) / 180);
234
367
  const coordinates = [];
235
368
  for (let i = 0; i <= pointsCount; i++) {
236
369
  const angle = i * angleStep;
237
- // Calculate offset in latitude and longitude
238
- const deltaLat = (radius / EARTH_RADIUS) * (180 / Math.PI);
239
- const deltaLon = deltaLat / Math.cos((lat * Math.PI) / 180);
240
- // Calculate new coordinates based on angle
241
- const newLat = lat + deltaLat * Math.sin(angle);
242
- const newLon = lon + deltaLon * Math.cos(angle);
370
+ const newLat = Math.max(-90, Math.min(90, lat + deltaLat * Math.sin(angle)));
371
+ const newLon = normalizeLongitude(lon + deltaLon * Math.cos(angle));
243
372
  coordinates.push([newLon, newLat]);
244
373
  }
245
- return {
246
- type: "Polygon",
247
- coordinates: [coordinates],
248
- };
249
- };
250
- /**
251
- * @description Creates a TU bounding box from a GeoJson Polygon.
252
- * Handles cases where the polygon crosses the 180/-180 meridian.
253
- */
254
- const getBoundingBoxFromGeoJsonPolygon = (polygon) => {
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
- });
268
- }
269
- else {
270
- const polygonParsed = geoJsonPolygonSchema.safeParse(polygon);
271
- if (!polygonParsed.success) {
272
- return null;
374
+ const longitudes = coordinates.map(c => c[0]);
375
+ if (checkCrossesMeridian(longitudes)) {
376
+ const rawPolygon = [coordinates.map(c => [c[0], c[1]])];
377
+ const westPart = polygonClipping.intersection(rawPolygon, WESTERN_HEMISPHERE);
378
+ const eastPart = polygonClipping.intersection(rawPolygon, EASTERN_HEMISPHERE);
379
+ const allParts = [...westPart, ...eastPart];
380
+ if (allParts.length === 1 && allParts[0]) {
381
+ return { type: "Polygon", coordinates: allParts[0] };
273
382
  }
274
- const points = polygonParsed.data.coordinates[0];
275
- if (!points) {
276
- // Should never happen since the schema checks for it
277
- return null;
383
+ if (allParts.length > 1) {
384
+ return { type: "MultiPolygon", coordinates: allParts };
278
385
  }
279
- allPoints.push(...points);
280
- }
281
- if (allPoints.length === 0) {
386
+ // Both hemisphere intersections produced zero polygons — degenerate case
387
+ // (e.g. very small polygon near antimeridian). Returning the original
388
+ // coordinates would produce invalid geometry per RFC 7946 Section 3.1.9.
282
389
  return null;
283
390
  }
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);
288
- return {
289
- nw: {
290
- latitude: Math.max(...latitudes),
291
- longitude: minLon,
292
- },
293
- se: {
294
- latitude: Math.min(...latitudes),
295
- longitude: maxLon,
296
- },
297
- };
298
- };
299
- /**
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 {GeoJsonMultiPolygon | GeoJsonPolygon} The polygon created from the bounding box
306
- */
307
- const getGeoJsonPolygonFromBoundingBox = (boundingBox) => {
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
- }
337
391
  return {
338
392
  type: "Polygon",
339
- coordinates: [
340
- [
341
- [nw.longitude, nw.latitude], // Northwest corner
342
- [se.longitude, nw.latitude], // Northeast corner
343
- [se.longitude, se.latitude], // Southeast corner
344
- [nw.longitude, se.latitude], // Southwest corner
345
- [nw.longitude, nw.latitude], // Close the loop back to Northwest corner
346
- ],
347
- ],
393
+ coordinates: [coordinates],
348
394
  };
349
395
  };
350
- /**
351
- * @description Creates TU point coordinate from a GeoJSON Point.
352
- */
353
- const getPointCoordinateFromGeoJsonPoint = (point) => {
354
- return { latitude: point.coordinates[1], longitude: point.coordinates[0] };
355
- };
356
396
  /**
357
397
  * @description Gets the extreme point of a polygon in a given direction.
358
398
  * @param {object} params - The parameters object
@@ -367,7 +407,6 @@ const getExtremeGeoJsonPointFromPolygon = ({ polygon, direction, }) => {
367
407
  }
368
408
  const firstPoint = polygonParsed.data.coordinates[0]?.[0];
369
409
  if (!firstPoint) {
370
- // Should never happen since the schema checks for it
371
410
  return null;
372
411
  }
373
412
  const extremePosition = polygonParsed.data.coordinates[0]?.reduce((extremePoint, currentPoint) => {
@@ -390,7 +429,7 @@ const getExtremeGeoJsonPointFromPolygon = ({ polygon, direction, }) => {
390
429
  type: "Point",
391
430
  coordinates: extremePosition,
392
431
  }
393
- : null; // Should never happen since the schema checks for it
432
+ : null;
394
433
  };
395
434
  /**
396
435
  * Checks if a position is inside a linear ring. On edge is considered inside.
@@ -419,35 +458,80 @@ const isGeoJsonPositionInLinearRing = ({ position, linearRing, }) => {
419
458
  };
420
459
  /**
421
460
  * @description Checks if a point is inside a polygon.
461
+ * Correctly handles holes per RFC 7946 Section 3.1.6: the first ring is the
462
+ * exterior boundary, subsequent rings are holes. A point inside a hole is
463
+ * considered outside the polygon.
422
464
  */
423
465
  const isGeoJsonPointInPolygon = ({ point, polygon, }) => {
424
466
  const polygonParsed = geoJsonPolygonSchema.safeParse(polygon);
425
467
  if (!polygonParsed.success) {
426
468
  return null;
427
469
  }
428
- return polygonParsed.data.coordinates.some(linearRing => isGeoJsonPositionInLinearRing({ position: point.coordinates, linearRing }));
470
+ const [exteriorRing, ...holeRings] = polygonParsed.data.coordinates;
471
+ if (!exteriorRing) {
472
+ return null;
473
+ }
474
+ // Explicit null checks are intentional: isGeoJsonPositionInLinearRing returns
475
+ // boolean | null, and using it directly in boolean expressions like
476
+ // `!result` or `.some()` would silently treat null as false/truthy, masking errors.
477
+ const insideExterior = isGeoJsonPositionInLinearRing({ position: point.coordinates, linearRing: exteriorRing });
478
+ if (insideExterior === null) {
479
+ return null;
480
+ }
481
+ if (!insideExterior) {
482
+ return false;
483
+ }
484
+ for (const hole of holeRings) {
485
+ const insideHole = isGeoJsonPositionInLinearRing({ position: point.coordinates, linearRing: hole });
486
+ if (insideHole === null) {
487
+ return null;
488
+ }
489
+ if (insideHole) {
490
+ return false;
491
+ }
492
+ }
493
+ return true;
429
494
  };
430
495
  /**
431
- * Checks if polygon1/multi-polygon1 is fully contained within polygon2/multi-polygon2
496
+ * Checks if polygon1 is fully contained within polygon2.
497
+ * Correctly handles holes per RFC 7946 Section 3.1.6: a polygon inside
498
+ * a hole of polygon2 is NOT considered contained.
432
499
  */
433
500
  const isFullyContainedInGeoJsonPolygon = (polygon1, polygon2) => {
434
501
  const polygon1Parsed = geoJsonPolygonSchema.safeParse(polygon1);
502
+ if (!polygon1Parsed.success) {
503
+ return null;
504
+ }
435
505
  const polygon2Parsed = geoJsonPolygonSchema.safeParse(polygon2);
436
- // The schema checks more than a TypeScript type can represent
437
- if (!polygon1Parsed.success || !polygon2Parsed.success) {
506
+ if (!polygon2Parsed.success) {
507
+ return null;
508
+ }
509
+ const outerRing1 = polygon1Parsed.data.coordinates[0];
510
+ if (!outerRing1) {
438
511
  return null;
439
512
  }
440
- return polygon1Parsed.data.coordinates.every(linearRing => polygon2Parsed.data.coordinates.some(lr => linearRing.every(position => isGeoJsonPositionInLinearRing({ position, linearRing: lr }))));
513
+ // Explicit null handling: isGeoJsonPointInPolygon returns boolean | null,
514
+ // so we use a for-loop instead of .every() to avoid null being silently
515
+ // coerced to false (masking validation errors as "not contained").
516
+ for (const position of outerRing1) {
517
+ const contained = isGeoJsonPointInPolygon({ point: { coordinates: position }, polygon: polygon2 });
518
+ if (contained === null) {
519
+ return null;
520
+ }
521
+ if (!contained) {
522
+ return false;
523
+ }
524
+ }
525
+ return true;
441
526
  };
442
527
  /**
443
528
  * @description Gets the intersection between two GeoJSON polygons/multi-polygons.
444
529
  * Returns a MultiPolygon representing the intersection, or null if there is no intersection.
445
- * @param polygon1 The first polygon/multi-polygon to check intersection
446
- * @param polygon2 The second polygon/multi-polygon to check intersection
447
- * @returns {(GeoJsonMultiPolygon | GeoJsonPolygon)} The intersection as either a Polygon or MultiPolygon
448
530
  */
449
531
  const getGeoJsonPolygonIntersection = (polygon1, polygon2) => {
450
- const intersectionResult = polygonClipping.intersection(polygon1.type === "MultiPolygon" ? polygon1.coordinates : [polygon1.coordinates], polygon2.type === "MultiPolygon" ? polygon2.coordinates : [polygon2.coordinates]);
532
+ const geom1 = polygon1.type === "MultiPolygon" ? toClipMultiPolygon(polygon1.coordinates) : [toClipPolygon(polygon1.coordinates)];
533
+ const geom2 = polygon2.type === "MultiPolygon" ? toClipMultiPolygon(polygon2.coordinates) : [toClipPolygon(polygon2.coordinates)];
534
+ const intersectionResult = polygonClipping.intersection(geom1, geom2);
451
535
  if (intersectionResult.length === 0) {
452
536
  return null;
453
537
  }
@@ -462,18 +546,6 @@ const getGeoJsonPolygonIntersection = (polygon1, polygon2) => {
462
546
  coordinates: intersectionResult,
463
547
  };
464
548
  };
465
- /**
466
- * @description Creates a TU bounding box from a GeoJSON Bbox.
467
- * @param bbox The GeoJSON Bbox to create a TU bounding box from
468
- * @returns {TuBoundingBox} The TU bounding box created from the GeoJSON Bbox
469
- */
470
- const getBoundingBoxFromGeoJsonBbox = (bbox) => {
471
- const [minLon, minLat, maxLon, maxLat] = bbox;
472
- return {
473
- nw: { longitude: minLon, latitude: maxLat },
474
- se: { longitude: maxLon, latitude: minLat },
475
- };
476
- };
477
549
 
478
550
  //! These tools are used to bridge the gap with out poorly typed graphql types
479
551
  // Should be ideally be avoided but are needed until we fix the graphql types
@@ -551,14 +623,131 @@ const getMultipleCoordinatesFromGeoJsonObject = (geoObject) => {
551
623
  }
552
624
  };
553
625
 
626
+ /**
627
+ * @description Converts a GeoJSON position (which may have altitude) to a 2D position.
628
+ */
629
+ const toPosition2d = (position) => [position[0], position[1]];
630
+ /**
631
+ * @description Creates a TU bounding box from a GeoJson Polygon.
632
+ * Handles cases where the polygon crosses the 180/-180 meridian.
633
+ */
634
+ const getBoundingBoxFromGeoJsonPolygon = (polygon) => {
635
+ const allPoints = [];
636
+ if (polygon.type === "MultiPolygon") {
637
+ const multiPolygonParsed = geoJsonMultiPolygonSchema.safeParse(polygon);
638
+ if (!multiPolygonParsed.success) {
639
+ return null;
640
+ }
641
+ multiPolygonParsed.data.coordinates.forEach(polygonCoords => {
642
+ const outerRing = polygonCoords[0];
643
+ if (outerRing) {
644
+ allPoints.push(...outerRing);
645
+ }
646
+ });
647
+ }
648
+ else {
649
+ const polygonParsed = geoJsonPolygonSchema.safeParse(polygon);
650
+ if (!polygonParsed.success) {
651
+ return null;
652
+ }
653
+ const points = polygonParsed.data.coordinates[0];
654
+ if (!points) {
655
+ return null;
656
+ }
657
+ allPoints.push(...points);
658
+ }
659
+ if (allPoints.length === 0) {
660
+ return null;
661
+ }
662
+ const longitudes = allPoints.map(point => point[0]);
663
+ const latitudes = allPoints.map(point => point[1]);
664
+ const { minLon, maxLon } = getMinMaxLongitudes(longitudes);
665
+ return {
666
+ nw: {
667
+ latitude: Math.max(...latitudes),
668
+ longitude: minLon,
669
+ },
670
+ se: {
671
+ latitude: Math.min(...latitudes),
672
+ longitude: maxLon,
673
+ },
674
+ };
675
+ };
676
+ /**
677
+ * @description Creates a GeoJSON Polygon or MultiPolygon from a TU bounding box.
678
+ * Returns a MultiPolygon when the bounding box crosses the 180/-180 meridian,
679
+ * with one polygon per hemisphere.
680
+ *
681
+ * Returns a 2D-only coordinate type since bounding boxes have no altitude.
682
+ * The return type is assignable to both GeoJsonPolygon | GeoJsonMultiPolygon
683
+ * (for GeoJSON consumers) and GraphQL AreaInput (for query variables).
684
+ */
685
+ const getGeoJsonPolygonFromBoundingBox = (boundingBox) => {
686
+ const { nw, se } = boundingBox;
687
+ if (nw.longitude > se.longitude) {
688
+ return {
689
+ type: "MultiPolygon",
690
+ coordinates: [
691
+ [
692
+ [
693
+ [nw.longitude, se.latitude],
694
+ [180, se.latitude],
695
+ [180, nw.latitude],
696
+ [nw.longitude, nw.latitude],
697
+ [nw.longitude, se.latitude],
698
+ ],
699
+ ],
700
+ [
701
+ [
702
+ [-180, se.latitude],
703
+ [se.longitude, se.latitude],
704
+ [se.longitude, nw.latitude],
705
+ [-180, nw.latitude],
706
+ [-180, se.latitude],
707
+ ],
708
+ ],
709
+ ],
710
+ };
711
+ }
712
+ return {
713
+ type: "Polygon",
714
+ coordinates: [
715
+ [
716
+ [nw.longitude, se.latitude],
717
+ [se.longitude, se.latitude],
718
+ [se.longitude, nw.latitude],
719
+ [nw.longitude, nw.latitude],
720
+ [nw.longitude, se.latitude],
721
+ ],
722
+ ],
723
+ };
724
+ };
725
+ /**
726
+ * @description Creates TU point coordinate from a GeoJSON Point.
727
+ */
728
+ const getPointCoordinateFromGeoJsonPoint = (point) => {
729
+ return { latitude: point.coordinates[1], longitude: point.coordinates[0] };
730
+ };
731
+ /**
732
+ * @description Creates a TU bounding box from a GeoJSON Bbox.
733
+ */
734
+ const getBoundingBoxFromGeoJsonBbox = (bbox) => {
735
+ const [west, south, east, north] = bbox;
736
+ return {
737
+ nw: { longitude: west, latitude: north },
738
+ se: { longitude: east, latitude: south },
739
+ };
740
+ };
741
+
554
742
  //* -------- Trackunit-invented schemas and types to extend the GeoJson spec -------- *//
555
743
  /**
556
744
  * Polygon geometry object that explicitly disallows holes.
557
745
  *
558
746
  * Same as geoJsonPolygonSchema but type disallows holes by
559
747
  * using tuple of one single linear ring instead of an array.
748
+ * Foreign members (RFC 7946 Section 6.1) are stripped during parse.
560
749
  */
561
- const tuGeoJsonPolygonNoHolesSchema = z.strictObject({
750
+ const tuGeoJsonPolygonNoHolesSchema = z.object({
562
751
  //The type is still "Polygon" (not PolygonNoHoles or similar) since it's always
563
752
  //compliant with Polygon, just not the other way around
564
753
  type: z.literal("Polygon"),
@@ -570,17 +759,19 @@ const tuGeoJsonPolygonNoHolesSchema = z.strictObject({
570
759
  * For when you wish to define an area by a point and a radius.
571
760
  *
572
761
  * radius is in meters
762
+ * Foreign members are stripped during parse.
573
763
  */
574
- const tuGeoJsonPointRadiusSchema = z.strictObject({
764
+ const tuGeoJsonPointRadiusSchema = z.object({
575
765
  type: z.literal("PointRadius"),
576
766
  coordinates: geoJsonPositionSchema,
577
767
  radius: z.number().positive(), // in meters
578
768
  });
579
769
  /**
580
770
  * A Polygon with exactly 5 points and 4 horizontal/vertical sides that form a normal rectangular box.
771
+ * Foreign members are stripped during parse.
581
772
  */
582
773
  const tuGeoJsonRectangularBoxPolygonSchema = z
583
- .strictObject({
774
+ .object({
584
775
  type: z.literal("Polygon"),
585
776
  coordinates: z.array(geoJsonLinearRingSchema),
586
777
  })
@@ -618,4 +809,4 @@ const tuGeoJsonRectangularBoxPolygonSchema = z
618
809
  }
619
810
  });
620
811
 
621
- export { EARTH_RADIUS, boundingBoxCrossesMeridian, checkCrossesMeridian, coordinatesToStandardFormat, denormalizeLongitude, geoJsonBboxSchema, geoJsonGeometrySchema, geoJsonLineStringSchema, geoJsonLinearRingSchema, geoJsonMultiLineStringSchema, geoJsonMultiPointSchema, geoJsonMultiPolygonSchema, geoJsonPointSchema, geoJsonPolygonSchema, geoJsonPositionSchema, getBboxFromGeoJsonPolygon, getBoundingBoxFromGeoJsonBbox, getBoundingBoxFromGeoJsonPolygon, getExtremeGeoJsonPointFromPolygon, getGeoJsonPolygonFromBoundingBox, getGeoJsonPolygonIntersection, getMinMaxLongitudes, getMultipleCoordinatesFromGeoJsonObject, getPointCoordinateFromGeoJsonObject, getPointCoordinateFromGeoJsonPoint, getPolygonFromBbox, getPolygonFromPointAndRadius, isFullyContainedInGeoJsonPolygon, isGeoJsonPointInPolygon, isGeoJsonPositionInLinearRing, normalizeLongitudes, tuGeoJsonPointRadiusSchema, tuGeoJsonPolygonNoHolesSchema, tuGeoJsonRectangularBoxPolygonSchema };
812
+ export { EARTH_RADIUS, boundingBoxCrossesMeridian, checkCrossesMeridian, coordinatesToStandardFormat, denormalizeLongitude, 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, isFullyContainedInGeoJsonPolygon, isGeoJsonPointInPolygon, isGeoJsonPositionInLinearRing, normalizeLongitudes, toPosition2d, tuGeoJsonPointRadiusSchema, tuGeoJsonPolygonNoHolesSchema, tuGeoJsonRectangularBoxPolygonSchema };