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