@math.gl/geoarrow 5.0.0-alpha.2

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.
Files changed (47) hide show
  1. package/LICENSE +140 -0
  2. package/README.md +311 -0
  3. package/dist/builder.d.ts +90 -0
  4. package/dist/builder.d.ts.map +1 -0
  5. package/dist/builder.js +428 -0
  6. package/dist/builder.js.map +1 -0
  7. package/dist/codecs.d.ts +10 -0
  8. package/dist/codecs.d.ts.map +1 -0
  9. package/dist/codecs.js +122 -0
  10. package/dist/codecs.js.map +1 -0
  11. package/dist/index.cjs +1651 -0
  12. package/dist/index.cjs.map +6 -0
  13. package/dist/index.d.ts +12 -0
  14. package/dist/index.d.ts.map +1 -0
  15. package/dist/index.js +10 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/kernels.d.ts +51 -0
  18. package/dist/kernels.d.ts.map +1 -0
  19. package/dist/kernels.js +359 -0
  20. package/dist/kernels.js.map +1 -0
  21. package/dist/layout.d.ts +52 -0
  22. package/dist/layout.d.ts.map +1 -0
  23. package/dist/layout.js +669 -0
  24. package/dist/layout.js.map +1 -0
  25. package/dist/tessellate.d.ts +31 -0
  26. package/dist/tessellate.d.ts.map +1 -0
  27. package/dist/tessellate.js +102 -0
  28. package/dist/tessellate.js.map +1 -0
  29. package/dist/types.d.ts +113 -0
  30. package/dist/types.d.ts.map +1 -0
  31. package/dist/types.js +41 -0
  32. package/dist/types.js.map +1 -0
  33. package/dist/worker.cjs +73 -0
  34. package/dist/worker.cjs.map +6 -0
  35. package/dist/worker.d.ts +11 -0
  36. package/dist/worker.d.ts.map +1 -0
  37. package/dist/worker.js +10 -0
  38. package/dist/worker.js.map +1 -0
  39. package/package.json +48 -0
  40. package/src/builder.ts +569 -0
  41. package/src/codecs.ts +155 -0
  42. package/src/index.ts +93 -0
  43. package/src/kernels.ts +463 -0
  44. package/src/layout.ts +833 -0
  45. package/src/tessellate.ts +133 -0
  46. package/src/types.ts +200 -0
  47. package/src/worker.ts +20 -0
package/src/layout.ts ADDED
@@ -0,0 +1,833 @@
1
+ // math.gl
2
+ // SPDX-License-Identifier: MIT
3
+ // Copyright (c) vis.gl contributors
4
+
5
+ import type {
6
+ GeoArrowArray,
7
+ GeoArrowColumn,
8
+ GeoArrowCoordinateMapper,
9
+ GeoArrowDenseUnion,
10
+ GeoArrowEncoding,
11
+ GeoArrowGeometryValue,
12
+ GeoArrowList,
13
+ GeoArrowOffsets,
14
+ GeoArrowValidity
15
+ } from './types';
16
+ import {
17
+ getGeoArrowDimensionSize,
18
+ getGeoArrowEncodingForGeometry,
19
+ getGeoArrowGeometryType
20
+ } from './types';
21
+
22
+ /** Stable validation diagnostic for a physical GeoArrow descriptor. */
23
+ export type GeoArrowValidationIssue = Readonly<{
24
+ code:
25
+ | 'invalid-length'
26
+ | 'invalid-validity'
27
+ | 'invalid-offset'
28
+ | 'invalid-stride'
29
+ | 'invalid-child'
30
+ | 'invalid-union'
31
+ | 'invalid-layout'
32
+ | 'unsafe-offset';
33
+ path: string;
34
+ message: string;
35
+ }>;
36
+
37
+ /** Validation result for a physical GeoArrow descriptor. */
38
+ export type GeoArrowValidationResult = Readonly<{
39
+ valid: boolean;
40
+ issues: readonly GeoArrowValidationIssue[];
41
+ }>;
42
+
43
+ /** Value-independent and inexpensive facts about a GeoArrow column. */
44
+ export type GeoArrowColumnInspection = Readonly<{
45
+ encoding: GeoArrowEncoding;
46
+ rowCount: number;
47
+ chunkCount: number;
48
+ nullCount: number;
49
+ coordinateCount: number;
50
+ storageKinds: readonly GeoArrowArray['kind'][];
51
+ valid: boolean;
52
+ issues: readonly GeoArrowValidationIssue[];
53
+ }>;
54
+
55
+ /** Returns whether one logical value is valid at a nesting level. */
56
+ export function isGeoArrowValueValid(
57
+ validity: GeoArrowValidity | undefined,
58
+ index: number
59
+ ): boolean {
60
+ if (!validity) return true;
61
+ const bitIndex = (validity.bitOffset || 0) + index;
62
+ return Boolean(validity.values[bitIndex >> 3] & (1 << (bitIndex & 7)));
63
+ }
64
+
65
+ /** Returns the total number of logical rows across chunks. */
66
+ export function getGeoArrowRowCount(column: GeoArrowColumn): number {
67
+ return column.chunks.reduce((count, chunk) => count + chunk.length, 0);
68
+ }
69
+
70
+ /** Visits coordinates without materializing row geometry objects. */
71
+ export function visitGeoArrowCoordinates(
72
+ column: GeoArrowColumn,
73
+ visitor: GeoArrowCoordinateMapper
74
+ ): void {
75
+ let rowOffset = 0;
76
+ for (const chunk of column.chunks) {
77
+ visitChunkCoordinates(chunk, column.encoding, rowOffset, visitor);
78
+ rowOffset += chunk.length;
79
+ }
80
+ }
81
+
82
+ /** Returns a zero-copy logical slice of a column, preserving chunk boundaries. */
83
+ export function sliceGeoArrowColumn(
84
+ column: GeoArrowColumn,
85
+ begin = 0,
86
+ end = getGeoArrowRowCount(column)
87
+ ): GeoArrowColumn {
88
+ const rowCount = getGeoArrowRowCount(column);
89
+ const first = normalizeSliceIndex(begin, rowCount);
90
+ const last = Math.max(first, normalizeSliceIndex(end, rowCount));
91
+ if (first === 0 && last === rowCount) return column;
92
+
93
+ const chunks: GeoArrowArray[] = [];
94
+ let chunkStart = 0;
95
+ for (const chunk of column.chunks) {
96
+ const chunkEnd = chunkStart + chunk.length;
97
+ const localFirst = Math.max(0, first - chunkStart);
98
+ const localLast = Math.min(chunk.length, last - chunkStart);
99
+ if (localLast > localFirst) {
100
+ chunks.push(sliceGeoArrowArray(chunk, localFirst, localLast));
101
+ }
102
+ chunkStart = chunkEnd;
103
+ if (chunkStart >= last) break;
104
+ }
105
+ return {...column, chunks};
106
+ }
107
+
108
+ /** Returns a zero-copy logical slice of one physical array. */
109
+ export function sliceGeoArrowArray(
110
+ array: GeoArrowArray,
111
+ begin: number,
112
+ end: number
113
+ ): GeoArrowArray {
114
+ if (begin === 0 && end === array.length) return array;
115
+ const length = end - begin;
116
+ const validity = array.validity
117
+ ? {...array.validity, bitOffset: (array.validity.bitOffset || 0) + begin}
118
+ : undefined;
119
+ switch (array.kind) {
120
+ case 'primitive':
121
+ return {
122
+ ...array,
123
+ length,
124
+ offset: (array.offset || 0) + begin * (array.stride || 1),
125
+ validity
126
+ };
127
+ case 'fixed-size-list':
128
+ return {...array, length, offset: (array.offset || 0) + begin, validity};
129
+ case 'list':
130
+ case 'serialized':
131
+ return {...array, length, offset: (array.offset || 0) + begin, validity};
132
+ case 'struct':
133
+ return {...array, length, offset: (array.offset || 0) + begin, validity};
134
+ case 'dense-union':
135
+ return {...array, length, offset: (array.offset || 0) + begin, validity};
136
+ }
137
+ }
138
+
139
+ /** Validates physical bounds, list offsets, union dispatch, and declared coordinate layout. */
140
+ export function validateGeoArrowColumn(column: GeoArrowColumn): GeoArrowValidationResult {
141
+ const issues: GeoArrowValidationIssue[] = [];
142
+ for (let chunkIndex = 0; chunkIndex < column.chunks.length; chunkIndex++) {
143
+ validateArray(column.chunks[chunkIndex], `chunks[${chunkIndex}]`, issues);
144
+ }
145
+ validateColumnLayout(column, issues);
146
+ return {valid: issues.length === 0, issues};
147
+ }
148
+
149
+ /** Inspects a column without decoding serialized geometry or creating row objects. */
150
+ export function inspectGeoArrowColumn(column: GeoArrowColumn): GeoArrowColumnInspection {
151
+ const validation = validateGeoArrowColumn(column);
152
+ const storageKinds: GeoArrowArray['kind'][] = [];
153
+ let nullCount = 0;
154
+ for (const chunk of column.chunks) {
155
+ collectStorageKinds(chunk, storageKinds);
156
+ for (let rowIndex = 0; rowIndex < chunk.length; rowIndex++) {
157
+ if (!isGeoArrowValueValid(chunk.validity, rowIndex)) nullCount++;
158
+ }
159
+ }
160
+ return {
161
+ encoding: column.encoding,
162
+ rowCount: getGeoArrowRowCount(column),
163
+ chunkCount: column.chunks.length,
164
+ nullCount,
165
+ coordinateCount: getGeoArrowVertexCount(column),
166
+ storageKinds,
167
+ valid: validation.valid,
168
+ issues: validation.issues
169
+ };
170
+ }
171
+
172
+ /** Counts coordinate tuples without materializing geometry objects. */
173
+ export function getGeoArrowVertexCount(column: GeoArrowColumn): number {
174
+ if (
175
+ column.encoding === 'geoarrow.wkb' ||
176
+ column.encoding === 'geoarrow.wkt' ||
177
+ column.encoding === 'geoarrow.box'
178
+ ) {
179
+ return 0;
180
+ }
181
+ let count = 0;
182
+ visitGeoArrowCoordinates(column, coordinate => {
183
+ count++;
184
+ return coordinate;
185
+ });
186
+ return count;
187
+ }
188
+
189
+ /** Collects unique transferable ArrayBuffers borrowed by a column. */
190
+ export function getGeoArrowTransferList(column: GeoArrowColumn): ArrayBuffer[] {
191
+ const buffers = new Set<ArrayBuffer>();
192
+ for (const chunk of column.chunks) collectArrayBuffers(chunk, buffers);
193
+ return [...buffers];
194
+ }
195
+
196
+ /** Materializes column rows for codecs and structural algorithms. */
197
+ export function materializeGeoArrowRows(
198
+ column: GeoArrowColumn
199
+ ): Array<GeoArrowGeometryValue | null> {
200
+ if (column.encoding === 'geoarrow.wkb' || column.encoding === 'geoarrow.wkt') {
201
+ throw new Error('Serialized GeoArrow columns must be decoded before materialization');
202
+ }
203
+ const rows: Array<GeoArrowGeometryValue | null> = [];
204
+ for (const chunk of column.chunks) {
205
+ for (let rowIndex = 0; rowIndex < chunk.length; rowIndex++) {
206
+ rows.push(materializeGeometryRow(chunk, rowIndex, column.encoding));
207
+ }
208
+ }
209
+ return rows;
210
+ }
211
+
212
+ /** Materializes one row from a physical array. */
213
+ export function materializeGeometryRow(
214
+ array: GeoArrowArray,
215
+ rowIndex: number,
216
+ encoding: GeoArrowEncoding
217
+ ): GeoArrowGeometryValue | null {
218
+ if (!isGeoArrowValueValid(array.validity, rowIndex)) return null;
219
+ if (encoding === 'geoarrow.geometry') {
220
+ if (array.kind !== 'dense-union') return null;
221
+ return materializeUnionRow(array, rowIndex);
222
+ }
223
+ if (encoding === 'geoarrow.geometrycollection') {
224
+ if (array.kind !== 'list') return null;
225
+ const [first, last] = getListRange(array, rowIndex);
226
+ const geometries: GeoArrowGeometryValue[] = [];
227
+ for (let index = first; index < last; index++) {
228
+ if (array.child.kind !== 'dense-union') continue;
229
+ const geometry = materializeUnionRow(array.child, index);
230
+ if (geometry) geometries.push(geometry);
231
+ }
232
+ return {type: 'GeometryCollection', geometries};
233
+ }
234
+ const geometryType = getGeoArrowGeometryType(encoding);
235
+ if (!geometryType || geometryType === 'GeometryCollection') return null;
236
+ const depth = getEncodingDepth(encoding);
237
+ const coordinates = readNestedCoordinates(array, rowIndex, depth);
238
+ if (!coordinates) return null;
239
+ return {type: geometryType, coordinates} as GeoArrowGeometryValue;
240
+ }
241
+
242
+ /** Returns a safe numeric offset. */
243
+ export function getGeoArrowOffset(offsets: GeoArrowOffsets, index: number): number {
244
+ const value = offsets[index];
245
+ const numericValue = typeof value === 'bigint' ? Number(value) : value;
246
+ if (!Number.isSafeInteger(numericValue)) {
247
+ throw new Error(`GeoArrow offset ${String(value)} exceeds JavaScript's safe integer range`);
248
+ }
249
+ return numericValue;
250
+ }
251
+
252
+ /** Returns the child range for one list row. */
253
+ export function getListRange(list: GeoArrowList, rowIndex: number): [number, number] {
254
+ const offsetIndex = (list.offset || 0) + rowIndex;
255
+ const baseValue = list.offsetBase ?? 0;
256
+ const base = typeof baseValue === 'bigint' ? Number(baseValue) : baseValue;
257
+ return [
258
+ getGeoArrowOffset(list.offsets, offsetIndex) - base,
259
+ getGeoArrowOffset(list.offsets, offsetIndex + 1) - base
260
+ ];
261
+ }
262
+
263
+ function visitChunkCoordinates(
264
+ array: GeoArrowArray,
265
+ encoding: GeoArrowEncoding,
266
+ rowOffset: number,
267
+ visitor: GeoArrowCoordinateMapper
268
+ ): void {
269
+ for (let rowIndex = 0; rowIndex < array.length; rowIndex++) {
270
+ if (!isGeoArrowValueValid(array.validity, rowIndex)) continue;
271
+ if (encoding === 'geoarrow.geometry' && array.kind === 'dense-union') {
272
+ visitUnionRowCoordinates(array, rowIndex, rowOffset + rowIndex, visitor);
273
+ continue;
274
+ }
275
+ if (encoding === 'geoarrow.geometrycollection' && array.kind === 'list') {
276
+ const [first, last] = getListRange(array, rowIndex);
277
+ if (array.child.kind === 'dense-union') {
278
+ for (let index = first; index < last; index++) {
279
+ visitUnionRowCoordinates(array.child, index, rowOffset + rowIndex, visitor);
280
+ }
281
+ }
282
+ continue;
283
+ }
284
+ const depth = getEncodingDepth(encoding);
285
+ visitNestedCoordinates(array, rowIndex, depth, rowOffset + rowIndex, visitor);
286
+ }
287
+ }
288
+
289
+ function visitUnionRowCoordinates(
290
+ union: GeoArrowDenseUnion,
291
+ rowIndex: number,
292
+ sourceRowIndex: number,
293
+ visitor: GeoArrowCoordinateMapper
294
+ ): void {
295
+ if (!isGeoArrowValueValid(union.validity, rowIndex)) return;
296
+ const physicalIndex = (union.offset || 0) + rowIndex;
297
+ const typeId = union.typeIds[physicalIndex];
298
+ const valueOffset = union.valueOffsets[physicalIndex];
299
+ const child = union.children.find(candidate => candidate.typeId === typeId);
300
+ if (!child) return;
301
+ const encoding = getEncodingFromChildName(child.name);
302
+ if (encoding === 'geoarrow.geometrycollection' && child.data.kind === 'list') {
303
+ const [first, last] = getListRange(child.data, valueOffset);
304
+ if (child.data.child.kind === 'dense-union') {
305
+ for (let index = first; index < last; index++) {
306
+ visitUnionRowCoordinates(child.data.child, index, sourceRowIndex, visitor);
307
+ }
308
+ }
309
+ return;
310
+ }
311
+ visitNestedCoordinates(
312
+ child.data,
313
+ valueOffset,
314
+ getEncodingDepth(encoding),
315
+ sourceRowIndex,
316
+ visitor
317
+ );
318
+ }
319
+
320
+ function visitNestedCoordinates(
321
+ array: GeoArrowArray,
322
+ index: number,
323
+ depth: number,
324
+ rowIndex: number,
325
+ visitor: GeoArrowCoordinateMapper
326
+ ): void {
327
+ if (depth === 0) {
328
+ const coordinate = readCoordinate(array, index);
329
+ if (coordinate) visitor(coordinate, rowIndex);
330
+ return;
331
+ }
332
+ if (array.kind !== 'list' || !isGeoArrowValueValid(array.validity, index)) return;
333
+ const [first, last] = getListRange(array, index);
334
+ for (let childIndex = first; childIndex < last; childIndex++) {
335
+ visitNestedCoordinates(array.child, childIndex, depth - 1, rowIndex, visitor);
336
+ }
337
+ }
338
+
339
+ function readNestedCoordinates(array: GeoArrowArray, index: number, depth: number): unknown {
340
+ if (depth === 0) return readCoordinate(array, index);
341
+ if (array.kind !== 'list' || !isGeoArrowValueValid(array.validity, index)) return null;
342
+ const [first, last] = getListRange(array, index);
343
+ const values: unknown[] = [];
344
+ for (let childIndex = first; childIndex < last; childIndex++) {
345
+ const value = readNestedCoordinates(array.child, childIndex, depth - 1);
346
+ if (value !== null) values.push(value);
347
+ }
348
+ return values;
349
+ }
350
+
351
+ function readCoordinate(array: GeoArrowArray, index: number): number[] | null {
352
+ if (!isGeoArrowValueValid(array.validity, index)) return null;
353
+ if (array.kind === 'fixed-size-list') {
354
+ const listIndex = (array.offset || 0) + index;
355
+ const coordinate: number[] = [];
356
+ for (let component = 0; component < array.size; component++) {
357
+ coordinate.push(readPrimitive(array.child, listIndex * array.size + component));
358
+ }
359
+ return coordinate;
360
+ }
361
+ if (array.kind === 'struct') {
362
+ const structIndex = (array.offset || 0) + index;
363
+ const coordinate: number[] = [];
364
+ for (const name of ['x', 'y', 'z', 'm']) {
365
+ const child = array.children[name];
366
+ if (child) coordinate.push(readPrimitive(child, structIndex));
367
+ }
368
+ return coordinate.length >= 2 ? coordinate : null;
369
+ }
370
+ return null;
371
+ }
372
+
373
+ function readPrimitive(array: GeoArrowArray, index: number): number {
374
+ if (array.kind !== 'primitive') throw new Error('GeoArrow coordinate child must be primitive');
375
+ const valueIndex = (array.offset || 0) + index * (array.stride || 1);
376
+ return Number(array.values[valueIndex]);
377
+ }
378
+
379
+ function materializeUnionRow(
380
+ union: GeoArrowDenseUnion,
381
+ rowIndex: number
382
+ ): GeoArrowGeometryValue | null {
383
+ if (!isGeoArrowValueValid(union.validity, rowIndex)) return null;
384
+ const physicalIndex = (union.offset || 0) + rowIndex;
385
+ const typeId = union.typeIds[physicalIndex];
386
+ const valueOffset = union.valueOffsets[physicalIndex];
387
+ const child = union.children.find(candidate => candidate.typeId === typeId);
388
+ if (!child || valueOffset < 0 || valueOffset >= child.data.length) return null;
389
+ return materializeGeometryRow(child.data, valueOffset, getEncodingFromChildName(child.name));
390
+ }
391
+
392
+ function getEncodingFromChildName(name: string): GeoArrowEncoding {
393
+ const normalized = name.replace(/[^a-z]/gi, '').toLowerCase();
394
+ switch (normalized) {
395
+ case 'point':
396
+ return 'geoarrow.point';
397
+ case 'linestring':
398
+ return 'geoarrow.linestring';
399
+ case 'polygon':
400
+ return 'geoarrow.polygon';
401
+ case 'multipoint':
402
+ return 'geoarrow.multipoint';
403
+ case 'multilinestring':
404
+ return 'geoarrow.multilinestring';
405
+ case 'multipolygon':
406
+ return 'geoarrow.multipolygon';
407
+ case 'geometrycollection':
408
+ return 'geoarrow.geometrycollection';
409
+ default:
410
+ throw new Error(`Unknown GeoArrow dense-union child ${name}`);
411
+ }
412
+ }
413
+
414
+ function getEncodingDepth(encoding: GeoArrowEncoding): 0 | 1 | 2 | 3 {
415
+ switch (encoding) {
416
+ case 'geoarrow.point':
417
+ return 0;
418
+ case 'geoarrow.linestring':
419
+ case 'geoarrow.multipoint':
420
+ return 1;
421
+ case 'geoarrow.polygon':
422
+ case 'geoarrow.multilinestring':
423
+ return 2;
424
+ case 'geoarrow.multipolygon':
425
+ return 3;
426
+ default:
427
+ return 0;
428
+ }
429
+ }
430
+
431
+ function validateArray(
432
+ array: GeoArrowArray,
433
+ path: string,
434
+ issues: GeoArrowValidationIssue[]
435
+ ): void {
436
+ if (!Number.isSafeInteger(array.length) || array.length < 0) {
437
+ issues.push({
438
+ code: 'invalid-length',
439
+ path,
440
+ message: 'Length must be a non-negative safe integer.'
441
+ });
442
+ return;
443
+ }
444
+ validateValidity(array.validity, array.length, path, issues);
445
+ switch (array.kind) {
446
+ case 'primitive': {
447
+ const stride = array.stride || 1;
448
+ const offset = array.offset || 0;
449
+ if (!Number.isSafeInteger(stride) || stride < 1) {
450
+ issues.push({code: 'invalid-stride', path, message: 'Primitive stride must be positive.'});
451
+ }
452
+ const requiredLength = array.length === 0 ? offset : offset + (array.length - 1) * stride + 1;
453
+ if (offset < 0 || requiredLength > array.values.length) {
454
+ issues.push({
455
+ code: 'invalid-length',
456
+ path,
457
+ message: 'Primitive values do not cover the logical range.'
458
+ });
459
+ }
460
+ break;
461
+ }
462
+ case 'fixed-size-list': {
463
+ const offset = array.offset ?? 0;
464
+ const validOffset = Number.isSafeInteger(offset) && offset >= 0;
465
+ if (!validOffset) {
466
+ issues.push({
467
+ code: 'invalid-offset',
468
+ path,
469
+ message: 'Fixed-size list offset must be a non-negative safe integer.'
470
+ });
471
+ }
472
+ if (!Number.isSafeInteger(array.size) || array.size < 1) {
473
+ issues.push({
474
+ code: 'invalid-length',
475
+ path,
476
+ message: 'Fixed-size list size must be positive.'
477
+ });
478
+ }
479
+ const childEnd = (offset + array.length) * array.size;
480
+ if (validOffset && childEnd > array.child.length) {
481
+ issues.push({code: 'invalid-child', path, message: 'Fixed-size list child is too short.'});
482
+ }
483
+ validateArray(array.child, `${path}.child`, issues);
484
+ break;
485
+ }
486
+ case 'list':
487
+ validateList(array, path, issues);
488
+ validateArray(array.child, `${path}.child`, issues);
489
+ break;
490
+ case 'struct':
491
+ if (Object.keys(array.children).length === 0) {
492
+ issues.push({code: 'invalid-child', path, message: 'Struct must contain children.'});
493
+ }
494
+ for (const [name, child] of Object.entries(array.children)) {
495
+ if ((array.offset || 0) + array.length > child.length) {
496
+ issues.push({
497
+ code: 'invalid-child',
498
+ path: `${path}.${name}`,
499
+ message: 'Struct child is too short.'
500
+ });
501
+ }
502
+ validateArray(child, `${path}.${name}`, issues);
503
+ }
504
+ break;
505
+ case 'dense-union': {
506
+ const end = (array.offset || 0) + array.length;
507
+ if (end > array.typeIds.length || end > array.valueOffsets.length) {
508
+ issues.push({
509
+ code: 'invalid-union',
510
+ path,
511
+ message: 'Dense-union dispatch buffers are too short.'
512
+ });
513
+ }
514
+ const childIds = new Set<number>();
515
+ for (const child of array.children) {
516
+ if (childIds.has(child.typeId)) {
517
+ issues.push({
518
+ code: 'invalid-union',
519
+ path,
520
+ message: `Duplicate dense-union type ID ${child.typeId}.`
521
+ });
522
+ }
523
+ childIds.add(child.typeId);
524
+ validateArray(child.data, `${path}.${child.name}`, issues);
525
+ }
526
+ for (let index = array.offset || 0; index < end; index++) {
527
+ if (!isGeoArrowValueValid(array.validity, index - (array.offset || 0))) continue;
528
+ const child = array.children.find(candidate => candidate.typeId === array.typeIds[index]);
529
+ const valueOffset = array.valueOffsets[index];
530
+ if (!child || valueOffset < 0 || valueOffset >= child.data.length) {
531
+ issues.push({
532
+ code: 'invalid-union',
533
+ path: `${path}[${index}]`,
534
+ message: 'Dense-union row references an invalid child value.'
535
+ });
536
+ }
537
+ }
538
+ break;
539
+ }
540
+ case 'serialized': {
541
+ validateOffsets(
542
+ array.offsets,
543
+ array.offset || 0,
544
+ array.length,
545
+ array.values.length,
546
+ array.offsetBase,
547
+ path,
548
+ issues
549
+ );
550
+ break;
551
+ }
552
+ }
553
+ }
554
+
555
+ function validateList(list: GeoArrowList, path: string, issues: GeoArrowValidationIssue[]): void {
556
+ validateOffsets(
557
+ list.offsets,
558
+ list.offset || 0,
559
+ list.length,
560
+ list.child.length,
561
+ list.offsetBase,
562
+ path,
563
+ issues
564
+ );
565
+ }
566
+
567
+ function validateOffsets(
568
+ offsets: GeoArrowOffsets,
569
+ offset: number,
570
+ length: number,
571
+ childLength: number,
572
+ offsetBase: number | bigint | undefined,
573
+ path: string,
574
+ issues: GeoArrowValidationIssue[]
575
+ ): void {
576
+ if (offset < 0 || offset + length >= offsets.length) {
577
+ issues.push({code: 'invalid-offset', path, message: 'Offset buffer is too short.'});
578
+ return;
579
+ }
580
+ const base = typeof offsetBase === 'bigint' ? Number(offsetBase) : offsetBase || 0;
581
+ let previous: number;
582
+ try {
583
+ previous = getGeoArrowOffset(offsets, offset) - base;
584
+ } catch (error) {
585
+ issues.push({code: 'unsafe-offset', path, message: (error as Error).message});
586
+ return;
587
+ }
588
+ for (let index = 1; index <= length; index++) {
589
+ let current: number;
590
+ try {
591
+ current = getGeoArrowOffset(offsets, offset + index) - base;
592
+ } catch (error) {
593
+ issues.push({code: 'unsafe-offset', path, message: (error as Error).message});
594
+ return;
595
+ }
596
+ if (current < previous || current < 0 || current > childLength) {
597
+ issues.push({
598
+ code: 'invalid-offset',
599
+ path,
600
+ message: 'Offsets must be monotonic and within child storage.'
601
+ });
602
+ return;
603
+ }
604
+ previous = current;
605
+ }
606
+ }
607
+
608
+ function validateValidity(
609
+ validity: GeoArrowValidity | undefined,
610
+ length: number,
611
+ path: string,
612
+ issues: GeoArrowValidationIssue[]
613
+ ): void {
614
+ if (!validity) return;
615
+ const bitOffset = validity.bitOffset || 0;
616
+ if (bitOffset < 0 || bitOffset + length > validity.values.length * 8) {
617
+ issues.push({code: 'invalid-validity', path, message: 'Validity bitmap is too short.'});
618
+ }
619
+ }
620
+
621
+ function validateColumnLayout(column: GeoArrowColumn, issues: GeoArrowValidationIssue[]): void {
622
+ if (column.encoding === 'geoarrow.wkb' || column.encoding === 'geoarrow.wkt') {
623
+ for (const [index, chunk] of column.chunks.entries()) {
624
+ const expectedEncoding = column.encoding === 'geoarrow.wkb' ? 'binary' : 'utf8';
625
+ if (chunk.kind !== 'serialized' || chunk.encoding !== expectedEncoding) {
626
+ issues.push({
627
+ code: 'invalid-layout',
628
+ path: `chunks[${index}]`,
629
+ message: `Serialized encoding requires ${expectedEncoding} storage.`
630
+ });
631
+ }
632
+ }
633
+ return;
634
+ }
635
+ if (column.encoding === 'geoarrow.box') {
636
+ for (const [index, chunk] of column.chunks.entries()) {
637
+ if (chunk.kind !== 'struct') {
638
+ addInvalidLayout(`chunks[${index}]`, 'Box encoding requires struct storage.', issues);
639
+ continue;
640
+ }
641
+ const requiredNames =
642
+ column.dimension === 'xy'
643
+ ? ['xmin', 'ymin', 'xmax', 'ymax']
644
+ : column.dimension === 'xyz'
645
+ ? ['xmin', 'ymin', 'zmin', 'xmax', 'ymax', 'zmax']
646
+ : column.dimension === 'xym'
647
+ ? ['xmin', 'ymin', 'mmin', 'xmax', 'ymax', 'mmax']
648
+ : ['xmin', 'ymin', 'zmin', 'mmin', 'xmax', 'ymax', 'zmax', 'mmax'];
649
+ for (const name of requiredNames) {
650
+ if (chunk.children[name]?.kind !== 'primitive') {
651
+ addInvalidLayout(
652
+ `chunks[${index}].${name}`,
653
+ `Box storage requires primitive ${name}.`,
654
+ issues
655
+ );
656
+ }
657
+ }
658
+ }
659
+ return;
660
+ }
661
+ if (!column.coordinateLayout) {
662
+ addInvalidLayout('coordinateLayout', 'Native geometry requires a coordinate layout.', issues);
663
+ return;
664
+ }
665
+ for (const [index, chunk] of column.chunks.entries()) {
666
+ validateGeometryLayout(chunk, column.encoding, column, `chunks[${index}]`, issues);
667
+ }
668
+ }
669
+
670
+ function validateGeometryLayout(
671
+ array: GeoArrowArray,
672
+ encoding: GeoArrowEncoding,
673
+ column: GeoArrowColumn,
674
+ path: string,
675
+ issues: GeoArrowValidationIssue[]
676
+ ): void {
677
+ if (encoding === 'geoarrow.geometry') {
678
+ if (array.kind !== 'dense-union') {
679
+ addInvalidLayout(path, 'Mixed geometry encoding requires dense-union storage.', issues);
680
+ return;
681
+ }
682
+ validateUnionLayouts(array, column, path, issues);
683
+ return;
684
+ }
685
+ if (encoding === 'geoarrow.geometrycollection') {
686
+ if (array.kind !== 'list' || array.child.kind !== 'dense-union') {
687
+ addInvalidLayout(path, 'GeometryCollection requires list-of-dense-union storage.', issues);
688
+ return;
689
+ }
690
+ validateUnionLayouts(array.child, column, `${path}.child`, issues);
691
+ return;
692
+ }
693
+ let coordinateArray = array;
694
+ const depth = getEncodingDepth(encoding);
695
+ for (let level = 0; level < depth; level++) {
696
+ if (coordinateArray.kind !== 'list') {
697
+ addInvalidLayout(path, `${encoding} requires ${depth} variable-list levels.`, issues);
698
+ return;
699
+ }
700
+ coordinateArray = coordinateArray.child;
701
+ }
702
+ validateCoordinateLayout(coordinateArray, column, path, issues);
703
+ }
704
+
705
+ function validateUnionLayouts(
706
+ union: GeoArrowDenseUnion,
707
+ column: GeoArrowColumn,
708
+ path: string,
709
+ issues: GeoArrowValidationIssue[]
710
+ ): void {
711
+ for (const child of union.children) {
712
+ let encoding: GeoArrowEncoding;
713
+ try {
714
+ encoding = getEncodingFromChildName(child.name);
715
+ } catch (error) {
716
+ addInvalidLayout(`${path}.${child.name}`, (error as Error).message, issues);
717
+ continue;
718
+ }
719
+ validateGeometryLayout(child.data, encoding, column, `${path}.${child.name}`, issues);
720
+ }
721
+ }
722
+
723
+ function validateCoordinateLayout(
724
+ array: GeoArrowArray,
725
+ column: GeoArrowColumn,
726
+ path: string,
727
+ issues: GeoArrowValidationIssue[]
728
+ ): void {
729
+ const dimensionSize = getGeoArrowDimensionSize(column.dimension);
730
+ if (column.coordinateLayout === 'interleaved') {
731
+ if (
732
+ array.kind !== 'fixed-size-list' ||
733
+ array.size !== dimensionSize ||
734
+ array.child.kind !== 'primitive'
735
+ ) {
736
+ addInvalidLayout(
737
+ path,
738
+ `Interleaved ${column.dimension} coordinates require fixed-size-list<${dimensionSize}> primitive storage.`,
739
+ issues
740
+ );
741
+ }
742
+ return;
743
+ }
744
+ if (array.kind !== 'struct') {
745
+ addInvalidLayout(
746
+ path,
747
+ `Separated ${column.dimension} coordinates require struct storage.`,
748
+ issues
749
+ );
750
+ return;
751
+ }
752
+ const requiredNames =
753
+ column.dimension === 'xy'
754
+ ? ['x', 'y']
755
+ : column.dimension === 'xyz'
756
+ ? ['x', 'y', 'z']
757
+ : column.dimension === 'xym'
758
+ ? ['x', 'y', 'm']
759
+ : ['x', 'y', 'z', 'm'];
760
+ for (const name of requiredNames) {
761
+ if (array.children[name]?.kind !== 'primitive') {
762
+ addInvalidLayout(
763
+ `${path}.${name}`,
764
+ `Separated coordinates require primitive ${name}.`,
765
+ issues
766
+ );
767
+ }
768
+ }
769
+ }
770
+
771
+ function addInvalidLayout(path: string, message: string, issues: GeoArrowValidationIssue[]): void {
772
+ issues.push({code: 'invalid-layout', path, message});
773
+ }
774
+
775
+ function collectStorageKinds(array: GeoArrowArray, kinds: GeoArrowArray['kind'][]): void {
776
+ kinds.push(array.kind);
777
+ switch (array.kind) {
778
+ case 'fixed-size-list':
779
+ case 'list':
780
+ collectStorageKinds(array.child, kinds);
781
+ break;
782
+ case 'struct':
783
+ for (const child of Object.values(array.children)) collectStorageKinds(child, kinds);
784
+ break;
785
+ case 'dense-union':
786
+ for (const child of array.children) collectStorageKinds(child.data, kinds);
787
+ break;
788
+ default:
789
+ break;
790
+ }
791
+ }
792
+
793
+ function collectArrayBuffers(array: GeoArrowArray, buffers: Set<ArrayBuffer>): void {
794
+ if (array.validity) addBuffer(array.validity.values.buffer, buffers);
795
+ switch (array.kind) {
796
+ case 'primitive':
797
+ addBuffer(array.values.buffer, buffers);
798
+ break;
799
+ case 'fixed-size-list':
800
+ collectArrayBuffers(array.child, buffers);
801
+ break;
802
+ case 'list':
803
+ addBuffer(array.offsets.buffer, buffers);
804
+ collectArrayBuffers(array.child, buffers);
805
+ break;
806
+ case 'struct':
807
+ for (const child of Object.values(array.children)) collectArrayBuffers(child, buffers);
808
+ break;
809
+ case 'dense-union':
810
+ addBuffer(array.typeIds.buffer, buffers);
811
+ addBuffer(array.valueOffsets.buffer, buffers);
812
+ for (const child of array.children) collectArrayBuffers(child.data, buffers);
813
+ break;
814
+ case 'serialized':
815
+ addBuffer(array.offsets.buffer, buffers);
816
+ addBuffer(array.values.buffer, buffers);
817
+ break;
818
+ }
819
+ }
820
+
821
+ function addBuffer(buffer: ArrayBufferLike, buffers: Set<ArrayBuffer>): void {
822
+ if (buffer instanceof ArrayBuffer) buffers.add(buffer);
823
+ }
824
+
825
+ function normalizeSliceIndex(index: number, length: number): number {
826
+ const normalized = index < 0 ? Math.max(0, length + index) : Math.min(length, index);
827
+ return Math.max(0, Math.trunc(normalized));
828
+ }
829
+
830
+ /** Returns a concrete encoding for a geometry value. */
831
+ export function getEncodingForGeometryValue(geometry: GeoArrowGeometryValue): GeoArrowEncoding {
832
+ return getGeoArrowEncodingForGeometry(geometry.type);
833
+ }