@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 +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.cjs.js
CHANGED
|
@@ -3,44 +3,95 @@
|
|
|
3
3
|
var zod = require('zod');
|
|
4
4
|
var polygonClipping = require('polygon-clipping');
|
|
5
5
|
|
|
6
|
-
|
|
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
|
-
*
|
|
9
|
-
*
|
|
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
|
|
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.
|
|
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
|
-
*
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
//* --------
|
|
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
|
-
*
|
|
110
|
-
*
|
|
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
|
|
115
|
-
|
|
116
|
-
.
|
|
117
|
-
|
|
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; // Earth
|
|
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
|
|
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 [
|
|
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
|
-
[
|
|
184
|
-
[
|
|
185
|
-
[
|
|
186
|
-
[
|
|
187
|
-
[
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
const
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
coordinates
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
277
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
439
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
.
|
|
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;
|