@jarenjs/core 0.34.2 → 0.43.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +43 -2
- package/README.md +7 -4
- package/dist/types/function.d.ts +25 -0
- package/dist/types/geo/bbox.d.ts +28 -0
- package/dist/types/geo/distance.d.ts +32 -0
- package/dist/types/geo/geojson.d.ts +4 -1
- package/dist/types/geo/simplify.d.ts +28 -0
- package/dist/types/geo/wkt.d.ts +65 -0
- package/dist/types/object.d.ts +18 -0
- package/dist/types/scan.d.ts +1 -0
- package/dist/types/string.d.ts +22 -0
- package/dist/types/text/sse.d.ts +74 -0
- package/docs/GEO.md +77 -4
- package/package.json +1 -1
- package/src/function.js +34 -0
- package/src/geo/bbox.js +41 -0
- package/src/geo/distance.js +48 -0
- package/src/geo/geojson.js +10 -2
- package/src/geo/index.js +1 -1
- package/src/geo/simplify.js +69 -0
- package/src/geo/wkt.js +379 -63
- package/src/object.js +68 -0
- package/src/scan.js +1 -0
- package/src/string.js +36 -0
- package/src/text/sse.js +197 -0
package/src/geo/wkt.js
CHANGED
|
@@ -1,23 +1,37 @@
|
|
|
1
1
|
//@ts-check
|
|
2
2
|
|
|
3
3
|
//#region Well-Known Text
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// `format` needs: a plain predicate over the string itself.
|
|
4
|
+
// WKT (ISO 19125 / OGC Simple Features) is the text encoding every
|
|
5
|
+
// spatial database speaks, and this module is the round trip between it
|
|
6
|
+
// and the suite's representation, which is GeoJSON: `wktToGeoJson`
|
|
7
|
+
// parses, `geoJsonToWkt` writes, and `isValidWkt` answers yes-or-no for
|
|
8
|
+
// a `format` that only needs the judgment.
|
|
10
9
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
10
|
+
// **There is one grammar walk and two entry points.** Every scan
|
|
11
|
+
// function takes a sink: absent, it validates and allocates nothing;
|
|
12
|
+
// present, it appends the value it just recognized. The `wkt` format
|
|
13
|
+
// tester runs per value in the validator and per keystroke in the form
|
|
14
|
+
// layer, so making it build a geometry to answer a boolean would be a
|
|
15
|
+
// regression — and two hand-maintained grammars for one syntax would
|
|
16
|
+
// drift. The differential test over the committed corpus asserts the
|
|
17
|
+
// two entry points accept exactly the same language.
|
|
18
|
+
//
|
|
19
|
+
// The grammar is validated strictly: the seven geometry tags and only
|
|
20
|
+
// those, an optional Z/M/ZM dimension modifier, `EMPTY` or a
|
|
21
|
+
// parenthesized body, and a consistent coordinate count per geometry.
|
|
22
|
+
// The dimension rule follows the field rather than the letter of SFA:
|
|
23
|
+
// an unmodified tag accepts 2 or 3 coordinates per point (PostGIS reads
|
|
24
|
+
// `POINT(1 2 3)` as 3D), `Z` and `M` require exactly 3, `ZM` exactly 4
|
|
25
|
+
// — but whichever count the first point establishes, every following
|
|
26
|
+
// point must match. Polygon rings must close and carry at least four
|
|
27
|
+
// points, as the spec requires. No leading or trailing text is
|
|
28
|
+
// tolerated: a format that trims would accept strings a consumer then
|
|
29
|
+
// fails on.
|
|
30
|
+
//
|
|
31
|
+
// Parsing answers what the text says and judges nothing else: a
|
|
32
|
+
// coordinate outside the WGS 84 bounds parses, and `isValidGeoJson` is
|
|
33
|
+
// the value gate — a parser that also judged would report the same
|
|
34
|
+
// defect twice and would make the two entry points disagree.
|
|
21
35
|
|
|
22
36
|
import {
|
|
23
37
|
isAsciiLetterCode,
|
|
@@ -25,6 +39,8 @@ import {
|
|
|
25
39
|
isWhitespaceCode,
|
|
26
40
|
} from '../scan.js';
|
|
27
41
|
|
|
42
|
+
import { eachPosition, geometryOf, isPosition } from './geojson.js';
|
|
43
|
+
|
|
28
44
|
// character codes
|
|
29
45
|
const LPAREN = 0x28;
|
|
30
46
|
const RPAREN = 0x29;
|
|
@@ -33,6 +49,24 @@ const PLUS = 0x2B;
|
|
|
33
49
|
const MINUS = 0x2D;
|
|
34
50
|
const DOT = 0x2E;
|
|
35
51
|
|
|
52
|
+
/**
|
|
53
|
+
* The closed tag map, read direction: the GeoJSON type a WKT tag names,
|
|
54
|
+
* or null for anything else. Consulted before the body is scanned, so
|
|
55
|
+
* an unknown tag is refused whether it carries a body or `EMPTY`.
|
|
56
|
+
*/
|
|
57
|
+
function geoJsonTypeOf(tag) {
|
|
58
|
+
switch (tag) {
|
|
59
|
+
case 'POINT': return 'Point';
|
|
60
|
+
case 'LINESTRING': return 'LineString';
|
|
61
|
+
case 'POLYGON': return 'Polygon';
|
|
62
|
+
case 'MULTIPOINT': return 'MultiPoint';
|
|
63
|
+
case 'MULTILINESTRING': return 'MultiLineString';
|
|
64
|
+
case 'MULTIPOLYGON': return 'MultiPolygon';
|
|
65
|
+
case 'GEOMETRYCOLLECTION': return 'GeometryCollection';
|
|
66
|
+
default: return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
36
70
|
function skipWs(text, at) {
|
|
37
71
|
while (at < text.length && isWhitespaceCode(text.charCodeAt(at)))
|
|
38
72
|
at++;
|
|
@@ -88,12 +122,19 @@ function scanNumber(text, at) {
|
|
|
88
122
|
/**
|
|
89
123
|
* One point: `dim.n` coordinates separated by whitespace. A zero `dim.n`
|
|
90
124
|
* means the modifier allowed 2 or 3, and the first point decides which.
|
|
125
|
+
* With a sink, appends the position; `dim.m` drops the trailing measure,
|
|
126
|
+
* which is not an altitude and has no place in a GeoJSON position.
|
|
91
127
|
* Returns the position after the point, or -1.
|
|
92
128
|
*/
|
|
93
|
-
function scanPoint(text, at, dim) {
|
|
94
|
-
|
|
129
|
+
function scanPoint(text, at, dim, out) {
|
|
130
|
+
const from = skipWs(text, at);
|
|
131
|
+
let i = scanNumber(text, from);
|
|
95
132
|
if (i < 0)
|
|
96
133
|
return -1;
|
|
134
|
+
// `+ 0` normalizes -0 to 0: the two are the same point, but only one
|
|
135
|
+
// of them survives JSON.stringify, so a parser producing -0 hands the
|
|
136
|
+
// caller a value that changes the moment it is serialized
|
|
137
|
+
const position = out === null ? null : [Number(text.slice(from, i)) + 0];
|
|
97
138
|
let count = 1;
|
|
98
139
|
for (;;) {
|
|
99
140
|
const j = skipWs(text, i);
|
|
@@ -102,6 +143,8 @@ function scanPoint(text, at, dim) {
|
|
|
102
143
|
const k = scanNumber(text, j);
|
|
103
144
|
if (k < 0)
|
|
104
145
|
break;
|
|
146
|
+
if (position !== null)
|
|
147
|
+
position.push(Number(text.slice(j, k)) + 0);
|
|
105
148
|
i = k;
|
|
106
149
|
count++;
|
|
107
150
|
}
|
|
@@ -113,15 +156,21 @@ function scanPoint(text, at, dim) {
|
|
|
113
156
|
else if (count !== dim.n) {
|
|
114
157
|
return -1;
|
|
115
158
|
}
|
|
159
|
+
if (position !== null) {
|
|
160
|
+
if (dim.m)
|
|
161
|
+
position.length = count - 1;
|
|
162
|
+
out.push(position);
|
|
163
|
+
}
|
|
116
164
|
return i;
|
|
117
165
|
}
|
|
118
166
|
|
|
119
167
|
/**
|
|
120
168
|
* A parenthesized, comma-separated list validated by `scanItem`, with at
|
|
121
169
|
* least `min` items. `first` may record where each item's scan started —
|
|
122
|
-
* the hook ring closure uses
|
|
170
|
+
* the hook ring closure uses — and `out` is the sink each item appends
|
|
171
|
+
* its own value to. Returns the position after `)`, or -1.
|
|
123
172
|
*/
|
|
124
|
-
function scanList(text, at, dim, min, scanItem, starts) {
|
|
173
|
+
function scanList(text, at, dim, min, scanItem, starts, out) {
|
|
125
174
|
let i = skipWs(text, at);
|
|
126
175
|
if (i >= text.length || text.charCodeAt(i) !== LPAREN)
|
|
127
176
|
return -1;
|
|
@@ -131,7 +180,7 @@ function scanList(text, at, dim, min, scanItem, starts) {
|
|
|
131
180
|
const from = skipWs(text, i);
|
|
132
181
|
if (starts !== null)
|
|
133
182
|
starts.push(from);
|
|
134
|
-
i = scanItem(text, from, dim);
|
|
183
|
+
i = scanItem(text, from, dim, out);
|
|
135
184
|
if (i < 0)
|
|
136
185
|
return -1;
|
|
137
186
|
count++;
|
|
@@ -147,95 +196,160 @@ function scanList(text, at, dim, min, scanItem, starts) {
|
|
|
147
196
|
}
|
|
148
197
|
}
|
|
149
198
|
|
|
199
|
+
/**
|
|
200
|
+
* A list whose items belong to a nesting level of their own: the items
|
|
201
|
+
* fill a fresh array and that array is what this level appends. Every
|
|
202
|
+
* scan function appends exactly one value to its sink, so the caller
|
|
203
|
+
* always reads its result at index 0.
|
|
204
|
+
*/
|
|
205
|
+
function scanNested(text, at, dim, min, scanItem, out) {
|
|
206
|
+
const items = out === null ? null : [];
|
|
207
|
+
const i = scanList(text, at, dim, min, scanItem, null, items);
|
|
208
|
+
if (i < 0)
|
|
209
|
+
return -1;
|
|
210
|
+
if (out !== null)
|
|
211
|
+
out.push(items);
|
|
212
|
+
return i;
|
|
213
|
+
}
|
|
214
|
+
|
|
150
215
|
/** A point that may also be wrapped in its own parens (MULTIPOINT). */
|
|
151
|
-
function scanMultiPointItem(text, at, dim) {
|
|
216
|
+
function scanMultiPointItem(text, at, dim, out) {
|
|
152
217
|
if (at < text.length && text.charCodeAt(at) === LPAREN) {
|
|
153
|
-
const i = scanPoint(text, at + 1, dim);
|
|
218
|
+
const i = scanPoint(text, at + 1, dim, out);
|
|
154
219
|
if (i < 0)
|
|
155
220
|
return -1;
|
|
156
221
|
const j = skipWs(text, i);
|
|
157
222
|
return j < text.length && text.charCodeAt(j) === RPAREN ? j + 1 : -1;
|
|
158
223
|
}
|
|
159
|
-
return scanPoint(text, at, dim);
|
|
224
|
+
return scanPoint(text, at, dim, out);
|
|
160
225
|
}
|
|
161
226
|
|
|
162
|
-
function scanLineStringBody(text, at, dim) {
|
|
163
|
-
return
|
|
227
|
+
function scanLineStringBody(text, at, dim, out) {
|
|
228
|
+
return scanNested(text, at, dim, 2, scanPoint, out);
|
|
164
229
|
}
|
|
165
230
|
|
|
166
231
|
/** A ring: four or more points, and the first equals the last. */
|
|
167
|
-
function scanRing(text, at, dim) {
|
|
232
|
+
function scanRing(text, at, dim, out) {
|
|
168
233
|
const starts = [];
|
|
169
|
-
const
|
|
234
|
+
const ring = out === null ? null : [];
|
|
235
|
+
const i = scanList(text, at, dim, 4, scanPoint, starts, ring);
|
|
170
236
|
if (i < 0)
|
|
171
237
|
return -1;
|
|
172
|
-
// compare the first and last point textually by re-scanning both
|
|
238
|
+
// compare the first and last point textually by re-scanning both
|
|
239
|
+
// spans: closure is a property of the coordinates the text carries,
|
|
240
|
+
// including a measure the built ring does not keep
|
|
173
241
|
const first = pointText(text, starts[0], dim);
|
|
174
242
|
const last = pointText(text, starts[starts.length - 1], dim);
|
|
175
|
-
|
|
243
|
+
if (first === null || first !== last)
|
|
244
|
+
return -1;
|
|
245
|
+
if (out !== null)
|
|
246
|
+
out.push(ring);
|
|
247
|
+
return i;
|
|
176
248
|
}
|
|
177
249
|
|
|
178
250
|
/** The point's coordinates as a normalized string, for closure tests. */
|
|
179
251
|
function pointText(text, at, dim) {
|
|
180
|
-
const end = scanPoint(text, at, dim);
|
|
252
|
+
const end = scanPoint(text, at, dim, null);
|
|
181
253
|
if (end < 0)
|
|
182
254
|
return null;
|
|
183
255
|
const parts = text.slice(at, end).trim().split(/\s+/);
|
|
184
256
|
return parts.map(Number).join(',');
|
|
185
257
|
}
|
|
186
258
|
|
|
187
|
-
function scanPolygonBody(text, at, dim) {
|
|
188
|
-
return
|
|
259
|
+
function scanPolygonBody(text, at, dim, out) {
|
|
260
|
+
return scanNested(text, at, dim, 1, scanRing, out);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** A collection member is a whole tagged geometry, modifier and all. */
|
|
264
|
+
function scanCollectionItem(text, at, dim, out) {
|
|
265
|
+
return scanGeometry(text, at, out);
|
|
189
266
|
}
|
|
190
267
|
|
|
191
268
|
/**
|
|
192
|
-
* One tagged geometry: `TAG [Z|M|ZM] (EMPTY | body)`.
|
|
193
|
-
*
|
|
269
|
+
* One tagged geometry: `TAG [Z|M|ZM] (EMPTY | body)`. With a sink,
|
|
270
|
+
* appends the GeoJSON geometry it recognized. Returns the position
|
|
271
|
+
* after it, or -1.
|
|
194
272
|
*/
|
|
195
|
-
function scanGeometry(text, at) {
|
|
273
|
+
function scanGeometry(text, at, out) {
|
|
196
274
|
const word = readWord(text, skipWs(text, at));
|
|
197
275
|
if (word === null)
|
|
198
276
|
return -1;
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
277
|
+
const type = geoJsonTypeOf(word[0]);
|
|
278
|
+
if (type === null)
|
|
279
|
+
return -1;
|
|
280
|
+
let i = word[1];
|
|
281
|
+
// an exact count from the modifier, or 0 for "2 or 3, first point
|
|
282
|
+
// decides"; `m` marks the trailing coordinate as a measure
|
|
283
|
+
const dim = { n: 0, m: false };
|
|
203
284
|
const mod = readWord(text, skipWs(text, i));
|
|
204
285
|
if (mod !== null && (mod[0] === 'Z' || mod[0] === 'M' || mod[0] === 'ZM')) {
|
|
205
286
|
dim.n = mod[0] === 'ZM' ? 4 : 3;
|
|
287
|
+
dim.m = mod[0] !== 'Z';
|
|
206
288
|
i = mod[1];
|
|
207
289
|
}
|
|
208
290
|
const empty = readWord(text, skipWs(text, i));
|
|
209
|
-
if (empty !== null && empty[0] === 'EMPTY')
|
|
291
|
+
if (empty !== null && empty[0] === 'EMPTY') {
|
|
292
|
+
if (out !== null) {
|
|
293
|
+
out.push(type === 'GeometryCollection'
|
|
294
|
+
? { type, geometries: [] }
|
|
295
|
+
: { type, coordinates: [] });
|
|
296
|
+
}
|
|
210
297
|
return empty[1];
|
|
298
|
+
}
|
|
211
299
|
|
|
212
|
-
|
|
213
|
-
|
|
300
|
+
const parts = out === null ? null : [];
|
|
301
|
+
let end;
|
|
302
|
+
switch (type) {
|
|
303
|
+
case 'Point': {
|
|
214
304
|
const j = skipWs(text, i);
|
|
215
305
|
if (j >= text.length || text.charCodeAt(j) !== LPAREN)
|
|
216
306
|
return -1;
|
|
217
|
-
const k = scanPoint(text, j + 1, dim);
|
|
307
|
+
const k = scanPoint(text, j + 1, dim, parts);
|
|
218
308
|
if (k < 0)
|
|
219
309
|
return -1;
|
|
220
310
|
const l = skipWs(text, k);
|
|
221
|
-
|
|
311
|
+
if (l >= text.length || text.charCodeAt(l) !== RPAREN)
|
|
312
|
+
return -1;
|
|
313
|
+
end = l + 1;
|
|
314
|
+
break;
|
|
222
315
|
}
|
|
223
|
-
case '
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
case '
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
316
|
+
case 'LineString':
|
|
317
|
+
end = scanLineStringBody(text, i, dim, parts);
|
|
318
|
+
break;
|
|
319
|
+
case 'Polygon':
|
|
320
|
+
end = scanPolygonBody(text, i, dim, parts);
|
|
321
|
+
break;
|
|
322
|
+
case 'MultiPoint':
|
|
323
|
+
end = scanNested(text, i, dim, 1, scanMultiPointItem, parts);
|
|
324
|
+
break;
|
|
325
|
+
case 'MultiLineString':
|
|
326
|
+
end = scanNested(text, i, dim, 1, scanLineStringBody, parts);
|
|
327
|
+
break;
|
|
328
|
+
case 'MultiPolygon':
|
|
329
|
+
end = scanNested(text, i, dim, 1, scanPolygonBody, parts);
|
|
330
|
+
break;
|
|
331
|
+
default: // GeometryCollection
|
|
332
|
+
end = scanNested(text, i, dim, 1, scanCollectionItem, parts);
|
|
333
|
+
break;
|
|
334
|
+
}
|
|
335
|
+
if (end < 0)
|
|
336
|
+
return -1;
|
|
337
|
+
if (out !== null) {
|
|
338
|
+
out.push(type === 'GeometryCollection'
|
|
339
|
+
? { type, geometries: parts[0] }
|
|
340
|
+
: { type, coordinates: parts[0] });
|
|
238
341
|
}
|
|
342
|
+
return end;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* The one entry into the walk: a null sink validates, an array sink
|
|
347
|
+
* builds. Answers whether the whole string was a single geometry.
|
|
348
|
+
*/
|
|
349
|
+
function scanWkt(text, out) {
|
|
350
|
+
if (typeof text !== 'string' || text.length === 0 || isWhitespaceCode(text.charCodeAt(0)))
|
|
351
|
+
return false;
|
|
352
|
+
return scanGeometry(text, 0, out) === text.length;
|
|
239
353
|
}
|
|
240
354
|
|
|
241
355
|
/**
|
|
@@ -244,6 +358,9 @@ function scanGeometry(text, at) {
|
|
|
244
358
|
* coordinate count consistent with the `Z`/`M`/`ZM` modifier and with
|
|
245
359
|
* itself, closed polygon rings, and nothing before or after.
|
|
246
360
|
*
|
|
361
|
+
* This is the non-allocating half of the walk {@link wktToGeoJson}
|
|
362
|
+
* builds with, so the two accept exactly the same language.
|
|
363
|
+
*
|
|
247
364
|
* @param {string} text
|
|
248
365
|
* @returns {boolean}
|
|
249
366
|
* @example
|
|
@@ -252,11 +369,210 @@ function scanGeometry(text, at) {
|
|
|
252
369
|
* isValidWkt('POLYGON ((0 0, 4 0, 4 4, 1 1))'); // false (open ring)
|
|
253
370
|
* isValidWkt('POINT Z (1 2)'); // false (Z wants 3)
|
|
254
371
|
* isValidWkt('LINESTRING (0 0, 1 1 1)'); // false (mixed dimension)
|
|
372
|
+
* isValidWkt('CIRCLE EMPTY'); // false (not one of the seven)
|
|
255
373
|
*/
|
|
256
374
|
export function isValidWkt(text) {
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
375
|
+
return scanWkt(text, null);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* The GeoJSON geometry a WKT string names, or `null` when the text is
|
|
380
|
+
* not well-formed WKT — the same judgment {@link isValidWkt} makes,
|
|
381
|
+
* from the same walk. `null` rather than a throw is the kernel's
|
|
382
|
+
* posture for "no answer" (`bboxOf`, `centroidOf`, `geohashBounds` all
|
|
383
|
+
* answer it), so no call site has to wrap this in a try.
|
|
384
|
+
*
|
|
385
|
+
* Three rules decide what a lenient parser would silently lose:
|
|
386
|
+
*
|
|
387
|
+
* - `EMPTY` becomes an empty coordinate array (`POINT EMPTY` →
|
|
388
|
+
* `{ type: 'Point', coordinates: [] }`), never `null`: unparseable
|
|
389
|
+
* and validly empty are different answers.
|
|
390
|
+
* - The `M` measure is dropped and `Z` is kept. RFC 7946 §3.1.1 defines
|
|
391
|
+
* a position's third element as altitude, and a measure is not one —
|
|
392
|
+
* writing it there would be a lie. `POINT ZM (1 2 3 4)` → `[1, 2, 3]`,
|
|
393
|
+
* `POINT M (1 2 3)` → `[1, 2]`.
|
|
394
|
+
* - Coordinate ranges are not judged. `POINT (999 999)` parses;
|
|
395
|
+
* `isValidGeoJson` is the value gate.
|
|
396
|
+
*
|
|
397
|
+
* @param {string} text
|
|
398
|
+
* @returns {object | null}
|
|
399
|
+
* @example
|
|
400
|
+
* wktToGeoJson('POINT (4.9041 52.3676)');
|
|
401
|
+
* // { type: 'Point', coordinates: [4.9041, 52.3676] }
|
|
402
|
+
* wktToGeoJson('POLYGON ((0 0, 4 0, 4 4, 0 0))');
|
|
403
|
+
* // { type: 'Polygon', coordinates: [[[0,0],[4,0],[4,4],[0,0]]] }
|
|
404
|
+
* wktToGeoJson('POINT EMPTY'); // { type: 'Point', coordinates: [] }
|
|
405
|
+
* wktToGeoJson('POLYGON ((0 0))'); // null
|
|
406
|
+
*/
|
|
407
|
+
export function wktToGeoJson(text) {
|
|
408
|
+
const out = [];
|
|
409
|
+
return scanWkt(text, out) ? out[0] : null;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* The closed tag map, write direction: the WKT tag each GeoJSON type
|
|
414
|
+
* takes, the nesting depth of its body, and whether its leaf positions
|
|
415
|
+
* are parenthesized. ISO 19125 spells a `<point text>` with its own
|
|
416
|
+
* parentheses — which POINT and MULTIPOINT carry and a position inside
|
|
417
|
+
* a line or ring does not.
|
|
418
|
+
*/
|
|
419
|
+
const WKT_BODY = {
|
|
420
|
+
Point: { tag: 'POINT', depth: 0, wrap: true },
|
|
421
|
+
LineString: { tag: 'LINESTRING', depth: 1, wrap: false },
|
|
422
|
+
Polygon: { tag: 'POLYGON', depth: 2, wrap: false },
|
|
423
|
+
MultiPoint: { tag: 'MULTIPOINT', depth: 1, wrap: true },
|
|
424
|
+
MultiLineString: { tag: 'MULTILINESTRING', depth: 2, wrap: false },
|
|
425
|
+
MultiPolygon: { tag: 'MULTIPOLYGON', depth: 3, wrap: false },
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* One position. `String(n)` is the shortest round-tripping spelling and
|
|
430
|
+
* the one JSON.stringify uses, so a coordinate written here parses back
|
|
431
|
+
* to the same number; a fixed precision would move it.
|
|
432
|
+
*/
|
|
433
|
+
function writePosition(position, hasZ) {
|
|
434
|
+
if (!Array.isArray(position) || position.length < 2)
|
|
435
|
+
return null;
|
|
436
|
+
const x = position[0];
|
|
437
|
+
const y = position[1];
|
|
438
|
+
if (typeof x !== 'number' || !Number.isFinite(x))
|
|
439
|
+
return null;
|
|
440
|
+
if (typeof y !== 'number' || !Number.isFinite(y))
|
|
441
|
+
return null;
|
|
442
|
+
if (!hasZ)
|
|
443
|
+
return `${x} ${y}`;
|
|
444
|
+
const z = position[2];
|
|
445
|
+
if (typeof z !== 'number' || !Number.isFinite(z))
|
|
446
|
+
return null;
|
|
447
|
+
return `${x} ${y} ${z}`;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** A coordinate nest `depth` levels deep, or null if it cannot be written. */
|
|
451
|
+
function writeCoordinates(value, depth, hasZ, wrap) {
|
|
452
|
+
if (depth === 0) {
|
|
453
|
+
const text = writePosition(value, hasZ);
|
|
454
|
+
if (text === null)
|
|
455
|
+
return null;
|
|
456
|
+
return wrap ? `(${text})` : text;
|
|
457
|
+
}
|
|
458
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
459
|
+
return null;
|
|
460
|
+
const parts = [];
|
|
461
|
+
for (let i = 0; i < value.length; i++) {
|
|
462
|
+
const text = writeCoordinates(value[i], depth - 1, hasZ, wrap);
|
|
463
|
+
if (text === null)
|
|
464
|
+
return null;
|
|
465
|
+
parts.push(text);
|
|
466
|
+
}
|
|
467
|
+
return `(${parts.join(', ')})`;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Whether every position of a value carries a third element. */
|
|
471
|
+
function isEvery3D(value) {
|
|
472
|
+
let seen = false;
|
|
473
|
+
let all = true;
|
|
474
|
+
eachPosition(value, (position) => {
|
|
475
|
+
seen = true;
|
|
476
|
+
if (position.length < 3)
|
|
477
|
+
all = false;
|
|
478
|
+
});
|
|
479
|
+
return seen && all;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** One tagged geometry, modifier and all, or null. */
|
|
483
|
+
function writeGeometry(geometry, dim) {
|
|
484
|
+
if (geometry === null || typeof geometry !== 'object' || Array.isArray(geometry))
|
|
485
|
+
return null;
|
|
486
|
+
const type = geometry.type;
|
|
487
|
+
if (type === 'GeometryCollection') {
|
|
488
|
+
const geometries = geometry.geometries;
|
|
489
|
+
if (!Array.isArray(geometries))
|
|
490
|
+
return null;
|
|
491
|
+
if (geometries.length === 0)
|
|
492
|
+
return 'GEOMETRYCOLLECTION EMPTY';
|
|
493
|
+
const parts = [];
|
|
494
|
+
for (let i = 0; i < geometries.length; i++) {
|
|
495
|
+
// each member carries its own modifier, which is the only place
|
|
496
|
+
// WKT lets the dimension change inside one value
|
|
497
|
+
const text = writeGeometry(geometries[i], dim);
|
|
498
|
+
if (text === null)
|
|
499
|
+
return null;
|
|
500
|
+
parts.push(text);
|
|
501
|
+
}
|
|
502
|
+
return `GEOMETRYCOLLECTION (${parts.join(', ')})`;
|
|
503
|
+
}
|
|
504
|
+
if (typeof type !== 'string' || !Object.hasOwn(WKT_BODY, type))
|
|
505
|
+
return null;
|
|
506
|
+
const body = WKT_BODY[type];
|
|
507
|
+
const coordinates = geometry.coordinates;
|
|
508
|
+
if (!Array.isArray(coordinates))
|
|
509
|
+
return null;
|
|
510
|
+
if (coordinates.length === 0)
|
|
511
|
+
return `${body.tag} EMPTY`;
|
|
512
|
+
const hasZ = dim === 3 && isEvery3D(geometry);
|
|
513
|
+
const text = writeCoordinates(coordinates, body.depth, hasZ, body.wrap);
|
|
514
|
+
return text === null ? null : `${body.tag}${hasZ ? ' Z' : ''} ${text}`;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* The geometry a value carries, following the traversal layer's
|
|
519
|
+
* unwrapping posture: a Feature yields its geometry, a
|
|
520
|
+
* FeatureCollection a GeometryCollection of its features', a bare
|
|
521
|
+
* position a Point.
|
|
522
|
+
*/
|
|
523
|
+
function geometryToWrite(value) {
|
|
524
|
+
if (isPosition(value))
|
|
525
|
+
return { type: 'Point', coordinates: value };
|
|
526
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
527
|
+
return null;
|
|
528
|
+
if (value.type === 'FeatureCollection') {
|
|
529
|
+
const features = Array.isArray(value.features) ? value.features : [];
|
|
530
|
+
const geometries = [];
|
|
531
|
+
for (let i = 0; i < features.length; i++) {
|
|
532
|
+
// a Feature with a null geometry is a located-nowhere record;
|
|
533
|
+
// WKT cannot say that, so it contributes nothing to the collection
|
|
534
|
+
const inner = geometryToWrite(features[i]);
|
|
535
|
+
if (inner !== null)
|
|
536
|
+
geometries.push(inner);
|
|
537
|
+
}
|
|
538
|
+
return { type: 'GeometryCollection', geometries };
|
|
539
|
+
}
|
|
540
|
+
return geometryOf(value);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* A GeoJSON value as a WKT string, or `null` for a value with no
|
|
545
|
+
* geometry to write. Accepts what the traversal layer accepts: a bare
|
|
546
|
+
* position, a geometry, a Feature or a FeatureCollection.
|
|
547
|
+
*
|
|
548
|
+
* `options.dim` is 2 (the default) or 3. At 3, a geometry whose every
|
|
549
|
+
* position carries a third element is written with a `Z` modifier;
|
|
550
|
+
* anything else is written 2D with the third elements dropped, because
|
|
551
|
+
* one WKT geometry carries one modifier for all of its coordinates. The
|
|
552
|
+
* decision is made per tagged geometry, so a GeometryCollection may mix
|
|
553
|
+
* 2D and 3D members — each writes its own modifier. Invalid WKT is
|
|
554
|
+
* never emitted.
|
|
555
|
+
*
|
|
556
|
+
* A non-finite coordinate yields `null`: a value that cannot be written
|
|
557
|
+
* is not written approximately.
|
|
558
|
+
*
|
|
559
|
+
* @param {any} value
|
|
560
|
+
* @param {{ dim?: number }} [options]
|
|
561
|
+
* @returns {string | null}
|
|
562
|
+
* @example
|
|
563
|
+
* geoJsonToWkt({ type: 'Point', coordinates: [4.9041, 52.3676] });
|
|
564
|
+
* // 'POINT (4.9041 52.3676)'
|
|
565
|
+
* geoJsonToWkt([4.9041, 52.3676]); // 'POINT (4.9041 52.3676)'
|
|
566
|
+
* geoJsonToWkt({ type: 'Point', coordinates: [] }); // 'POINT EMPTY'
|
|
567
|
+
* geoJsonToWkt({ type: 'Point', coordinates: [1, 2, 3] }, { dim: 3 });
|
|
568
|
+
* // 'POINT Z (1 2 3)'
|
|
569
|
+
* geoJsonToWkt({ type: 'Point', coordinates: [NaN, 2] }); // null
|
|
570
|
+
*/
|
|
571
|
+
export function geoJsonToWkt(value, options = {}) {
|
|
572
|
+
const geometry = geometryToWrite(value);
|
|
573
|
+
if (geometry === null)
|
|
574
|
+
return null;
|
|
575
|
+
return writeGeometry(geometry, options.dim === 3 ? 3 : 2);
|
|
260
576
|
}
|
|
261
577
|
|
|
262
578
|
//#endregion
|
package/src/object.js
CHANGED
|
@@ -453,6 +453,74 @@ export function isJsonObject(value) {
|
|
|
453
453
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
454
454
|
}
|
|
455
455
|
|
|
456
|
+
/**
|
|
457
|
+
* True when a value is representable as JSON as it stands: `null`, a
|
|
458
|
+
* boolean, a finite number, a string, or an array/plain object (own
|
|
459
|
+
* enumerable members, prototype `Object.prototype` or `null`) whose
|
|
460
|
+
* members all are. Everything else — `undefined`, `bigint`, `symbol`,
|
|
461
|
+
* functions, `NaN`/`±Infinity`, class instances (`Error`, `Date`, `Map`,
|
|
462
|
+
* `Response`, DOM nodes ...) and any cycle — is not.
|
|
463
|
+
*
|
|
464
|
+
* This is the boundary predicate for "JSON only crosses": a host that
|
|
465
|
+
* hands a value into application state asks it once, before the value
|
|
466
|
+
* can carry an object the state validator would have to reject. It is
|
|
467
|
+
* TOTAL — a hostile accessor that throws makes the value not-JSON rather
|
|
468
|
+
* than propagating.
|
|
469
|
+
*
|
|
470
|
+
* @param {any} value
|
|
471
|
+
* @returns {boolean}
|
|
472
|
+
*/
|
|
473
|
+
export function isJsonValue(value) {
|
|
474
|
+
try {
|
|
475
|
+
return isJsonValueRec(value, null);
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
return false;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* @param {any} value
|
|
484
|
+
* @param {Set<object> | null} path - the containers on the current
|
|
485
|
+
* descent (a cycle is a container met twice on ONE path; a shared
|
|
486
|
+
* subtree met on two paths is fine JSON)
|
|
487
|
+
* @returns {boolean}
|
|
488
|
+
*/
|
|
489
|
+
function isJsonValueRec(value, path) {
|
|
490
|
+
switch (typeof value) {
|
|
491
|
+
case 'string':
|
|
492
|
+
case 'boolean':
|
|
493
|
+
return true;
|
|
494
|
+
case 'number':
|
|
495
|
+
return Number.isFinite(value);
|
|
496
|
+
case 'object':
|
|
497
|
+
break;
|
|
498
|
+
default:
|
|
499
|
+
return false;
|
|
500
|
+
}
|
|
501
|
+
if (value === null) return true;
|
|
502
|
+
const isArray = Array.isArray(value);
|
|
503
|
+
if (!isArray) {
|
|
504
|
+
const proto = Object.getPrototypeOf(value);
|
|
505
|
+
if (proto !== Object.prototype && proto !== null) return false;
|
|
506
|
+
}
|
|
507
|
+
if (path !== null && path.has(value)) return false;
|
|
508
|
+
const next = path ?? new Set();
|
|
509
|
+
next.add(value);
|
|
510
|
+
if (isArray) {
|
|
511
|
+
for (let i = 0; i < value.length; i++) {
|
|
512
|
+
if (!isJsonValueRec(value[i], next)) return false;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
else {
|
|
516
|
+
for (const key of Object.keys(value)) {
|
|
517
|
+
if (!isJsonValueRec(value[key], next)) return false;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
next.delete(value);
|
|
521
|
+
return true;
|
|
522
|
+
}
|
|
523
|
+
|
|
456
524
|
/**
|
|
457
525
|
* Assign a member so that a key named `__proto__` becomes an own data
|
|
458
526
|
* property instead of reassigning the object's prototype. Every builder
|
package/src/scan.js
CHANGED