@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.cjs.js +370 -174
- package/index.esm.js +366 -175
- package/package.json +1 -1
- package/src/GeoJsonSchemas.d.ts +168 -90
- package/src/GeoJsonUtils.d.ts +18 -41
- package/src/{TUGeoJsonObjectBridgeUtils.d.ts → TuGeoJsonBridgeUtils.d.ts} +1 -1
- package/src/TuGeoJsonConversions.d.ts +42 -0
- package/src/TuGeoJsonSchemas.d.ts +17 -14
- package/src/index.d.ts +3 -2
- /package/src/{meridianUtils.d.ts → MeridianUtils.d.ts} +0 -0
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
|
-
|
|
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
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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.
|
|
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
|
-
*
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
//* --------
|
|
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
|
-
*
|
|
108
|
-
*
|
|
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
|
|
113
|
-
|
|
114
|
-
.
|
|
115
|
-
|
|
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; // Earth
|
|
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
|
|
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 [
|
|
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
|
-
[
|
|
182
|
-
[
|
|
183
|
-
[
|
|
184
|
-
[
|
|
185
|
-
[
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
238
|
-
const
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
coordinates
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
275
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
437
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
.
|
|
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 };
|