squirreling 0.9.3 → 0.9.4

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.
@@ -1,434 +1,109 @@
1
- /**
2
- * @import { SpatialFunc, SqlPrimitive } from '../types.js'
3
- */
4
-
5
- // ============================================================================
6
- // GeoJSON geometry helpers
7
- // ============================================================================
8
-
9
- /**
10
- * @typedef {{ type: string, coordinates: any, geometries?: GeoJsonGeometry[] }} GeoJsonGeometry
11
- */
12
-
13
- const EPSILON = 1e-10
14
-
15
- /**
16
- * Normalize a geometry value. Accepts GeoJSON objects.
17
- * Returns null if the value is not a valid geometry.
18
- *
19
- * @param {SqlPrimitive} val
20
- * @returns {GeoJsonGeometry | null}
21
- */
22
- function toGeometry(val) {
23
- if (val == null) return null
24
- if (typeof val === 'object' && !(val instanceof Date) && !Array.isArray(val)) {
25
- // eslint-disable-next-line no-extra-parens
26
- const geom = /** @type {GeoJsonGeometry} */ (val)
27
- if (typeof geom.type === 'string' && geom.coordinates !== undefined) {
28
- return geom
29
- }
30
- // GeometryCollection has geometries instead of coordinates
31
- if (geom.type === 'GeometryCollection' && Array.isArray(geom.geometries)) {
32
- return geom
33
- }
34
- }
35
- return null
36
- }
37
-
38
- // ============================================================================
39
- // Core geometric algorithms
40
- // ============================================================================
41
-
42
- /**
43
- * Compute the squared distance between two 2D points.
44
- *
45
- * @param {number[]} a
46
- * @param {number[]} b
47
- * @returns {number}
48
- */
49
- function distSq(a, b) {
50
- const dx = a[0] - b[0]
51
- const dy = a[1] - b[1]
52
- return dx * dx + dy * dy
53
- }
54
-
55
- /**
56
- * Compute the Euclidean distance between two 2D points.
57
- *
58
- * @param {number[]} a
59
- * @param {number[]} b
60
- * @returns {number}
61
- */
62
- function pointDist(a, b) {
63
- return Math.sqrt(distSq(a, b))
64
- }
65
-
66
- /**
67
- * Minimum distance from point p to line segment [a, b].
68
- *
69
- * @param {number[]} p
70
- * @param {number[]} a
71
- * @param {number[]} b
72
- * @returns {number}
73
- */
74
- function pointToSegmentDist(p, a, b) {
75
- const dx = b[0] - a[0]
76
- const dy = b[1] - a[1]
77
- const lenSq = dx * dx + dy * dy
78
- if (lenSq === 0) return pointDist(p, a)
79
- let t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / lenSq
80
- t = Math.max(0, Math.min(1, t))
81
- return pointDist(p, [a[0] + t * dx, a[1] + t * dy])
82
- }
83
-
84
- /**
85
- * Test whether two line segments [p1,p2] and [p3,p4] intersect.
86
- * Returns true if they share any point (including endpoints).
87
- *
88
- * @param {number[]} p1
89
- * @param {number[]} p2
90
- * @param {number[]} p3
91
- * @param {number[]} p4
92
- * @returns {boolean}
93
- */
94
- function segmentsIntersect(p1, p2, p3, p4) {
95
- const d1 = cross(p3, p4, p1)
96
- const d2 = cross(p3, p4, p2)
97
- const d3 = cross(p1, p2, p3)
98
- const d4 = cross(p1, p2, p4)
99
-
100
- if ((d1 > 0 && d2 < 0 || d1 < 0 && d2 > 0) &&
101
- (d3 > 0 && d4 < 0 || d3 < 0 && d4 > 0)) {
102
- return true
103
- }
104
-
105
- if (Math.abs(d1) < EPSILON && onSegment(p3, p4, p1)) return true
106
- if (Math.abs(d2) < EPSILON && onSegment(p3, p4, p2)) return true
107
- if (Math.abs(d3) < EPSILON && onSegment(p1, p2, p3)) return true
108
- if (Math.abs(d4) < EPSILON && onSegment(p1, p2, p4)) return true
109
-
110
- return false
111
- }
112
-
113
- /**
114
- * Cross product of vectors (b-a) and (c-a).
115
- *
116
- * @param {number[]} a
117
- * @param {number[]} b
118
- * @param {number[]} c
119
- * @returns {number}
120
- */
121
- function cross(a, b, c) {
122
- return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
123
- }
124
-
125
- /**
126
- * Check if point c lies on segment [a, b], assuming collinearity.
127
- *
128
- * @param {number[]} a
129
- * @param {number[]} b
130
- * @param {number[]} c
131
- * @returns {boolean}
132
- */
133
- function onSegment(a, b, c) {
134
- return Math.min(a[0], b[0]) - EPSILON <= c[0] && c[0] <= Math.max(a[0], b[0]) + EPSILON &&
135
- Math.min(a[1], b[1]) - EPSILON <= c[1] && c[1] <= Math.max(a[1], b[1]) + EPSILON
136
- }
137
-
138
- /**
139
- * Point-in-polygon test using ray casting.
140
- * ring is an array of [x, y] coords (closed ring, first = last).
141
- *
142
- * @param {number[]} point
143
- * @param {number[][]} ring
144
- * @returns {boolean}
145
- */
146
- function pointInRing(point, ring) {
147
- const [px, py] = point
148
- let inside = false
149
- for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
150
- const xi = ring[i][0], yi = ring[i][1]
151
- const xj = ring[j][0], yj = ring[j][1]
152
- if (yi > py !== yj > py &&
153
- px < (xj - xi) * (py - yi) / (yj - yi) + xi) {
154
- inside = !inside
155
- }
156
- }
157
- return inside
158
- }
159
-
160
- /**
161
- * Test if point is on the boundary of a ring.
162
- *
163
- * @param {number[]} point
164
- * @param {number[][]} ring
165
- * @returns {boolean}
166
- */
167
- function pointOnRingBoundary(point, ring) {
168
- for (let i = 0; i < ring.length - 1; i++) {
169
- if (pointToSegmentDist(point, ring[i], ring[i + 1]) < EPSILON) {
170
- return true
171
- }
172
- }
173
- return false
174
- }
175
-
176
- /**
177
- * Test if point is inside a polygon (array of rings).
178
- * First ring is exterior, rest are holes.
179
- *
180
- * @param {number[]} point
181
- * @param {number[][][]} rings
182
- * @returns {boolean}
183
- */
184
- function pointInPolygon(point, rings) {
185
- if (!pointInRing(point, rings[0]) && !pointOnRingBoundary(point, rings[0])) return false
186
- for (let i = 1; i < rings.length; i++) {
187
- // Point must not be inside a hole (but can be on hole boundary)
188
- if (pointInRing(point, rings[i]) && !pointOnRingBoundary(point, rings[i])) return false
189
- }
190
- return true
191
- }
192
-
193
- /**
194
- * Test if point is strictly inside a polygon (not on boundary).
195
- *
196
- * @param {number[]} point
197
- * @param {number[][][]} rings
198
- * @returns {boolean}
199
- */
200
- function pointInPolygonInterior(point, rings) {
201
- if (!pointInRing(point, rings[0])) return false
202
- if (pointOnRingBoundary(point, rings[0])) return false
203
- for (let i = 1; i < rings.length; i++) {
204
- if (pointInRing(point, rings[i])) return false
205
- }
206
- return true
207
- }
1
+ import { geomToWkt, parseWkt } from './wkt.js'
2
+ import { distSq, pointInPolygon, pointLineRelation, pointToSegmentDistSq, simpleIntersects, simplePairContainment, simplePairRelation } from './spatial.geometry.js'
3
+ import { simpleGeomEqual } from './spatial.equality.js'
208
4
 
209
5
  /**
210
- * Test if a line segment intersects a ring boundary.
211
- *
212
- * @param {number[]} a
213
- * @param {number[]} b
214
- * @param {number[][]} ring
215
- * @returns {boolean}
6
+ * @import { SpatialFunc, SqlPrimitive } from '../types.js'
7
+ * @import { Geometry, Point, SimpleGeometry } from './geometry.js'
216
8
  */
217
- function segmentIntersectsRing(a, b, ring) {
218
- for (let i = 0; i < ring.length - 1; i++) {
219
- if (segmentsIntersect(a, b, ring[i], ring[i + 1])) return true
220
- }
221
- return false
222
- }
223
9
 
224
10
  /**
225
- * Test if a linestring intersects a polygon.
226
- *
227
- * @param {number[][]} line
228
- * @param {number[][][]} rings
229
- * @returns {boolean}
230
- */
231
- function lineIntersectsPolygon(line, rings) {
232
- // Check if any point of the line is inside the polygon
233
- for (const pt of line) {
234
- if (pointInPolygon(pt, rings)) return true
235
- }
236
- // Check if any segment of the line intersects any ring edge
237
- for (let i = 0; i < line.length - 1; i++) {
238
- for (const ring of rings) {
239
- if (segmentIntersectsRing(line[i], line[i + 1], ring)) return true
240
- }
241
- }
242
- return false
243
- }
244
-
245
- /**
246
- * Test if two linestrings share any point (intersect).
11
+ * Evaluate a spatial predicate function.
247
12
  *
248
- * @param {number[][]} line1
249
- * @param {number[][]} line2
250
- * @returns {boolean}
13
+ * @param {Object} options
14
+ * @param {SpatialFunc} options.funcName
15
+ * @param {SqlPrimitive[]} options.args
16
+ * @returns {SqlPrimitive}
251
17
  */
252
- function linesIntersect(line1, line2) {
253
- for (let i = 0; i < line1.length - 1; i++) {
254
- for (let j = 0; j < line2.length - 1; j++) {
255
- if (segmentsIntersect(line1[i], line1[i + 1], line2[j], line2[j + 1])) return true
256
- }
18
+ export function evaluateSpatialFunc({ funcName, args }) {
19
+ // Singleton functions
20
+ if (funcName === 'ST_GEOMFROMTEXT') {
21
+ if (args[0] == null) return null
22
+ return parseWkt(String(args[0]))
257
23
  }
258
- return false
259
- }
260
24
 
261
- /**
262
- * Test if two polygons share any space.
263
- *
264
- * @param {number[][][]} rings1
265
- * @param {number[][][]} rings2
266
- * @returns {boolean}
267
- */
268
- function polygonsIntersect(rings1, rings2) {
269
- // Check if any vertex of polygon1 is inside polygon2
270
- for (const pt of rings1[0]) {
271
- if (pointInPolygon(pt, rings2)) return true
272
- }
273
- // Check if any vertex of polygon2 is inside polygon1
274
- for (const pt of rings2[0]) {
275
- if (pointInPolygon(pt, rings1)) return true
276
- }
277
- // Check if any edge of polygon1 intersects any edge of polygon2
278
- for (let i = 0; i < rings1[0].length - 1; i++) {
279
- for (let j = 0; j < rings2[0].length - 1; j++) {
280
- if (segmentsIntersect(rings1[0][i], rings1[0][i + 1], rings2[0][j], rings2[0][j + 1])) return true
25
+ if (funcName === 'ST_MAKEENVELOPE') {
26
+ if (args[0] == null || args[1] == null || args[2] == null || args[3] == null) return null
27
+ const xmin = Number(args[0])
28
+ const ymin = Number(args[1])
29
+ const xmax = Number(args[2])
30
+ const ymax = Number(args[3])
31
+ return {
32
+ type: 'Polygon',
33
+ coordinates: [[[xmin, ymin], [xmax, ymin], [xmax, ymax], [xmin, ymax], [xmin, ymin]]],
281
34
  }
282
35
  }
283
- return false
284
- }
285
36
 
286
- /**
287
- * Test if all points of a linestring are inside a polygon.
288
- *
289
- * @param {number[][]} line
290
- * @param {number[][][]} rings
291
- * @returns {boolean}
292
- */
293
- function lineInsidePolygon(line, rings) {
294
- for (const pt of line) {
295
- if (!pointInPolygon(pt, rings)) return false
296
- }
297
- // Also check that no segment crosses a hole boundary from inside to outside
298
- for (let i = 0; i < line.length - 1; i++) {
299
- const mid = [(line[i][0] + line[i + 1][0]) / 2, (line[i][1] + line[i + 1][1]) / 2]
300
- if (!pointInPolygon(mid, rings)) return false
37
+ const geomA = toGeometry(args[0])
38
+ if (funcName === 'ST_ASTEXT') {
39
+ if (geomA == null) return null
40
+ return geomToWkt(geomA)
301
41
  }
302
- return true
303
- }
304
42
 
305
- /**
306
- * Test if all points of a linestring are strictly in the polygon interior.
307
- *
308
- * @param {number[][]} line
309
- * @param {number[][][]} rings
310
- * @returns {boolean}
311
- */
312
- function lineInPolygonInterior(line, rings) {
313
- for (const pt of line) {
314
- if (!pointInPolygonInterior(pt, rings)) return false
315
- }
316
- for (let i = 0; i < line.length - 1; i++) {
317
- const mid = [(line[i][0] + line[i + 1][0]) / 2, (line[i][1] + line[i + 1][1]) / 2]
318
- if (!pointInPolygonInterior(mid, rings)) return false
319
- }
320
- return true
321
- }
322
-
323
- /**
324
- * Test if polygon A contains polygon B (all of B is inside A).
325
- *
326
- * @param {number[][][]} ringsA
327
- * @param {number[][][]} ringsB
328
- * @returns {boolean}
329
- */
330
- function polygonContainsPolygon(ringsA, ringsB) {
331
- // Every vertex of B's exterior must be inside A
332
- for (const pt of ringsB[0]) {
333
- if (!pointInPolygon(pt, ringsA)) return false
334
- }
335
- // Check that edges of B don't cross outside A
336
- for (let i = 0; i < ringsB[0].length - 1; i++) {
337
- const mid = [(ringsB[0][i][0] + ringsB[0][i + 1][0]) / 2, (ringsB[0][i][1] + ringsB[0][i + 1][1]) / 2]
338
- if (!pointInPolygon(mid, ringsA)) return false
339
- }
340
- return true
341
- }
43
+ // Predicate functions (require two geometries)
44
+ const geomB = toGeometry(args[1])
45
+ if (geomA == null || geomB == null) return null
46
+ const a = decompose(geomA)
47
+ const b = decompose(geomB)
342
48
 
343
- /**
344
- * Test if polygon A contains polygon B properly (no boundary contact).
345
- *
346
- * @param {number[][][]} ringsA
347
- * @param {number[][][]} ringsB
348
- * @returns {boolean}
349
- */
350
- function polygonContainsPolygonProperly(ringsA, ringsB) {
351
- for (const pt of ringsB[0]) {
352
- if (!pointInPolygonInterior(pt, ringsA)) return false
49
+ switch (funcName) {
50
+ case 'ST_INTERSECTS': return simpleIntersects(a, b)
51
+ case 'ST_CONTAINS': return stContains(a, b)
52
+ case 'ST_CONTAINSPROPERLY': return stContainsProperly(a, b)
53
+ case 'ST_WITHIN': return stContains(b, a) // inverse of contains
54
+ case 'ST_OVERLAPS': return stOverlaps(a, b)
55
+ case 'ST_TOUCHES': return stTouches(a, b)
56
+ case 'ST_EQUALS': return stEquals(a, b)
57
+ case 'ST_CROSSES': return stCrosses(a, b)
58
+ case 'ST_COVERS': return stContains(a, b) // TODO: handle boundary
59
+ case 'ST_COVEREDBY': return stContains(b, a) // inverse of covers
60
+ case 'ST_DWITHIN': {
61
+ if (args[2] == null) return null
62
+ const dist = Number(args[2])
63
+ return stDWithin(a, b, dist)
353
64
  }
354
- for (let i = 0; i < ringsB[0].length - 1; i++) {
355
- const mid = [(ringsB[0][i][0] + ringsB[0][i + 1][0]) / 2, (ringsB[0][i][1] + ringsB[0][i + 1][1]) / 2]
356
- if (!pointInPolygonInterior(mid, ringsA)) return false
65
+ default: return null
357
66
  }
358
- return true
359
67
  }
360
68
 
361
69
  /**
362
- * Test if two rings are equal (same vertices, possibly different starting point).
70
+ * Normalize a geometry value. Accepts GeoJSON objects.
71
+ * Returns null if the value is not a valid geometry.
363
72
  *
364
- * @param {number[][]} ring1
365
- * @param {number[][]} ring2
366
- * @returns {boolean}
73
+ * @param {SqlPrimitive} val
74
+ * @returns {Geometry | null}
367
75
  */
368
- function ringsEqual(ring1, ring2) {
369
- if (ring1.length !== ring2.length) return false
370
- // Try every rotation
371
- const n = ring1.length - 1 // closed ring, last = first
372
- for (let offset = 0; offset < n; offset++) {
373
- let match = true
374
- for (let i = 0; i < n; i++) {
375
- const j = (i + offset) % n
376
- if (Math.abs(ring1[i][0] - ring2[j][0]) > EPSILON ||
377
- Math.abs(ring1[i][1] - ring2[j][1]) > EPSILON) {
378
- match = false
379
- break
380
- }
76
+ function toGeometry(val) {
77
+ if (typeof val === 'object' && val != null && 'type' in val) {
78
+ if (val.type === 'GeometryCollection' && Array.isArray(val.geometries)) {
79
+ // eslint-disable-next-line no-extra-parens
80
+ return /** @type {Geometry} */ (val)
381
81
  }
382
- if (match) return true
383
- }
384
- // Try reverse direction
385
- for (let offset = 0; offset < n; offset++) {
386
- let match = true
387
- for (let i = 0; i < n; i++) {
388
- const j = (n - i + offset) % n
389
- if (Math.abs(ring1[i][0] - ring2[j][0]) > EPSILON ||
390
- Math.abs(ring1[i][1] - ring2[j][1]) > EPSILON) {
391
- match = false
392
- break
393
- }
82
+ const geometryTypes = ['Point', 'MultiPoint', 'LineString', 'MultiLineString', 'Polygon', 'MultiPolygon']
83
+ if (geometryTypes.includes(val.type) && Array.isArray(val.coordinates)) {
84
+ // eslint-disable-next-line no-extra-parens
85
+ return /** @type {Geometry} */ (val)
394
86
  }
395
- if (match) return true
396
87
  }
397
- return false
88
+ return null
398
89
  }
399
90
 
400
91
  // ============================================================================
401
92
  // Minimum distance between geometries
402
93
  // ============================================================================
403
94
 
404
- /**
405
- * Get all coordinates from a geometry as a flat list of [x, y] points.
406
- *
407
- * @param {GeoJsonGeometry} geom
408
- * @returns {number[][]}
409
- */
410
- function getPoints(geom) {
411
- switch (geom.type) {
412
- case 'Point': return [geom.coordinates]
413
- case 'MultiPoint': return geom.coordinates
414
- case 'LineString': return geom.coordinates
415
- case 'MultiLineString': return geom.coordinates.flat()
416
- case 'Polygon': return geom.coordinates[0]
417
- case 'MultiPolygon': return geom.coordinates.flatMap((/** @type {number[][][]} */ p) => p[0])
418
- case 'GeometryCollection': return geom.geometries.flatMap((/** @type {GeoJsonGeometry} */ g) => getPoints(g))
419
- default: return []
420
- }
421
- }
422
-
423
95
  /**
424
96
  * Get all line segments from a geometry.
425
97
  *
426
- * @param {GeoJsonGeometry} geom
427
- * @returns {Array<[number[], number[]]>}
98
+ * @param {SimpleGeometry[]} geoms
99
+ * @returns {{ segments: Array<[number[], number[]]>, points: number[][] }}
428
100
  */
429
- function getSegments(geom) {
101
+ function getSegments(geoms) {
430
102
  /** @type {Array<[number[], number[]]>} */
431
103
  const segments = []
104
+ /** @type {number[][]} */
105
+ const points = []
106
+
432
107
  /**
433
108
  * @param {number[][]} coords
434
109
  */
@@ -436,509 +111,130 @@ function getSegments(geom) {
436
111
  for (let i = 0; i < coords.length - 1; i++) {
437
112
  segments.push([coords[i], coords[i + 1]])
438
113
  }
114
+ points.push(...coords)
439
115
  }
440
- switch (geom.type) {
441
- case 'LineString': addLine(geom.coordinates); break
442
- case 'MultiLineString': geom.coordinates.forEach((/** @type {number[][]} */ l) => addLine(l)); break
443
- case 'Polygon': geom.coordinates.forEach((/** @type {number[][]} */ r) => addLine(r)); break
444
- case 'MultiPolygon': geom.coordinates.forEach((/** @type {number[][][]} */ p) => p.forEach((/** @type {number[][]} */ r) => addLine(r))); break
445
- case 'GeometryCollection': geom.geometries.forEach((/** @type {GeoJsonGeometry} */ g) => segments.push(...getSegments(g))); break
446
- }
447
- return segments
448
- }
449
-
450
- /**
451
- * Minimum distance between two segments.
452
- *
453
- * @param {number[]} a1
454
- * @param {number[]} a2
455
- * @param {number[]} b1
456
- * @param {number[]} b2
457
- * @returns {number}
458
- */
459
- function segmentToSegmentDist(a1, a2, b1, b2) {
460
- if (segmentsIntersect(a1, a2, b1, b2)) return 0
461
- return Math.min(
462
- pointToSegmentDist(a1, b1, b2),
463
- pointToSegmentDist(a2, b1, b2),
464
- pointToSegmentDist(b1, a1, a2),
465
- pointToSegmentDist(b2, a1, a2)
466
- )
467
- }
468
-
469
- /**
470
- * Compute the minimum distance between two geometries.
471
- *
472
- * @param {GeoJsonGeometry} a
473
- * @param {GeoJsonGeometry} b
474
- * @returns {number}
475
- */
476
- function geometryDistance(a, b) {
477
- // Handle geometries that contain each other
478
- if (a.type === 'Polygon' || a.type === 'MultiPolygon') {
479
- const pts = getPoints(b)
480
- for (const pt of pts) {
481
- if (a.type === 'Polygon' && pointInPolygon(pt, a.coordinates)) return 0
482
- if (a.type === 'MultiPolygon') {
483
- for (const poly of a.coordinates) {
484
- if (pointInPolygon(pt, poly)) return 0
485
- }
486
- }
487
- }
488
- }
489
- if (b.type === 'Polygon' || b.type === 'MultiPolygon') {
490
- const pts = getPoints(a)
491
- for (const pt of pts) {
492
- if (b.type === 'Polygon' && pointInPolygon(pt, b.coordinates)) return 0
493
- if (b.type === 'MultiPolygon') {
494
- for (const poly of b.coordinates) {
495
- if (pointInPolygon(pt, poly)) return 0
496
- }
497
- }
498
- }
499
- }
500
-
501
- const segsA = getSegments(a)
502
- const segsB = getSegments(b)
503
- const ptsA = getPoints(a)
504
- const ptsB = getPoints(b)
505
-
506
- let min = Infinity
507
116
 
508
- // Segment-to-segment
509
- for (const [a1, a2] of segsA) {
510
- for (const [b1, b2] of segsB) {
511
- min = Math.min(min, segmentToSegmentDist(a1, a2, b1, b2))
512
- }
117
+ for (const geom of geoms) {
118
+ if (geom.type === 'Point') points.push(geom.coordinates)
119
+ else if (geom.type === 'LineString') addLine(geom.coordinates)
120
+ else if (geom.type === 'Polygon') geom.coordinates.forEach(addLine)
513
121
  }
514
122
 
515
- // Point-to-segment
516
- if (segsB.length > 0) {
517
- for (const pt of ptsA) {
518
- for (const [b1, b2] of segsB) {
519
- min = Math.min(min, pointToSegmentDist(pt, b1, b2))
520
- }
521
- }
522
- }
523
- if (segsA.length > 0) {
524
- for (const pt of ptsB) {
525
- for (const [a1, a2] of segsA) {
526
- min = Math.min(min, pointToSegmentDist(pt, a1, a2))
527
- }
528
- }
529
- }
530
-
531
- // Point-to-point fallback
532
- if (segsA.length === 0 && segsB.length === 0) {
533
- for (const pa of ptsA) {
534
- for (const pb of ptsB) {
535
- min = Math.min(min, pointDist(pa, pb))
536
- }
537
- }
538
- }
539
-
540
- return min
123
+ return { segments, points }
541
124
  }
542
125
 
543
- // ============================================================================
544
- // Spatial predicate dispatch - decompose to primitive type pairs
545
- // ============================================================================
546
-
547
126
  /**
548
- * Decompose Multi* and GeometryCollection into simple geometries.
127
+ * Test whether two geometries are within a given distance of each other.
128
+ * Intersecting geometries have distance 0. For non-intersecting geometries,
129
+ * the minimum distance is always at an endpoint, so point-to-segment suffices.
549
130
  *
550
- * @param {GeoJsonGeometry} geom
551
- * @returns {GeoJsonGeometry[]}
552
- */
553
- function decompose(geom) {
554
- switch (geom.type) {
555
- case 'MultiPoint':
556
- return geom.coordinates.map((/** @type {number[]} */ c) => ({ type: 'Point', coordinates: c }))
557
- case 'MultiLineString':
558
- return geom.coordinates.map((/** @type {number[][]} */ c) => ({ type: 'LineString', coordinates: c }))
559
- case 'MultiPolygon':
560
- return geom.coordinates.map((/** @type {number[][][]} */ c) => ({ type: 'Polygon', coordinates: c }))
561
- case 'GeometryCollection':
562
- return geom.geometries.flatMap((/** @type {GeoJsonGeometry} */ g) => decompose(g))
563
- default:
564
- return [geom]
565
- }
566
- }
567
-
568
- // ============================================================================
569
- // ST_Intersects
570
- // ============================================================================
571
-
572
- /**
573
- * @param {GeoJsonGeometry} a
574
- * @param {GeoJsonGeometry} b
575
- * @returns {boolean}
576
- */
577
- function stIntersects(a, b) {
578
- const partsA = decompose(a)
579
- const partsB = decompose(b)
580
- for (const pa of partsA) {
581
- for (const pb of partsB) {
582
- if (simplePairIntersects(pa, pb)) return true
583
- }
584
- }
585
- return false
586
- }
587
-
588
- /**
589
- * @param {GeoJsonGeometry} a
590
- * @param {GeoJsonGeometry} b
591
- * @returns {boolean}
592
- */
593
- function simplePairIntersects(a, b) {
594
- const ta = a.type
595
- const tb = b.type
596
-
597
- if (ta === 'Point' && tb === 'Point') {
598
- return pointDist(a.coordinates, b.coordinates) < EPSILON
599
- }
600
- if (ta === 'Point' && tb === 'LineString') {
601
- return pointOnLine(a.coordinates, b.coordinates)
602
- }
603
- if (ta === 'LineString' && tb === 'Point') {
604
- return pointOnLine(b.coordinates, a.coordinates)
605
- }
606
- if (ta === 'Point' && tb === 'Polygon') {
607
- return pointInPolygon(a.coordinates, b.coordinates)
608
- }
609
- if (ta === 'Polygon' && tb === 'Point') {
610
- return pointInPolygon(b.coordinates, a.coordinates)
611
- }
612
- if (ta === 'LineString' && tb === 'LineString') {
613
- return linesIntersect(a.coordinates, b.coordinates)
614
- }
615
- if (ta === 'LineString' && tb === 'Polygon') {
616
- return lineIntersectsPolygon(a.coordinates, b.coordinates)
617
- }
618
- if (ta === 'Polygon' && tb === 'LineString') {
619
- return lineIntersectsPolygon(b.coordinates, a.coordinates)
620
- }
621
- if (ta === 'Polygon' && tb === 'Polygon') {
622
- return polygonsIntersect(a.coordinates, b.coordinates)
623
- }
624
- return false
625
- }
626
-
627
- /**
628
- * Test if point is on a linestring.
629
- *
630
- * @param {number[]} point
631
- * @param {number[][]} line
632
- * @returns {boolean}
633
- */
634
- function pointOnLine(point, line) {
635
- for (let i = 0; i < line.length - 1; i++) {
636
- if (pointToSegmentDist(point, line[i], line[i + 1]) < EPSILON) return true
637
- }
638
- return false
639
- }
640
-
641
- // ============================================================================
642
- // ST_Contains
643
- // ============================================================================
644
-
645
- /**
646
- * @param {GeoJsonGeometry} a
647
- * @param {GeoJsonGeometry} b
648
- * @returns {boolean}
649
- */
650
- function stContains(a, b) {
651
- // Every part of b must be inside some part of a
652
- const partsB = decompose(b)
653
- for (const pb of partsB) {
654
- if (!simpleContainedByAny(a, pb)) return false
655
- }
656
- return true
657
- }
658
-
659
- /**
660
- * @param {GeoJsonGeometry} a
661
- * @param {GeoJsonGeometry} b - simple geometry
662
- * @returns {boolean}
663
- */
664
- function simpleContainedByAny(a, b) {
665
- const partsA = decompose(a)
666
- for (const pa of partsA) {
667
- if (simplePairContains(pa, b)) return true
668
- }
669
- return false
670
- }
671
-
672
- /**
673
- * @param {GeoJsonGeometry} a
674
- * @param {GeoJsonGeometry} b
675
- * @returns {boolean}
676
- */
677
- function simplePairContains(a, b) {
678
- const ta = a.type
679
- const tb = b.type
680
-
681
- if (ta === 'Point' && tb === 'Point') {
682
- return pointDist(a.coordinates, b.coordinates) < EPSILON
683
- }
684
- if (ta === 'LineString' && tb === 'Point') {
685
- return pointOnLine(b.coordinates, a.coordinates)
686
- }
687
- if (ta === 'Polygon' && tb === 'Point') {
688
- return pointInPolygon(b.coordinates, a.coordinates)
689
- }
690
- if (ta === 'Polygon' && tb === 'LineString') {
691
- return lineInsidePolygon(b.coordinates, a.coordinates)
692
- }
693
- if (ta === 'Polygon' && tb === 'Polygon') {
694
- return polygonContainsPolygon(a.coordinates, b.coordinates)
695
- }
696
- if (ta === 'LineString' && tb === 'LineString') {
697
- // Line A contains line B if every point of B is on A
698
- for (const pt of b.coordinates) {
699
- if (!pointOnLine(pt, a.coordinates)) return false
700
- }
701
- return true
702
- }
703
- return false
704
- }
705
-
706
- // ============================================================================
707
- // ST_ContainsProperly
708
- // ============================================================================
709
-
710
- /**
711
- * @param {GeoJsonGeometry} a
712
- * @param {GeoJsonGeometry} b
713
- * @returns {boolean}
714
- */
715
- function stContainsProperly(a, b) {
716
- const partsB = decompose(b)
717
- for (const pb of partsB) {
718
- if (!simpleContainedByAnyProperly(a, pb)) return false
719
- }
720
- return true
721
- }
722
-
723
- /**
724
- * @param {GeoJsonGeometry} a
725
- * @param {GeoJsonGeometry} b
726
- * @returns {boolean}
727
- */
728
- function simpleContainedByAnyProperly(a, b) {
729
- const partsA = decompose(a)
730
- for (const pa of partsA) {
731
- if (simplePairContainsProperly(pa, b)) return true
732
- }
733
- return false
734
- }
735
-
736
- /**
737
- * @param {GeoJsonGeometry} a
738
- * @param {GeoJsonGeometry} b
739
- * @returns {boolean}
740
- */
741
- function simplePairContainsProperly(a, b) {
742
- const ta = a.type
743
- const tb = b.type
744
-
745
- if (ta === 'Polygon' && tb === 'Point') {
746
- return pointInPolygonInterior(b.coordinates, a.coordinates)
747
- }
748
- if (ta === 'Polygon' && tb === 'LineString') {
749
- return lineInPolygonInterior(b.coordinates, a.coordinates)
750
- }
751
- if (ta === 'Polygon' && tb === 'Polygon') {
752
- return polygonContainsPolygonProperly(a.coordinates, b.coordinates)
753
- }
754
- // Points and lines have no interior in the topological sense for "contains properly"
755
- return false
756
- }
757
-
758
- // ============================================================================
759
- // ST_Within (inverse of ST_Contains)
760
- // ============================================================================
761
-
762
- /**
763
- * @param {GeoJsonGeometry} a
764
- * @param {GeoJsonGeometry} b
765
- * @returns {boolean}
766
- */
767
- function stWithin(a, b) {
768
- return stContains(b, a)
769
- }
770
-
771
- // ============================================================================
772
- // ST_Touches
773
- // ============================================================================
774
-
775
- /**
776
- * @param {GeoJsonGeometry} a
777
- * @param {GeoJsonGeometry} b
778
- * @returns {boolean}
779
- */
780
- function stTouches(a, b) {
781
- // Geometries touch if they intersect but their interiors do not
782
- if (!stIntersects(a, b)) return false
783
-
784
- const partsA = decompose(a)
785
- const partsB = decompose(b)
786
- for (const pa of partsA) {
787
- for (const pb of partsB) {
788
- if (simplePairInteriorsIntersect(pa, pb)) return false
789
- }
790
- }
791
- return true
792
- }
793
-
794
- /**
795
- * Test if interiors of two simple geometries share any point.
796
- *
797
- * @param {GeoJsonGeometry} a
798
- * @param {GeoJsonGeometry} b
131
+ * @param {SimpleGeometry[]} a
132
+ * @param {SimpleGeometry[]} b
133
+ * @param {number} distance
799
134
  * @returns {boolean}
800
135
  */
801
- function simplePairInteriorsIntersect(a, b) {
802
- const ta = a.type
803
- const tb = b.type
804
-
805
- if (ta === 'Point' && tb === 'Point') {
806
- // Points have no interior dimension per se, but two equal points "touch" is not
807
- // standard. By definition, ST_Touches returns false for Point/Point.
808
- return true // If they intersect at all, their "interiors" intersect
809
- }
810
- if (ta === 'Point' && tb === 'LineString') {
811
- // Interior of a linestring excludes endpoints
812
- return pointInLineInterior(a.coordinates, b.coordinates)
813
- }
814
- if (ta === 'LineString' && tb === 'Point') {
815
- return pointInLineInterior(b.coordinates, a.coordinates)
816
- }
817
- if (ta === 'Point' && tb === 'Polygon') {
818
- return pointInPolygonInterior(a.coordinates, b.coordinates)
819
- }
820
- if (ta === 'Polygon' && tb === 'Point') {
821
- return pointInPolygonInterior(b.coordinates, a.coordinates)
822
- }
823
- if (ta === 'LineString' && tb === 'LineString') {
824
- // Check if lines share interior points (not just endpoints)
825
- return linesShareInterior(a.coordinates, b.coordinates)
826
- }
827
- if (ta === 'LineString' && tb === 'Polygon') {
828
- return lineInteriorIntersectsPolygonInterior(a.coordinates, b.coordinates)
136
+ function stDWithin(a, b, distance) {
137
+ if (distance < 0) return false
138
+ if (simpleIntersects(a, b)) return true
139
+
140
+ const distanceSq = distance * distance
141
+ const { points: ptsA, segments: segsA } = getSegments(a)
142
+ const { points: ptsB, segments: segsB } = getSegments(b)
143
+
144
+ // Point-to-point
145
+ for (const pa of ptsA) {
146
+ for (const pb of ptsB) {
147
+ if (distSq(pa, pb) <= distanceSq) return true
148
+ }
829
149
  }
830
- if (ta === 'Polygon' && tb === 'LineString') {
831
- return lineInteriorIntersectsPolygonInterior(b.coordinates, a.coordinates)
150
+
151
+ // Point-to-segment
152
+ for (const pt of ptsA) {
153
+ for (const [b1, b2] of segsB) {
154
+ if (pointToSegmentDistSq(pt, b1, b2) <= distanceSq) return true
155
+ }
832
156
  }
833
- if (ta === 'Polygon' && tb === 'Polygon') {
834
- return polygonInteriorsIntersect(a.coordinates, b.coordinates)
157
+ for (const pt of ptsB) {
158
+ for (const [a1, a2] of segsA) {
159
+ if (pointToSegmentDistSq(pt, a1, a2) <= distanceSq) return true
160
+ }
835
161
  }
162
+
836
163
  return false
837
164
  }
838
165
 
839
- /**
840
- * @param {number[]} point
841
- * @param {number[][]} line
842
- * @returns {boolean}
843
- */
844
- function pointInLineInterior(point, line) {
845
- // Interior of line excludes endpoints
846
- if (pointDist(point, line[0]) < EPSILON) return false
847
- if (pointDist(point, line[line.length - 1]) < EPSILON) return false
848
- return pointOnLine(point, line)
849
- }
166
+ // ============================================================================
167
+ // Spatial predicate dispatch - decompose to primitive type pairs
168
+ // ============================================================================
850
169
 
851
170
  /**
852
- * @param {number[][]} line1
853
- * @param {number[][]} line2
854
- * @returns {boolean}
171
+ * Decompose Multi* and GeometryCollection into simple geometries.
172
+ *
173
+ * @param {Geometry} geom
174
+ * @returns {SimpleGeometry[]}
855
175
  */
856
- function linesShareInterior(line1, line2) {
857
- // Check if any interior point of one line lies on the other line's interior
858
- for (let i = 0; i < line1.length - 1; i++) {
859
- for (let j = 0; j < line2.length - 1; j++) {
860
- if (segmentsIntersect(line1[i], line1[i + 1], line2[j], line2[j + 1])) {
861
- // Find the intersection point and check if it's interior to both
862
- // For simplicity, check segment midpoints
863
- const mid1 = [(line1[i][0] + line1[i + 1][0]) / 2, (line1[i][1] + line1[i + 1][1]) / 2]
864
- if (pointOnLine(mid1, line2) && pointInLineInterior(mid1, line1) && pointInLineInterior(mid1, line2)) {
865
- return true
866
- }
867
- const mid2 = [(line2[j][0] + line2[j + 1][0]) / 2, (line2[j][1] + line2[j + 1][1]) / 2]
868
- if (pointOnLine(mid2, line1) && pointInLineInterior(mid2, line1) && pointInLineInterior(mid2, line2)) {
869
- return true
870
- }
871
- // Also check the actual intersection point
872
- const ip = segmentIntersectionPoint(line1[i], line1[i + 1], line2[j], line2[j + 1])
873
- if (ip && pointInLineInterior(ip, line1) && pointInLineInterior(ip, line2)) {
874
- return true
875
- }
876
- }
877
- }
176
+ function decompose(geom) {
177
+ switch (geom.type) {
178
+ case 'MultiPoint':
179
+ return geom.coordinates.map(c => ({ type: 'Point', coordinates: c }))
180
+ case 'MultiLineString':
181
+ return geom.coordinates.map(c => ({ type: 'LineString', coordinates: c }))
182
+ case 'MultiPolygon':
183
+ return geom.coordinates.map(c => ({ type: 'Polygon', coordinates: c }))
184
+ case 'GeometryCollection':
185
+ return geom.geometries.flatMap(decompose)
186
+ default:
187
+ return [geom]
878
188
  }
879
- return false
880
189
  }
881
190
 
191
+ // ============================================================================
192
+ // ST_Contains
193
+ // ============================================================================
194
+
882
195
  /**
883
- * Compute intersection point of two segments (if they intersect at a single point).
884
- *
885
- * @param {number[]} p1
886
- * @param {number[]} p2
887
- * @param {number[]} p3
888
- * @param {number[]} p4
889
- * @returns {number[] | null}
196
+ * @param {SimpleGeometry[]} a
197
+ * @param {SimpleGeometry[]} b
198
+ * @returns {boolean}
890
199
  */
891
- function segmentIntersectionPoint(p1, p2, p3, p4) {
892
- const d1x = p2[0] - p1[0], d1y = p2[1] - p1[1]
893
- const d2x = p4[0] - p3[0], d2y = p4[1] - p3[1]
894
- const denom = d1x * d2y - d1y * d2x
895
- if (Math.abs(denom) < EPSILON) return null // parallel
896
- const t = ((p3[0] - p1[0]) * d2y - (p3[1] - p1[1]) * d2x) / denom
897
- return [p1[0] + t * d1x, p1[1] + t * d1y]
200
+ function stContains(a, b) {
201
+ // Every part of b must be inside some part of a
202
+ return b.every(pb => a.some(pa => simplePairContainment(pa, pb) !== 'OUTSIDE'))
898
203
  }
899
204
 
205
+ // ============================================================================
206
+ // ST_ContainsProperly
207
+ // ============================================================================
208
+
900
209
  /**
901
- * @param {number[][]} line
902
- * @param {number[][][]} rings
210
+ * @param {SimpleGeometry[]} a
211
+ * @param {SimpleGeometry[]} b
903
212
  * @returns {boolean}
904
213
  */
905
- function lineInteriorIntersectsPolygonInterior(line, rings) {
906
- // Check if any interior point of the line is inside the polygon interior
907
- for (let i = 0; i < line.length - 1; i++) {
908
- const mid = [(line[i][0] + line[i + 1][0]) / 2, (line[i][1] + line[i + 1][1]) / 2]
909
- if (pointInPolygonInterior(mid, rings)) return true
910
- }
911
- // Also check interior points of the line
912
- for (let i = 1; i < line.length - 1; i++) {
913
- if (pointInPolygonInterior(line[i], rings)) return true
914
- }
915
- return false
214
+ function stContainsProperly(a, b) {
215
+ // Every part of b must be strictly inside some part of a
216
+ return b.every(pb => a.some(pa => simplePairContainment(pa, pb) === 'INSIDE'))
916
217
  }
917
218
 
219
+ // ============================================================================
220
+ // ST_Touches
221
+ // ============================================================================
222
+
918
223
  /**
919
- * @param {number[][][]} rings1
920
- * @param {number[][][]} rings2
224
+ * @param {SimpleGeometry[]} a
225
+ * @param {SimpleGeometry[]} b
921
226
  * @returns {boolean}
922
227
  */
923
- function polygonInteriorsIntersect(rings1, rings2) {
924
- // Check if any vertex of polygon1 is inside polygon2's interior
925
- for (const pt of rings1[0]) {
926
- if (pointInPolygonInterior(pt, rings2)) return true
927
- }
928
- // Check if any vertex of polygon2 is inside polygon1's interior
929
- for (const pt of rings2[0]) {
930
- if (pointInPolygonInterior(pt, rings1)) return true
931
- }
932
- // Check if any edge midpoints are inside the other polygon's interior
933
- for (let i = 0; i < rings1[0].length - 1; i++) {
934
- const mid = [(rings1[0][i][0] + rings1[0][i + 1][0]) / 2, (rings1[0][i][1] + rings1[0][i + 1][1]) / 2]
935
- if (pointInPolygonInterior(mid, rings2)) return true
936
- }
937
- for (let i = 0; i < rings2[0].length - 1; i++) {
938
- const mid = [(rings2[0][i][0] + rings2[0][i + 1][0]) / 2, (rings2[0][i][1] + rings2[0][i + 1][1]) / 2]
939
- if (pointInPolygonInterior(mid, rings1)) return true
228
+ function stTouches(a, b) {
229
+ let intersects = false
230
+ for (const pa of a) {
231
+ for (const pb of b) {
232
+ const rel = simplePairRelation(pa, pb)
233
+ if (rel === 'INSIDE') return false
234
+ if (rel === 'BOUNDARY') intersects = true
235
+ }
940
236
  }
941
- return false
237
+ return intersects
942
238
  }
943
239
 
944
240
  // ============================================================================
@@ -946,8 +242,8 @@ function polygonInteriorsIntersect(rings1, rings2) {
946
242
  // ============================================================================
947
243
 
948
244
  /**
949
- * @param {GeoJsonGeometry} a
950
- * @param {GeoJsonGeometry} b
245
+ * @param {SimpleGeometry[]} a
246
+ * @param {SimpleGeometry[]} b
951
247
  * @returns {boolean}
952
248
  */
953
249
  function stOverlaps(a, b) {
@@ -956,7 +252,7 @@ function stOverlaps(a, b) {
956
252
  const dimA = geometryDimension(a)
957
253
  const dimB = geometryDimension(b)
958
254
  if (dimA !== dimB) return false
959
- if (!stIntersects(a, b)) return false
255
+ if (!simpleIntersects(a, b)) return false
960
256
  if (stEquals(a, b)) return false
961
257
  // Must not be containment
962
258
  if (stContains(a, b) || stContains(b, a)) return false
@@ -964,30 +260,23 @@ function stOverlaps(a, b) {
964
260
  }
965
261
 
966
262
  /**
967
- * @param {GeoJsonGeometry} geom
263
+ * @param {SimpleGeometry[]} parts
968
264
  * @returns {number}
969
265
  */
970
- function geometryDimension(geom) {
971
- switch (geom.type) {
972
- case 'Point':
973
- case 'MultiPoint':
974
- return 0
975
- case 'LineString':
976
- case 'MultiLineString':
977
- return 1
978
- case 'Polygon':
979
- case 'MultiPolygon':
980
- return 2
981
- case 'GeometryCollection': {
982
- let max = 0
983
- for (const g of geom.geometries) {
984
- max = Math.max(max, geometryDimension(g))
266
+ function geometryDimension(parts) {
267
+ let max = 0
268
+ for (const geom of parts) {
269
+ switch (geom.type) {
270
+ case 'Point':
271
+ break
272
+ case 'LineString':
273
+ if (max < 1) max = 1
274
+ break
275
+ case 'Polygon':
276
+ return 2
985
277
  }
986
- return max
987
- }
988
- default:
989
- return 0
990
278
  }
279
+ return max
991
280
  }
992
281
 
993
282
  // ============================================================================
@@ -995,23 +284,20 @@ function geometryDimension(geom) {
995
284
  // ============================================================================
996
285
 
997
286
  /**
998
- * @param {GeoJsonGeometry} a
999
- * @param {GeoJsonGeometry} b
287
+ * @param {SimpleGeometry[]} a
288
+ * @param {SimpleGeometry[]} b
1000
289
  * @returns {boolean}
1001
290
  */
1002
291
  function stEquals(a, b) {
1003
- const partsA = decompose(a)
1004
- const partsB = decompose(b)
1005
-
1006
- if (partsA.length !== partsB.length) return false
292
+ if (a.length !== b.length) return false
1007
293
 
1008
- // For each simple geometry in A, find a matching one in B
294
+ // For each simple geometry in a, find a matching one in b
1009
295
  const used = new Set()
1010
- for (const pa of partsA) {
296
+ for (const pa of a) {
1011
297
  let found = false
1012
- for (let i = 0; i < partsB.length; i++) {
298
+ for (let i = 0; i < b.length; i++) {
1013
299
  if (used.has(i)) continue
1014
- if (simpleGeomEqual(pa, partsB[i])) {
300
+ if (simpleGeomEqual(pa, b[i])) {
1015
301
  used.add(i)
1016
302
  found = true
1017
303
  break
@@ -1022,70 +308,13 @@ function stEquals(a, b) {
1022
308
  return true
1023
309
  }
1024
310
 
1025
- /**
1026
- * @param {GeoJsonGeometry} a
1027
- * @param {GeoJsonGeometry} b
1028
- * @returns {boolean}
1029
- */
1030
- function simpleGeomEqual(a, b) {
1031
- if (a.type !== b.type) return false
1032
- switch (a.type) {
1033
- case 'Point':
1034
- return pointDist(a.coordinates, b.coordinates) < EPSILON
1035
- case 'LineString':
1036
- return lineStringEqual(a.coordinates, b.coordinates)
1037
- case 'Polygon':
1038
- return polygonEqual(a.coordinates, b.coordinates)
1039
- default:
1040
- return false
1041
- }
1042
- }
1043
-
1044
- /**
1045
- * @param {number[][]} a
1046
- * @param {number[][]} b
1047
- * @returns {boolean}
1048
- */
1049
- function lineStringEqual(a, b) {
1050
- if (a.length !== b.length) return false
1051
- // Forward
1052
- let forward = true
1053
- for (let i = 0; i < a.length; i++) {
1054
- if (Math.abs(a[i][0] - b[i][0]) > EPSILON || Math.abs(a[i][1] - b[i][1]) > EPSILON) {
1055
- forward = false
1056
- break
1057
- }
1058
- }
1059
- if (forward) return true
1060
- // Reverse
1061
- for (let i = 0; i < a.length; i++) {
1062
- if (Math.abs(a[i][0] - b[a.length - 1 - i][0]) > EPSILON || Math.abs(a[i][1] - b[a.length - 1 - i][1]) > EPSILON) {
1063
- return false
1064
- }
1065
- }
1066
- return true
1067
- }
1068
-
1069
- /**
1070
- * @param {number[][][]} a
1071
- * @param {number[][][]} b
1072
- * @returns {boolean}
1073
- */
1074
- function polygonEqual(a, b) {
1075
- if (a.length !== b.length) return false
1076
- for (let i = 0; i < a.length; i++) {
1077
- if (!ringsEqual(a[i], b[i])) return false
1078
- }
1079
- return true
1080
- }
1081
-
1082
311
  // ============================================================================
1083
312
  // ST_Crosses
1084
313
  // ============================================================================
1085
314
 
1086
315
  /**
1087
- * @param {GeoJsonGeometry} a
1088
- * @param {GeoJsonGeometry} b
316
+ * @param {SimpleGeometry[]} a
317
+ * @param {SimpleGeometry[]} b
1089
318
  * @returns {boolean}
1090
319
  */
1091
320
  function stCrosses(a, b) {
@@ -1094,7 +323,7 @@ function stCrosses(a, b) {
1094
323
  const dimA = geometryDimension(a)
1095
324
  const dimB = geometryDimension(b)
1096
325
 
1097
- if (!stIntersects(a, b)) return false
326
+ if (!simpleIntersects(a, b)) return false
1098
327
 
1099
328
  // Point/Point or Polygon/Polygon cannot cross
1100
329
  if (dimA === dimB && dimA !== 1) return false
@@ -1103,23 +332,20 @@ function stCrosses(a, b) {
1103
332
  if (dimA === 1 && dimB === 1) {
1104
333
  // They cross if they intersect but neither contains the other
1105
334
  // and the intersection is a set of points (not line segments)
1106
- return stIntersects(a, b) && !stContains(a, b) && !stContains(b, a) && !stTouches(a, b)
335
+ return !stContains(a, b) && !stContains(b, a) && !stTouches(a, b)
1107
336
  }
1108
337
 
1109
338
  // Point/Line, Point/Polygon: point "in interior"
1110
339
  if (dimA === 0 && dimB >= 1) {
1111
- const partsA = decompose(a)
1112
- for (const pa of partsA) {
1113
- if (b.type === 'LineString' || b.type === 'MultiLineString') {
1114
- const partsB = decompose(b)
1115
- for (const pb of partsB) {
1116
- if (pointInLineInterior(pa.coordinates, pb.coordinates)) return true
340
+ for (const pa of a) {
341
+ // eslint-disable-next-line no-extra-parens
342
+ const point = /** @type {Point} */ (pa)
343
+ for (const pb of b) {
344
+ if (pb.type === 'LineString') {
345
+ if (pointLineRelation(point.coordinates, pb.coordinates) === 'INSIDE') return true
1117
346
  }
1118
- }
1119
- if (b.type === 'Polygon' || b.type === 'MultiPolygon') {
1120
- const partsB = decompose(b)
1121
- for (const pb of partsB) {
1122
- if (pointInPolygonInterior(pa.coordinates, pb.coordinates)) return true
347
+ if (pb.type === 'Polygon') {
348
+ if (pointInPolygon(point.coordinates, pb.coordinates) === 'INSIDE') return true
1123
349
  }
1124
350
  }
1125
351
  }
@@ -1128,7 +354,7 @@ function stCrosses(a, b) {
1128
354
 
1129
355
  // Line/Polygon: line crosses polygon if part of line is inside and part is outside
1130
356
  if (dimA === 1 && dimB === 2) {
1131
- return stIntersects(a, b) && !stContains(b, a)
357
+ return !stContains(b, a)
1132
358
  }
1133
359
 
1134
360
  // Symmetric cases
@@ -1137,335 +363,3 @@ function stCrosses(a, b) {
1137
363
 
1138
364
  return false
1139
365
  }
1140
-
1141
- // ============================================================================
1142
- // ST_Covers / ST_CoveredBy
1143
- // ============================================================================
1144
-
1145
- /**
1146
- * ST_Covers: a covers b if no point of b is outside a.
1147
- * Similar to ST_Contains but allows b to be on boundary.
1148
- *
1149
- * @param {GeoJsonGeometry} a
1150
- * @param {GeoJsonGeometry} b
1151
- * @returns {boolean}
1152
- */
1153
- function stCovers(a, b) {
1154
- // ST_Covers is the same as ST_Contains for most purposes
1155
- // The difference is subtle in edge cases with boundaries
1156
- return stContains(a, b)
1157
- }
1158
-
1159
- /**
1160
- * @param {GeoJsonGeometry} a
1161
- * @param {GeoJsonGeometry} b
1162
- * @returns {boolean}
1163
- */
1164
- function stCoveredBy(a, b) {
1165
- return stCovers(b, a)
1166
- }
1167
-
1168
- // ============================================================================
1169
- // ST_DWithin
1170
- // ============================================================================
1171
-
1172
- /**
1173
- * @param {GeoJsonGeometry} a
1174
- * @param {GeoJsonGeometry} b
1175
- * @param {number} distance
1176
- * @returns {boolean}
1177
- */
1178
- function stDWithin(a, b, distance) {
1179
- return geometryDistance(a, b) <= distance
1180
- }
1181
-
1182
- // ============================================================================
1183
- // WKT parsing (ST_GeomFromText)
1184
- // ============================================================================
1185
-
1186
- /**
1187
- * Parse a WKT string into a GeoJSON geometry.
1188
- *
1189
- * @param {string} wkt
1190
- * @returns {GeoJsonGeometry | null}
1191
- */
1192
- function parseWkt(wkt) {
1193
- const s = wkt.trim()
1194
- const upper = s.toUpperCase()
1195
-
1196
- if (upper.startsWith('POINT')) {
1197
- const coords = parseWktCoordinate(s.slice(5).trim())
1198
- if (!coords) return null
1199
- return { type: 'Point', coordinates: coords }
1200
- }
1201
-
1202
- if (upper.startsWith('MULTIPOINT')) {
1203
- const inner = extractParens(s.slice(10).trim())
1204
- if (inner == null) return null
1205
- const coords = parseWktCoordinateList(inner)
1206
- if (!coords) return null
1207
- return { type: 'MultiPoint', coordinates: coords }
1208
- }
1209
-
1210
- if (upper.startsWith('MULTILINESTRING')) {
1211
- const inner = extractParens(s.slice(15).trim())
1212
- if (inner == null) return null
1213
- const rings = parseWktRingList(inner)
1214
- if (!rings) return null
1215
- return { type: 'MultiLineString', coordinates: rings }
1216
- }
1217
-
1218
- if (upper.startsWith('MULTIPOLYGON')) {
1219
- const inner = extractParens(s.slice(12).trim())
1220
- if (inner == null) return null
1221
- const polys = parseWktPolygonList(inner)
1222
- if (!polys) return null
1223
- return { type: 'MultiPolygon', coordinates: polys }
1224
- }
1225
-
1226
- if (upper.startsWith('LINESTRING')) {
1227
- const inner = extractParens(s.slice(10).trim())
1228
- if (inner == null) return null
1229
- const coords = parseWktCoordinateList(inner)
1230
- if (!coords) return null
1231
- return { type: 'LineString', coordinates: coords }
1232
- }
1233
-
1234
- if (upper.startsWith('POLYGON')) {
1235
- const inner = extractParens(s.slice(7).trim())
1236
- if (inner == null) return null
1237
- const rings = parseWktRingList(inner)
1238
- if (!rings) return null
1239
- return { type: 'Polygon', coordinates: rings }
1240
- }
1241
-
1242
- return null
1243
- }
1244
-
1245
- /**
1246
- * Extract content inside outer parentheses.
1247
- *
1248
- * @param {string} s
1249
- * @returns {string | null}
1250
- */
1251
- function extractParens(s) {
1252
- const trimmed = s.trim()
1253
- if (!trimmed.startsWith('(') || !trimmed.endsWith(')')) return null
1254
- return trimmed.slice(1, -1).trim()
1255
- }
1256
-
1257
- /**
1258
- * Parse a single coordinate like "(1 2)" or "1 2".
1259
- *
1260
- * @param {string} s
1261
- * @returns {number[] | null}
1262
- */
1263
- function parseWktCoordinate(s) {
1264
- const inner = s.trim().replace(/^\(/, '').replace(/\)$/, '').trim()
1265
- const parts = inner.split(/\s+/)
1266
- if (parts.length < 2) return null
1267
- const x = Number(parts[0])
1268
- const y = Number(parts[1])
1269
- if (!Number.isFinite(x) || !Number.isFinite(y)) return null
1270
- return [x, y]
1271
- }
1272
-
1273
- /**
1274
- * Parse a comma-separated list of coordinates like "1 2, 3 4, 5 6".
1275
- *
1276
- * @param {string} s
1277
- * @returns {number[][] | null}
1278
- */
1279
- function parseWktCoordinateList(s) {
1280
- const parts = s.split(',')
1281
- /** @type {number[][]} */
1282
- const coords = []
1283
- for (const part of parts) {
1284
- const trimmed = part.trim().replace(/^\(/, '').replace(/\)$/, '').trim()
1285
- const nums = trimmed.split(/\s+/)
1286
- if (nums.length < 2) return null
1287
- const x = Number(nums[0])
1288
- const y = Number(nums[1])
1289
- if (!Number.isFinite(x) || !Number.isFinite(y)) return null
1290
- coords.push([x, y])
1291
- }
1292
- return coords.length > 0 ? coords : null
1293
- }
1294
-
1295
- /**
1296
- * Parse a list of rings like "(1 2, 3 4), (5 6, 7 8)".
1297
- *
1298
- * @param {string} s
1299
- * @returns {number[][][] | null}
1300
- */
1301
- function parseWktRingList(s) {
1302
- /** @type {number[][][]} */
1303
- const rings = []
1304
- const ringStrs = splitTopLevel(s)
1305
- for (const ringStr of ringStrs) {
1306
- const inner = extractParens(ringStr.trim())
1307
- if (inner == null) return null
1308
- const coords = parseWktCoordinateList(inner)
1309
- if (!coords) return null
1310
- rings.push(coords)
1311
- }
1312
- return rings.length > 0 ? rings : null
1313
- }
1314
-
1315
- /**
1316
- * Parse a list of polygons like "((ring1), (ring2)), ((ring3))".
1317
- *
1318
- * @param {string} s
1319
- * @returns {number[][][][] | null}
1320
- */
1321
- function parseWktPolygonList(s) {
1322
- /** @type {number[][][][]} */
1323
- const polys = []
1324
- const polyStrs = splitTopLevel(s)
1325
- for (const polyStr of polyStrs) {
1326
- const inner = extractParens(polyStr.trim())
1327
- if (inner == null) return null
1328
- const rings = parseWktRingList(inner)
1329
- if (!rings) return null
1330
- polys.push(rings)
1331
- }
1332
- return polys.length > 0 ? polys : null
1333
- }
1334
-
1335
- /**
1336
- * Split a string by commas at the top-level (not inside parentheses).
1337
- *
1338
- * @param {string} s
1339
- * @returns {string[]}
1340
- */
1341
- function splitTopLevel(s) {
1342
- /** @type {string[]} */
1343
- const parts = []
1344
- let depth = 0
1345
- let start = 0
1346
- for (let i = 0; i < s.length; i++) {
1347
- if (s[i] === '(') depth++
1348
- else if (s[i] === ')') depth--
1349
- else if (s[i] === ',' && depth === 0) {
1350
- parts.push(s.slice(start, i))
1351
- start = i + 1
1352
- }
1353
- }
1354
- parts.push(s.slice(start))
1355
- return parts
1356
- }
1357
-
1358
- // ============================================================================
1359
- // WKT serialization (ST_AsText)
1360
- // ============================================================================
1361
-
1362
- /**
1363
- * Convert a GeoJSON geometry to WKT.
1364
- *
1365
- * @param {GeoJsonGeometry} geom
1366
- * @returns {string}
1367
- */
1368
- function geomToWkt(geom) {
1369
- switch (geom.type) {
1370
- case 'Point':
1371
- return `POINT (${coordToWkt(geom.coordinates)})`
1372
- case 'MultiPoint':
1373
- return `MULTIPOINT (${geom.coordinates.map((/** @type {number[]} */ c) => `(${coordToWkt(c)})`).join(', ')})`
1374
- case 'LineString':
1375
- return `LINESTRING (${coordListToWkt(geom.coordinates)})`
1376
- case 'MultiLineString':
1377
- return `MULTILINESTRING (${geom.coordinates.map((/** @type {number[][]} */ l) => `(${coordListToWkt(l)})`).join(', ')})`
1378
- case 'Polygon':
1379
- return `POLYGON (${geom.coordinates.map((/** @type {number[][]} */ r) => `(${coordListToWkt(r)})`).join(', ')})`
1380
- case 'MultiPolygon':
1381
- return `MULTIPOLYGON (${geom.coordinates.map((/** @type {number[][][]} */ p) => `(${p.map((/** @type {number[][]} */ r) => `(${coordListToWkt(r)})`).join(', ')})`).join(', ')})`
1382
- case 'GeometryCollection':
1383
- return `GEOMETRYCOLLECTION (${(geom.geometries || []).map((/** @type {GeoJsonGeometry} */ g) => geomToWkt(g)).join(', ')})`
1384
- default:
1385
- return ''
1386
- }
1387
- }
1388
-
1389
- /**
1390
- * Format a single coordinate to WKT.
1391
- *
1392
- * @param {number[]} coord
1393
- * @returns {string}
1394
- */
1395
- function coordToWkt(coord) {
1396
- return `${coord[0]} ${coord[1]}`
1397
- }
1398
-
1399
- /**
1400
- * Format a coordinate list to WKT.
1401
- *
1402
- * @param {number[][]} coords
1403
- * @returns {string}
1404
- */
1405
- function coordListToWkt(coords) {
1406
- return coords.map(c => `${c[0]} ${c[1]}`).join(', ')
1407
- }
1408
-
1409
- // ============================================================================
1410
- // Public API
1411
- // ============================================================================
1412
-
1413
- /**
1414
- * Evaluate a spatial predicate function.
1415
- *
1416
- * @param {Object} options
1417
- * @param {SpatialFunc} options.funcName
1418
- * @param {SqlPrimitive[]} options.args
1419
- * @returns {SqlPrimitive}
1420
- */
1421
- export function evaluateSpatialFunc({ funcName, args }) {
1422
- // Constructor / accessor functions (don't require two geometries)
1423
- if (funcName === 'ST_GEOMFROMTEXT') {
1424
- if (args[0] == null) return null
1425
- return parseWkt(String(args[0]))
1426
- }
1427
-
1428
- if (funcName === 'ST_MAKEENVELOPE') {
1429
- if (args[0] == null || args[1] == null || args[2] == null || args[3] == null) return null
1430
- const xmin = Number(args[0])
1431
- const ymin = Number(args[1])
1432
- const xmax = Number(args[2])
1433
- const ymax = Number(args[3])
1434
- return {
1435
- type: 'Polygon',
1436
- coordinates: [[[xmin, ymin], [xmax, ymin], [xmax, ymax], [xmin, ymax], [xmin, ymin]]],
1437
- }
1438
- }
1439
-
1440
- if (funcName === 'ST_ASTEXT') {
1441
- const geom = toGeometry(args[0])
1442
- if (geom == null) return null
1443
- return geomToWkt(geom)
1444
- }
1445
-
1446
- // Predicate functions (require two geometries)
1447
- const a = toGeometry(args[0])
1448
- const b = toGeometry(args[1])
1449
-
1450
- if (a == null || b == null) return null
1451
-
1452
- switch (funcName) {
1453
- case 'ST_INTERSECTS': return stIntersects(a, b)
1454
- case 'ST_CONTAINS': return stContains(a, b)
1455
- case 'ST_CONTAINSPROPERLY': return stContainsProperly(a, b)
1456
- case 'ST_WITHIN': return stWithin(a, b)
1457
- case 'ST_OVERLAPS': return stOverlaps(a, b)
1458
- case 'ST_TOUCHES': return stTouches(a, b)
1459
- case 'ST_EQUALS': return stEquals(a, b)
1460
- case 'ST_CROSSES': return stCrosses(a, b)
1461
- case 'ST_COVERS': return stCovers(a, b)
1462
- case 'ST_COVEREDBY': return stCoveredBy(a, b)
1463
- case 'ST_DWITHIN': {
1464
- if (args[2] == null) return null
1465
- const dist = Number(args[2])
1466
- return stDWithin(a, b, dist)
1467
- }
1468
- default:
1469
- return null
1470
- }
1471
- }