squirreling 0.9.2 → 0.9.3

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.
@@ -0,0 +1,1471 @@
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
+ }
208
+
209
+ /**
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}
216
+ */
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
+
224
+ /**
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).
247
+ *
248
+ * @param {number[][]} line1
249
+ * @param {number[][]} line2
250
+ * @returns {boolean}
251
+ */
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
+ }
257
+ }
258
+ return false
259
+ }
260
+
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
281
+ }
282
+ }
283
+ return false
284
+ }
285
+
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
301
+ }
302
+ return true
303
+ }
304
+
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
+ }
342
+
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
353
+ }
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
357
+ }
358
+ return true
359
+ }
360
+
361
+ /**
362
+ * Test if two rings are equal (same vertices, possibly different starting point).
363
+ *
364
+ * @param {number[][]} ring1
365
+ * @param {number[][]} ring2
366
+ * @returns {boolean}
367
+ */
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
+ }
381
+ }
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
+ }
394
+ }
395
+ if (match) return true
396
+ }
397
+ return false
398
+ }
399
+
400
+ // ============================================================================
401
+ // Minimum distance between geometries
402
+ // ============================================================================
403
+
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
+ /**
424
+ * Get all line segments from a geometry.
425
+ *
426
+ * @param {GeoJsonGeometry} geom
427
+ * @returns {Array<[number[], number[]]>}
428
+ */
429
+ function getSegments(geom) {
430
+ /** @type {Array<[number[], number[]]>} */
431
+ const segments = []
432
+ /**
433
+ * @param {number[][]} coords
434
+ */
435
+ function addLine(coords) {
436
+ for (let i = 0; i < coords.length - 1; i++) {
437
+ segments.push([coords[i], coords[i + 1]])
438
+ }
439
+ }
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
+
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
+ }
513
+ }
514
+
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
541
+ }
542
+
543
+ // ============================================================================
544
+ // Spatial predicate dispatch - decompose to primitive type pairs
545
+ // ============================================================================
546
+
547
+ /**
548
+ * Decompose Multi* and GeometryCollection into simple geometries.
549
+ *
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
799
+ * @returns {boolean}
800
+ */
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)
829
+ }
830
+ if (ta === 'Polygon' && tb === 'LineString') {
831
+ return lineInteriorIntersectsPolygonInterior(b.coordinates, a.coordinates)
832
+ }
833
+ if (ta === 'Polygon' && tb === 'Polygon') {
834
+ return polygonInteriorsIntersect(a.coordinates, b.coordinates)
835
+ }
836
+ return false
837
+ }
838
+
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
+ }
850
+
851
+ /**
852
+ * @param {number[][]} line1
853
+ * @param {number[][]} line2
854
+ * @returns {boolean}
855
+ */
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
+ }
878
+ }
879
+ return false
880
+ }
881
+
882
+ /**
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}
890
+ */
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]
898
+ }
899
+
900
+ /**
901
+ * @param {number[][]} line
902
+ * @param {number[][][]} rings
903
+ * @returns {boolean}
904
+ */
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
916
+ }
917
+
918
+ /**
919
+ * @param {number[][][]} rings1
920
+ * @param {number[][][]} rings2
921
+ * @returns {boolean}
922
+ */
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
940
+ }
941
+ return false
942
+ }
943
+
944
+ // ============================================================================
945
+ // ST_Overlaps
946
+ // ============================================================================
947
+
948
+ /**
949
+ * @param {GeoJsonGeometry} a
950
+ * @param {GeoJsonGeometry} b
951
+ * @returns {boolean}
952
+ */
953
+ function stOverlaps(a, b) {
954
+ // Overlaps requires same dimension, and that each geometry has some part
955
+ // inside the other and some part outside
956
+ const dimA = geometryDimension(a)
957
+ const dimB = geometryDimension(b)
958
+ if (dimA !== dimB) return false
959
+ if (!stIntersects(a, b)) return false
960
+ if (stEquals(a, b)) return false
961
+ // Must not be containment
962
+ if (stContains(a, b) || stContains(b, a)) return false
963
+ return true
964
+ }
965
+
966
+ /**
967
+ * @param {GeoJsonGeometry} geom
968
+ * @returns {number}
969
+ */
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))
985
+ }
986
+ return max
987
+ }
988
+ default:
989
+ return 0
990
+ }
991
+ }
992
+
993
+ // ============================================================================
994
+ // ST_Equals
995
+ // ============================================================================
996
+
997
+ /**
998
+ * @param {GeoJsonGeometry} a
999
+ * @param {GeoJsonGeometry} b
1000
+ * @returns {boolean}
1001
+ */
1002
+ function stEquals(a, b) {
1003
+ const partsA = decompose(a)
1004
+ const partsB = decompose(b)
1005
+
1006
+ if (partsA.length !== partsB.length) return false
1007
+
1008
+ // For each simple geometry in A, find a matching one in B
1009
+ const used = new Set()
1010
+ for (const pa of partsA) {
1011
+ let found = false
1012
+ for (let i = 0; i < partsB.length; i++) {
1013
+ if (used.has(i)) continue
1014
+ if (simpleGeomEqual(pa, partsB[i])) {
1015
+ used.add(i)
1016
+ found = true
1017
+ break
1018
+ }
1019
+ }
1020
+ if (!found) return false
1021
+ }
1022
+ return true
1023
+ }
1024
+
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
+ // ============================================================================
1083
+ // ST_Crosses
1084
+ // ============================================================================
1085
+
1086
+ /**
1087
+ * @param {GeoJsonGeometry} a
1088
+ * @param {GeoJsonGeometry} b
1089
+ * @returns {boolean}
1090
+ */
1091
+ function stCrosses(a, b) {
1092
+ // Crosses: interiors intersect, and the intersection has lower dimension
1093
+ // than the maximum of the two geometries' dimensions
1094
+ const dimA = geometryDimension(a)
1095
+ const dimB = geometryDimension(b)
1096
+
1097
+ if (!stIntersects(a, b)) return false
1098
+
1099
+ // Point/Point or Polygon/Polygon cannot cross
1100
+ if (dimA === dimB && dimA !== 1) return false
1101
+
1102
+ // Line/Line: they cross if they intersect at a point (not overlap)
1103
+ if (dimA === 1 && dimB === 1) {
1104
+ // They cross if they intersect but neither contains the other
1105
+ // 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)
1107
+ }
1108
+
1109
+ // Point/Line, Point/Polygon: point "in interior"
1110
+ 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
1117
+ }
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
1123
+ }
1124
+ }
1125
+ }
1126
+ return false
1127
+ }
1128
+
1129
+ // Line/Polygon: line crosses polygon if part of line is inside and part is outside
1130
+ if (dimA === 1 && dimB === 2) {
1131
+ return stIntersects(a, b) && !stContains(b, a)
1132
+ }
1133
+
1134
+ // Symmetric cases
1135
+ if (dimA >= 1 && dimB === 0) return stCrosses(b, a)
1136
+ if (dimA === 2 && dimB === 1) return stCrosses(b, a)
1137
+
1138
+ return false
1139
+ }
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
+ }