@honua/app-platform 0.1.11-beta.0 → 0.1.13
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/columnar/apache-arrow.d.ts +17 -0
- package/columnar/apache-arrow.d.ts.map +1 -0
- package/columnar/apache-arrow.js +1036 -0
- package/columnar/batch-cache-conformance.d.ts +66 -0
- package/columnar/batch-cache-conformance.d.ts.map +1 -0
- package/columnar/batch-cache-conformance.js +317 -0
- package/columnar/batch-cache-indexeddb.d.ts +31 -0
- package/columnar/batch-cache-indexeddb.d.ts.map +1 -0
- package/columnar/batch-cache-indexeddb.js +148 -0
- package/columnar/batch-cache.d.ts +298 -0
- package/columnar/batch-cache.d.ts.map +1 -0
- package/columnar/batch-cache.js +757 -0
- package/columnar/geoarrow-aggregate.d.ts +178 -0
- package/columnar/geoarrow-aggregate.d.ts.map +1 -0
- package/columnar/geoarrow-aggregate.js +976 -0
- package/columnar/geoarrow-patch.d.ts +382 -0
- package/columnar/geoarrow-patch.d.ts.map +1 -0
- package/columnar/geoarrow-patch.js +1656 -0
- package/columnar/geoarrow-reproject.d.ts +22 -0
- package/columnar/geoarrow-reproject.d.ts.map +1 -0
- package/columnar/geoarrow-reproject.js +119 -0
- package/columnar/geoarrow-serialization.d.ts +68 -0
- package/columnar/geoarrow-serialization.d.ts.map +1 -0
- package/columnar/geoarrow-serialization.js +331 -0
- package/columnar/geoarrow-types.d.ts +271 -0
- package/columnar/geoarrow-types.d.ts.map +1 -0
- package/columnar/geoarrow-types.js +15 -0
- package/columnar/geoarrow.d.ts +52 -0
- package/columnar/geoarrow.d.ts.map +1 -0
- package/columnar/geoarrow.js +1469 -0
- package/columnar/index.d.ts +43 -0
- package/columnar/index.d.ts.map +1 -0
- package/columnar/index.js +29 -0
- package/columnar/operators.d.ts +70 -0
- package/columnar/operators.d.ts.map +1 -0
- package/columnar/operators.js +307 -0
- package/columnar/result.d.ts +369 -0
- package/columnar/result.d.ts.map +1 -0
- package/columnar/result.js +815 -0
- package/columnar/telemetry.d.ts +152 -0
- package/columnar/telemetry.d.ts.map +1 -0
- package/columnar/telemetry.js +265 -0
- package/columnar/transfer.d.ts +28 -0
- package/columnar/transfer.d.ts.map +1 -0
- package/columnar/transfer.js +669 -0
- package/columnar/types.d.ts +175 -0
- package/columnar/types.d.ts.map +1 -0
- package/columnar/types.js +22 -0
- package/columnar/worker.d.ts +157 -0
- package/columnar/worker.d.ts.map +1 -0
- package/columnar/worker.js +1059 -0
- package/contract/pmtiles.d.ts +6 -5
- package/contract/pmtiles.d.ts.map +1 -1
- package/contract/pmtiles.js +30 -5
- package/control-plane/generated/admin-operations.d.ts +3 -3
- package/control-plane/generated/admin-operations.d.ts.map +1 -1
- package/control-plane/generated/admin-operations.js +3 -3
- package/controls/catalog.d.ts.map +1 -1
- package/controls/catalog.js +110 -0
- package/controls/index.d.ts +1 -1
- package/controls/index.js +1 -1
- package/controls/types.d.ts +1 -1
- package/controls/types.js +1 -1
- package/core/client.d.ts.map +1 -1
- package/core/client.js +87 -19
- package/core/deprecation-notice.d.ts +28 -0
- package/core/deprecation-notice.d.ts.map +1 -0
- package/core/deprecation-notice.js +22 -0
- package/core/esri-geojson.d.ts.map +1 -1
- package/core/esri-geojson.js +10 -6
- package/core/optional-grpc-peer.d.ts +15 -0
- package/core/optional-grpc-peer.d.ts.map +1 -0
- package/core/optional-grpc-peer.js +60 -0
- package/esri-compat/basemap-gallery.d.ts +2 -0
- package/esri-compat/basemap-gallery.d.ts.map +1 -1
- package/esri-compat/basemap-gallery.js +12 -0
- package/esri-compat/basemap.d.ts +4 -0
- package/esri-compat/basemap.d.ts.map +1 -1
- package/esri-compat/basemap.js +70 -0
- package/esri-compat/bookmarks.d.ts +2 -0
- package/esri-compat/bookmarks.d.ts.map +1 -1
- package/esri-compat/bookmarks.js +12 -0
- package/esri-compat/controls.d.ts +18 -0
- package/esri-compat/controls.d.ts.map +1 -1
- package/esri-compat/controls.js +120 -57
- package/esri-compat/directions.d.ts +4 -0
- package/esri-compat/directions.d.ts.map +1 -1
- package/esri-compat/directions.js +30 -20
- package/esri-compat/editor.d.ts +4 -0
- package/esri-compat/editor.d.ts.map +1 -1
- package/esri-compat/editor.js +16 -0
- package/esri-compat/esri-config.d.ts +2 -1
- package/esri-compat/esri-config.d.ts.map +1 -1
- package/esri-compat/feature-effect.d.ts +23 -0
- package/esri-compat/feature-effect.d.ts.map +1 -0
- package/esri-compat/feature-effect.js +12 -0
- package/esri-compat/feature-form.d.ts +6 -0
- package/esri-compat/feature-form.d.ts.map +1 -1
- package/esri-compat/feature-form.js +63 -0
- package/esri-compat/feature-layer.d.ts +21 -2
- package/esri-compat/feature-layer.d.ts.map +1 -1
- package/esri-compat/feature-layer.js +172 -1
- package/esri-compat/feature-table.d.ts +5 -0
- package/esri-compat/feature-table.d.ts.map +1 -1
- package/esri-compat/feature-table.js +23 -0
- package/esri-compat/identity-manager.d.ts +1 -1
- package/esri-compat/identity-manager.js +1 -1
- package/esri-compat/legend.d.ts +2 -0
- package/esri-compat/legend.d.ts.map +1 -1
- package/esri-compat/legend.js +98 -0
- package/esri-compat/locator.d.ts +1 -1
- package/esri-compat/locator.js +1 -1
- package/esri-compat/map-view-mount.d.ts +1 -0
- package/esri-compat/map-view-mount.d.ts.map +1 -1
- package/esri-compat/map-view-mount.js +46 -25
- package/esri-compat/map-view.d.ts +13 -0
- package/esri-compat/map-view.d.ts.map +1 -1
- package/esri-compat/map-view.js +94 -8
- package/esri-compat/map.d.ts +5 -1
- package/esri-compat/map.d.ts.map +1 -1
- package/esri-compat/map.js +10 -0
- package/esri-compat/measurement.d.ts +2 -0
- package/esri-compat/measurement.d.ts.map +1 -1
- package/esri-compat/measurement.js +14 -0
- package/esri-compat/point.d.ts +7 -0
- package/esri-compat/point.d.ts.map +1 -1
- package/esri-compat/point.js +33 -3
- package/esri-compat/popup.d.ts +2 -0
- package/esri-compat/popup.d.ts.map +1 -1
- package/esri-compat/popup.js +12 -0
- package/esri-compat/portal.d.ts +1 -1
- package/esri-compat/portal.js +1 -1
- package/esri-compat/print.d.ts +3 -0
- package/esri-compat/print.d.ts.map +1 -1
- package/esri-compat/print.js +15 -0
- package/esri-compat/search.d.ts +4 -0
- package/esri-compat/search.d.ts.map +1 -1
- package/esri-compat/search.js +25 -28
- package/esri-compat/sketch.d.ts +2 -0
- package/esri-compat/sketch.d.ts.map +1 -1
- package/esri-compat/sketch.js +12 -0
- package/esri-compat/widget-host.d.ts +46 -17
- package/esri-compat/widget-host.d.ts.map +1 -1
- package/esri-compat/widget-host.js +84 -20
- package/esri-compat/widget-shell-hosts.d.ts +61 -0
- package/esri-compat/widget-shell-hosts.d.ts.map +1 -0
- package/esri-compat/widget-shell-hosts.js +76 -0
- package/kernel/renderer.d.ts +82 -0
- package/kernel/renderer.js +1 -0
- package/offline/conflict-resolution.d.ts +183 -0
- package/offline/conflict-resolution.d.ts.map +1 -0
- package/offline/conflict-resolution.js +286 -0
- package/offline/credential-screen.d.ts +34 -0
- package/offline/credential-screen.d.ts.map +1 -0
- package/offline/credential-screen.js +78 -0
- package/offline/digest.d.ts +15 -0
- package/offline/digest.d.ts.map +1 -0
- package/offline/digest.js +58 -0
- package/offline/edit-queue.d.ts +403 -0
- package/offline/edit-queue.d.ts.map +1 -0
- package/offline/edit-queue.js +1807 -0
- package/offline/edit-replay.d.ts +116 -0
- package/offline/edit-replay.d.ts.map +1 -0
- package/offline/edit-replay.js +392 -0
- package/offline/fetch-handler.d.ts +12 -0
- package/offline/fetch-handler.d.ts.map +1 -0
- package/offline/fetch-handler.js +65 -0
- package/offline/index.d.ts +52 -0
- package/offline/index.d.ts.map +1 -0
- package/offline/index.js +34 -0
- package/offline/indexeddb.d.ts +135 -0
- package/offline/indexeddb.d.ts.map +1 -0
- package/offline/indexeddb.js +888 -0
- package/offline/memory-store.d.ts +17 -0
- package/offline/memory-store.d.ts.map +1 -0
- package/offline/memory-store.js +183 -0
- package/offline/quota.d.ts +161 -0
- package/offline/quota.d.ts.map +1 -0
- package/offline/quota.js +370 -0
- package/offline/read-gate.d.ts +175 -0
- package/offline/read-gate.d.ts.map +1 -0
- package/offline/read-gate.js +252 -0
- package/offline/read-resource.d.ts +99 -0
- package/offline/read-resource.d.ts.map +1 -0
- package/offline/read-resource.js +156 -0
- package/offline/read.d.ts +50 -0
- package/offline/read.d.ts.map +1 -0
- package/offline/read.js +65 -0
- package/offline/region.d.ts +29 -0
- package/offline/region.d.ts.map +1 -0
- package/offline/region.js +1007 -0
- package/offline/replay-conflict.d.ts +183 -0
- package/offline/replay-conflict.d.ts.map +1 -0
- package/offline/replay-conflict.js +482 -0
- package/offline/selection.d.ts +140 -0
- package/offline/selection.d.ts.map +1 -0
- package/offline/selection.js +391 -0
- package/offline/snapshot.d.ts +132 -0
- package/offline/snapshot.d.ts.map +1 -0
- package/offline/snapshot.js +312 -0
- package/offline/status.d.ts +136 -0
- package/offline/status.d.ts.map +1 -0
- package/offline/status.js +479 -0
- package/offline/store-conformance.d.ts +48 -0
- package/offline/store-conformance.d.ts.map +1 -0
- package/offline/store-conformance.js +233 -0
- package/offline/tile-protocol.d.ts +81 -0
- package/offline/tile-protocol.d.ts.map +1 -0
- package/offline/tile-protocol.js +119 -0
- package/offline/types.d.ts +349 -0
- package/offline/types.d.ts.map +1 -0
- package/offline/types.js +75 -0
- package/package.json +14 -2
- package/scene-workspace/index.d.ts +2 -2
- package/scene-workspace/index.d.ts.map +1 -1
- package/scene-workspace/index.js +1 -1
- package/scene-workspace/scene-discovery.d.ts +111 -91
- package/scene-workspace/scene-discovery.d.ts.map +1 -1
- package/scene-workspace/scene-discovery.js +340 -92
- package/scene-workspace/scene-view.d.ts +6 -6
- package/scene-workspace/scene-view.d.ts.map +1 -1
- package/scene-workspace/scene-view.js +32 -6
- package/web-components/elements.d.ts +34 -0
- package/web-components/elements.d.ts.map +1 -1
- package/web-components/elements.js +82 -8
- package/web-components/index.d.ts +2 -0
- package/web-components/index.d.ts.map +1 -1
- package/web-components/index.js +3 -1
- package/web-components/map-chrome.d.ts +103 -0
- package/web-components/map-chrome.d.ts.map +1 -0
- package/web-components/map-chrome.js +325 -0
- package/web-components/widget-shell.d.ts +68 -0
- package/web-components/widget-shell.d.ts.map +1 -0
- package/web-components/widget-shell.js +224 -0
- package/widget-capabilities.d.ts +67 -0
- package/widget-capabilities.js +152 -0
|
@@ -0,0 +1,1469 @@
|
|
|
1
|
+
import { GEOARROW_SPEC_VERSION, HONUA_GEOARROW_LAYOUT_VERSION, HonuaGeoArrowError, } from "./geoarrow-types.js";
|
|
2
|
+
import { createColumnarBatch, inspectColumnarBatch } from "./transfer.js";
|
|
3
|
+
import { DEFAULT_COLUMNAR_BATCH_MAX_BACKING_BYTES, DEFAULT_COLUMNAR_BATCH_MAX_METADATA_ENTRIES, DEFAULT_COLUMNAR_BATCH_MAX_ROWS, DEFAULT_COLUMNAR_BATCH_MAX_STRING_BYTES, } from "./types.js";
|
|
4
|
+
const DEFAULT_MAX_VERTICES = 8_000_000;
|
|
5
|
+
const DEFAULT_MAX_RINGS = 2_000_000;
|
|
6
|
+
const DEFAULT_MAX_DICTIONARY_VALUES = 65_536;
|
|
7
|
+
const INT32_MAX = 0x7fff_ffff;
|
|
8
|
+
const MAX_JSON_DEPTH = 64;
|
|
9
|
+
const MAX_GEOARROW_DESCRIPTOR_BYTES = 16 * 1024 * 1024;
|
|
10
|
+
const MAX_GEOARROW_JSON_NODES = 65_536;
|
|
11
|
+
const META = Object.freeze({
|
|
12
|
+
version: "honua.geoarrow.layout.version",
|
|
13
|
+
specVersion: "honua.geoarrow.spec.version",
|
|
14
|
+
geometryField: "honua.geoarrow.geometry.field",
|
|
15
|
+
geometryKind: "honua.geoarrow.geometry.kind",
|
|
16
|
+
dimensions: "honua.geoarrow.geometry.dimensions",
|
|
17
|
+
coordinateLayout: "honua.geoarrow.geometry.coordinate-layout",
|
|
18
|
+
temporalField: "honua.geoarrow.temporal.field",
|
|
19
|
+
temporalUnit: "honua.geoarrow.temporal.unit",
|
|
20
|
+
temporalTimezone: "honua.geoarrow.temporal.timezone",
|
|
21
|
+
dictionaryField: "honua.geoarrow.dictionary.field",
|
|
22
|
+
dictionaryOrdered: "honua.geoarrow.dictionary.ordered",
|
|
23
|
+
featureIdField: "honua.geoarrow.feature-id.field",
|
|
24
|
+
});
|
|
25
|
+
function fail(code, message, detail, cause) {
|
|
26
|
+
throw new HonuaGeoArrowError(code, message, detail, cause === undefined ? undefined : { cause });
|
|
27
|
+
}
|
|
28
|
+
function positiveLimit(value, fallback, name) {
|
|
29
|
+
const result = value ?? fallback;
|
|
30
|
+
if (!Number.isSafeInteger(result) || result <= 0)
|
|
31
|
+
fail("invalid-input", `${name} must be a positive safe integer.`);
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
function resolveLimits(limits) {
|
|
35
|
+
const maxRows = positiveLimit(limits.maxRows, DEFAULT_COLUMNAR_BATCH_MAX_ROWS, "maxRows");
|
|
36
|
+
const maxVertices = positiveLimit(limits.maxVertices, DEFAULT_MAX_VERTICES, "maxVertices");
|
|
37
|
+
const maxRings = positiveLimit(limits.maxRings, DEFAULT_MAX_RINGS, "maxRings");
|
|
38
|
+
const maxDictionaryValues = positiveLimit(limits.maxDictionaryValues, DEFAULT_MAX_DICTIONARY_VALUES, "maxDictionaryValues");
|
|
39
|
+
const maxBackingBytes = positiveLimit(limits.maxBackingBytes, DEFAULT_COLUMNAR_BATCH_MAX_BACKING_BYTES, "maxBackingBytes");
|
|
40
|
+
const requestedCopiedBytes = positiveLimit(limits.maxCopiedBytes, maxBackingBytes, "maxCopiedBytes");
|
|
41
|
+
const maxCopiedBytes = Math.min(requestedCopiedBytes, maxBackingBytes);
|
|
42
|
+
const maxDescriptorBytes = positiveLimit(limits.maxStringBytes, DEFAULT_COLUMNAR_BATCH_MAX_STRING_BYTES, "maxStringBytes");
|
|
43
|
+
const maxJsonNodes = positiveLimit(limits.maxMetadataEntries, DEFAULT_COLUMNAR_BATCH_MAX_METADATA_ENTRIES, "maxMetadataEntries");
|
|
44
|
+
for (const [name, value] of Object.entries({
|
|
45
|
+
maxRows,
|
|
46
|
+
maxVertices,
|
|
47
|
+
maxRings,
|
|
48
|
+
maxDictionaryValues,
|
|
49
|
+
maxCopiedBytes: requestedCopiedBytes,
|
|
50
|
+
})) {
|
|
51
|
+
if (value > INT32_MAX) {
|
|
52
|
+
fail("invalid-input", `${name} cannot exceed the int32 GeoArrow representation ceiling ${INT32_MAX}.`, {
|
|
53
|
+
resource: name,
|
|
54
|
+
actual: value,
|
|
55
|
+
limit: INT32_MAX,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (maxDescriptorBytes > MAX_GEOARROW_DESCRIPTOR_BYTES) {
|
|
60
|
+
fail("invalid-input", `maxStringBytes cannot exceed the ${MAX_GEOARROW_DESCRIPTOR_BYTES}-byte GeoArrow descriptor ceiling.`, { resource: "maxStringBytes", actual: maxDescriptorBytes, limit: MAX_GEOARROW_DESCRIPTOR_BYTES });
|
|
61
|
+
}
|
|
62
|
+
if (maxJsonNodes > MAX_GEOARROW_JSON_NODES) {
|
|
63
|
+
fail("invalid-input", `maxMetadataEntries cannot exceed the ${MAX_GEOARROW_JSON_NODES}-node GeoArrow ceiling.`, {
|
|
64
|
+
resource: "maxMetadataEntries",
|
|
65
|
+
actual: maxJsonNodes,
|
|
66
|
+
limit: MAX_GEOARROW_JSON_NODES,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return Object.freeze({
|
|
70
|
+
maxRows,
|
|
71
|
+
maxVertices,
|
|
72
|
+
maxRings,
|
|
73
|
+
maxDictionaryValues,
|
|
74
|
+
maxCopiedBytes,
|
|
75
|
+
maxDescriptorBytes,
|
|
76
|
+
maxJsonNodes,
|
|
77
|
+
batch: limits,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
function arrayLength(value, label) {
|
|
81
|
+
if (!Array.isArray(value))
|
|
82
|
+
fail("invalid-input", `${label} must be an array.`);
|
|
83
|
+
const length = value.length;
|
|
84
|
+
if (!Number.isSafeInteger(length))
|
|
85
|
+
fail("invalid-input", `${label}.length must be a safe integer.`);
|
|
86
|
+
return length;
|
|
87
|
+
}
|
|
88
|
+
function dimensionNames(dimensions) {
|
|
89
|
+
switch (dimensions) {
|
|
90
|
+
case "xy":
|
|
91
|
+
return ["x", "y"];
|
|
92
|
+
case "xyz":
|
|
93
|
+
return ["x", "y", "z"];
|
|
94
|
+
case "xym":
|
|
95
|
+
return ["x", "y", "m"];
|
|
96
|
+
case "xyzm":
|
|
97
|
+
return ["x", "y", "z", "m"];
|
|
98
|
+
default:
|
|
99
|
+
fail("unsupported-layout", `Unsupported GeoArrow dimensions "${String(dimensions)}".`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function normalizePosition(value, names, label, allowEmptyPoint = false) {
|
|
103
|
+
const length = arrayLength(value, label);
|
|
104
|
+
const coordinates = value;
|
|
105
|
+
if (length !== names.length) {
|
|
106
|
+
fail("invalid-input", `${label} must contain ${names.length} coordinates (${names.join(", ")}).`);
|
|
107
|
+
}
|
|
108
|
+
const emptyPoint = allowEmptyPoint && coordinates.every((coordinate) => typeof coordinate === "number" && Number.isNaN(coordinate));
|
|
109
|
+
const result = [];
|
|
110
|
+
for (let index = 0; index < length; index += 1) {
|
|
111
|
+
const coordinate = coordinates[index];
|
|
112
|
+
if (typeof coordinate !== "number" || (!emptyPoint && !Number.isFinite(coordinate))) {
|
|
113
|
+
fail("invalid-input", `${label}[${index}] must be finite, or every ordinate must be NaN for an empty Point.`);
|
|
114
|
+
}
|
|
115
|
+
result.push(coordinate);
|
|
116
|
+
}
|
|
117
|
+
return Object.freeze(result);
|
|
118
|
+
}
|
|
119
|
+
function samePosition(first, last) {
|
|
120
|
+
return first.length === last.length && first.every((coordinate, index) => coordinate === last[index]);
|
|
121
|
+
}
|
|
122
|
+
function normalizeGeometry(input, rows, limits) {
|
|
123
|
+
if (typeof input !== "object" || input === null)
|
|
124
|
+
fail("invalid-input", "geometry must be an object.");
|
|
125
|
+
const kind = input.kind;
|
|
126
|
+
if (kind !== "point" && kind !== "linestring" && kind !== "polygon") {
|
|
127
|
+
fail("unsupported-layout", `Unsupported geometry kind "${String(kind)}".`);
|
|
128
|
+
}
|
|
129
|
+
const field = cleanField(input.field ?? "geometry", "geometry.field");
|
|
130
|
+
const dimensions = input.dimensions ?? "xy";
|
|
131
|
+
const names = dimensionNames(dimensions);
|
|
132
|
+
const coordinateLayout = input.coordinateLayout ?? "separated";
|
|
133
|
+
if (coordinateLayout !== "interleaved" && coordinateLayout !== "separated") {
|
|
134
|
+
fail("unsupported-layout", `Unsupported coordinate layout "${String(coordinateLayout)}".`);
|
|
135
|
+
}
|
|
136
|
+
const edges = input.edges ?? "planar";
|
|
137
|
+
if (!["planar", "spherical", "vincenty", "thomas", "andoyer", "karney"].includes(edges)) {
|
|
138
|
+
fail("unsupported-layout", `Unsupported edge interpretation "${String(edges)}".`);
|
|
139
|
+
}
|
|
140
|
+
if (arrayLength(input.values, "geometry.values") !== rows) {
|
|
141
|
+
fail("invalid-input", "geometry.values length must match the batch row count.");
|
|
142
|
+
}
|
|
143
|
+
let vertices = 0;
|
|
144
|
+
let rings = 0;
|
|
145
|
+
let hasNull = false;
|
|
146
|
+
const values = [];
|
|
147
|
+
const addVertices = (count) => {
|
|
148
|
+
if (count > limits.maxVertices - vertices) {
|
|
149
|
+
fail("vertex-limit-exceeded", `Geometry exceeds the ${limits.maxVertices}-vertex conversion limit.`, {
|
|
150
|
+
limit: limits.maxVertices,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
vertices += count;
|
|
154
|
+
};
|
|
155
|
+
for (let row = 0; row < rows; row += 1) {
|
|
156
|
+
const geometry = input.values[row];
|
|
157
|
+
if (geometry === null) {
|
|
158
|
+
hasNull = true;
|
|
159
|
+
if (input.kind === "point")
|
|
160
|
+
addVertices(1);
|
|
161
|
+
values.push(null);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (input.kind === "point") {
|
|
165
|
+
addVertices(1);
|
|
166
|
+
values.push(normalizePosition(geometry, names, `geometry.values[${row}]`, true));
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const partCount = arrayLength(geometry, `geometry.values[${row}]`);
|
|
170
|
+
if (input.kind === "linestring") {
|
|
171
|
+
const rawLine = geometry;
|
|
172
|
+
addVertices(partCount);
|
|
173
|
+
const line = [];
|
|
174
|
+
for (let vertex = 0; vertex < partCount; vertex += 1) {
|
|
175
|
+
line.push(normalizePosition(rawLine[vertex], names, `geometry.values[${row}][${vertex}]`));
|
|
176
|
+
}
|
|
177
|
+
values.push(Object.freeze(line));
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const rawPolygon = geometry;
|
|
181
|
+
if (partCount > limits.maxRings - rings) {
|
|
182
|
+
fail("ring-limit-exceeded", `Geometry exceeds the ${limits.maxRings}-ring conversion limit.`, {
|
|
183
|
+
limit: limits.maxRings,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
rings += partCount;
|
|
187
|
+
const polygon = [];
|
|
188
|
+
for (let ringIndex = 0; ringIndex < partCount; ringIndex += 1) {
|
|
189
|
+
const rawRing = rawPolygon[ringIndex];
|
|
190
|
+
const vertexCount = arrayLength(rawRing, `geometry.values[${row}][${ringIndex}]`);
|
|
191
|
+
if (vertexCount !== 0 && vertexCount < 4) {
|
|
192
|
+
fail("invalid-input", `Polygon ring ${row}:${ringIndex} must be empty or contain at least four positions.`);
|
|
193
|
+
}
|
|
194
|
+
addVertices(vertexCount);
|
|
195
|
+
const ring = [];
|
|
196
|
+
for (let vertex = 0; vertex < vertexCount; vertex += 1) {
|
|
197
|
+
ring.push(normalizePosition(rawRing[vertex], names, `geometry.values[${row}][${ringIndex}][${vertex}]`));
|
|
198
|
+
}
|
|
199
|
+
if (ring.length > 0 && !samePosition(ring[0], ring[ring.length - 1])) {
|
|
200
|
+
fail("invalid-input", `Polygon ring ${row}:${ringIndex} must be closed.`);
|
|
201
|
+
}
|
|
202
|
+
polygon.push(Object.freeze(ring));
|
|
203
|
+
}
|
|
204
|
+
values.push(Object.freeze(polygon));
|
|
205
|
+
}
|
|
206
|
+
const crs = input.crs === undefined ? undefined : normalizeCrs(input.crs, limits);
|
|
207
|
+
const crsType = normalizeCrsType(input.crsType, crs);
|
|
208
|
+
return Object.freeze({
|
|
209
|
+
kind: input.kind,
|
|
210
|
+
field,
|
|
211
|
+
dimensions,
|
|
212
|
+
coordinateLayout,
|
|
213
|
+
...(crs === undefined ? {} : { crs }),
|
|
214
|
+
...(crsType === undefined ? {} : { crsType }),
|
|
215
|
+
edges,
|
|
216
|
+
values: Object.freeze(values),
|
|
217
|
+
vertices,
|
|
218
|
+
rings,
|
|
219
|
+
hasNull,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
function utf8Bytes(value) {
|
|
223
|
+
let bytes = 0;
|
|
224
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
225
|
+
const code = value.charCodeAt(index);
|
|
226
|
+
if (code <= 0x7f)
|
|
227
|
+
bytes += 1;
|
|
228
|
+
else if (code <= 0x7ff)
|
|
229
|
+
bytes += 2;
|
|
230
|
+
else if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length) {
|
|
231
|
+
const next = value.charCodeAt(index + 1);
|
|
232
|
+
if (next >= 0xdc00 && next <= 0xdfff) {
|
|
233
|
+
bytes += 4;
|
|
234
|
+
index += 1;
|
|
235
|
+
}
|
|
236
|
+
else
|
|
237
|
+
bytes += 3;
|
|
238
|
+
}
|
|
239
|
+
else
|
|
240
|
+
bytes += 3;
|
|
241
|
+
}
|
|
242
|
+
return bytes;
|
|
243
|
+
}
|
|
244
|
+
function jsonStringBytes(value) {
|
|
245
|
+
let bytes = 2;
|
|
246
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
247
|
+
const code = value.charCodeAt(index);
|
|
248
|
+
if (code === 0x22 ||
|
|
249
|
+
code === 0x5c ||
|
|
250
|
+
code === 0x08 ||
|
|
251
|
+
code === 0x09 ||
|
|
252
|
+
code === 0x0a ||
|
|
253
|
+
code === 0x0c ||
|
|
254
|
+
code === 0x0d) {
|
|
255
|
+
bytes += 2;
|
|
256
|
+
}
|
|
257
|
+
else if (code <= 0x1f)
|
|
258
|
+
bytes += 6;
|
|
259
|
+
else if (code <= 0x7f)
|
|
260
|
+
bytes += 1;
|
|
261
|
+
else if (code <= 0x7ff)
|
|
262
|
+
bytes += 2;
|
|
263
|
+
else if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length) {
|
|
264
|
+
const next = value.charCodeAt(index + 1);
|
|
265
|
+
if (next >= 0xdc00 && next <= 0xdfff) {
|
|
266
|
+
bytes += 4;
|
|
267
|
+
index += 1;
|
|
268
|
+
}
|
|
269
|
+
else
|
|
270
|
+
bytes += 6;
|
|
271
|
+
}
|
|
272
|
+
else if (code >= 0xd800 && code <= 0xdfff)
|
|
273
|
+
bytes += 6;
|
|
274
|
+
else
|
|
275
|
+
bytes += 3;
|
|
276
|
+
}
|
|
277
|
+
return bytes;
|
|
278
|
+
}
|
|
279
|
+
function consumeJson(budget, bytes) {
|
|
280
|
+
if (bytes > budget.maxBytes - budget.bytes) {
|
|
281
|
+
fail("invalid-input", `CRS metadata exceeds the ${budget.maxBytes}-byte descriptor limit.`, {
|
|
282
|
+
resource: "crs-json-bytes",
|
|
283
|
+
limit: budget.maxBytes,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
budget.bytes += bytes;
|
|
287
|
+
}
|
|
288
|
+
function normalizeJsonValue(value, depth, budget, label) {
|
|
289
|
+
if (depth > MAX_JSON_DEPTH)
|
|
290
|
+
fail("invalid-input", `CRS metadata exceeds the ${MAX_JSON_DEPTH}-level depth limit.`);
|
|
291
|
+
budget.nodes += 1;
|
|
292
|
+
if (budget.nodes > budget.maxNodes) {
|
|
293
|
+
fail("invalid-input", `CRS metadata exceeds the ${budget.maxNodes}-node descriptor limit.`, {
|
|
294
|
+
resource: "crs-json-nodes",
|
|
295
|
+
limit: budget.maxNodes,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
if (value === null) {
|
|
299
|
+
consumeJson(budget, 4);
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
if (typeof value === "string") {
|
|
303
|
+
consumeJson(budget, jsonStringBytes(value));
|
|
304
|
+
return value;
|
|
305
|
+
}
|
|
306
|
+
if (typeof value === "boolean") {
|
|
307
|
+
consumeJson(budget, value ? 4 : 5);
|
|
308
|
+
return value;
|
|
309
|
+
}
|
|
310
|
+
if (typeof value === "number") {
|
|
311
|
+
if (!Number.isFinite(value))
|
|
312
|
+
fail("invalid-input", `${label} must contain only finite JSON numbers.`);
|
|
313
|
+
consumeJson(budget, utf8Bytes(String(value)));
|
|
314
|
+
return value;
|
|
315
|
+
}
|
|
316
|
+
if (value === null || typeof value !== "object") {
|
|
317
|
+
fail("invalid-input", `${label} contains a non-JSON value.`);
|
|
318
|
+
}
|
|
319
|
+
if (budget.ancestors.has(value))
|
|
320
|
+
fail("invalid-input", `${label} contains a cycle.`);
|
|
321
|
+
budget.ancestors.add(value);
|
|
322
|
+
try {
|
|
323
|
+
if (Array.isArray(value)) {
|
|
324
|
+
if (Object.getPrototypeOf(value) !== Array.prototype) {
|
|
325
|
+
fail("invalid-input", `${label} must contain only plain JSON arrays.`);
|
|
326
|
+
}
|
|
327
|
+
consumeJson(budget, 2);
|
|
328
|
+
if (value.length > budget.maxNodes - budget.nodes) {
|
|
329
|
+
fail("invalid-input", `CRS metadata exceeds the ${budget.maxNodes}-node descriptor limit.`);
|
|
330
|
+
}
|
|
331
|
+
const normalized = [];
|
|
332
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
333
|
+
if (index > 0)
|
|
334
|
+
consumeJson(budget, 1);
|
|
335
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
336
|
+
if (!descriptor || descriptor.get !== undefined || descriptor.set !== undefined) {
|
|
337
|
+
fail("invalid-input", `${label}[${index}] must be a plain JSON data property.`);
|
|
338
|
+
}
|
|
339
|
+
normalized.push(normalizeJsonValue(descriptor.value, depth + 1, budget, `${label}[${index}]`));
|
|
340
|
+
}
|
|
341
|
+
return Object.freeze(normalized);
|
|
342
|
+
}
|
|
343
|
+
const prototype = Object.getPrototypeOf(value);
|
|
344
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
345
|
+
fail("invalid-input", `${label} must contain only plain JSON objects.`);
|
|
346
|
+
}
|
|
347
|
+
consumeJson(budget, 2);
|
|
348
|
+
const normalized = Object.create(null);
|
|
349
|
+
let propertyCount = 0;
|
|
350
|
+
for (const key in value) {
|
|
351
|
+
if (!Object.hasOwn(value, key))
|
|
352
|
+
continue;
|
|
353
|
+
propertyCount += 1;
|
|
354
|
+
if (propertyCount > budget.maxNodes - budget.nodes) {
|
|
355
|
+
fail("invalid-input", `CRS metadata exceeds the ${budget.maxNodes}-node descriptor limit.`);
|
|
356
|
+
}
|
|
357
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
358
|
+
if (!descriptor || descriptor.get !== undefined || descriptor.set !== undefined) {
|
|
359
|
+
fail("invalid-input", `${label}.${key} must be a plain JSON data property.`);
|
|
360
|
+
}
|
|
361
|
+
if (propertyCount > 1)
|
|
362
|
+
consumeJson(budget, 1);
|
|
363
|
+
consumeJson(budget, jsonStringBytes(key) + 1);
|
|
364
|
+
normalized[key] = normalizeJsonValue(descriptor.value, depth + 1, budget, `${label}.${key}`);
|
|
365
|
+
}
|
|
366
|
+
return Object.freeze(normalized);
|
|
367
|
+
}
|
|
368
|
+
finally {
|
|
369
|
+
budget.ancestors.delete(value);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
function boundedJson(value, limits, label) {
|
|
373
|
+
const budget = {
|
|
374
|
+
bytes: 0,
|
|
375
|
+
nodes: 0,
|
|
376
|
+
maxBytes: limits.maxDescriptorBytes,
|
|
377
|
+
maxNodes: limits.maxJsonNodes,
|
|
378
|
+
ancestors: new WeakSet(),
|
|
379
|
+
};
|
|
380
|
+
try {
|
|
381
|
+
const normalized = normalizeJsonValue(value, 0, budget, label);
|
|
382
|
+
const json = JSON.stringify(normalized);
|
|
383
|
+
if (utf8Bytes(json) !== budget.bytes)
|
|
384
|
+
fail("invalid-input", `${label} byte accounting is inconsistent.`);
|
|
385
|
+
return Object.freeze({ value: normalized, json });
|
|
386
|
+
}
|
|
387
|
+
catch (cause) {
|
|
388
|
+
if (cause instanceof HonuaGeoArrowError)
|
|
389
|
+
throw cause;
|
|
390
|
+
fail("invalid-input", `${label} could not be inspected as bounded plain JSON.`, undefined, cause);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
function normalizeCrs(crs, limits) {
|
|
394
|
+
if (typeof crs === "string") {
|
|
395
|
+
if (crs.trim() === "")
|
|
396
|
+
fail("invalid-input", "geometry.crs must not be empty.");
|
|
397
|
+
if (jsonStringBytes(crs) > limits.maxDescriptorBytes) {
|
|
398
|
+
fail("invalid-input", `CRS metadata exceeds the ${limits.maxDescriptorBytes}-byte descriptor limit.`);
|
|
399
|
+
}
|
|
400
|
+
return crs;
|
|
401
|
+
}
|
|
402
|
+
if (typeof crs !== "object" || crs === null || Array.isArray(crs)) {
|
|
403
|
+
fail("invalid-input", "geometry.crs must be a serialized CRS string or PROJJSON object.");
|
|
404
|
+
}
|
|
405
|
+
return boundedJson(crs, limits, "geometry.crs").value;
|
|
406
|
+
}
|
|
407
|
+
const GEOARROW_CRS_TYPES = ["projjson", "wkt2:2019", "authority_code", "srid"];
|
|
408
|
+
function normalizeCrsType(crsType, crs) {
|
|
409
|
+
if (crsType === undefined)
|
|
410
|
+
return undefined;
|
|
411
|
+
if (typeof crsType !== "string" || !GEOARROW_CRS_TYPES.includes(crsType)) {
|
|
412
|
+
fail("invalid-input", `Unsupported geometry.crsType "${String(crsType)}".`);
|
|
413
|
+
}
|
|
414
|
+
if (crs === undefined)
|
|
415
|
+
fail("invalid-input", "geometry.crsType requires geometry.crs.");
|
|
416
|
+
return crsType;
|
|
417
|
+
}
|
|
418
|
+
function extensionMetadata(geometry, limits) {
|
|
419
|
+
const metadata = {};
|
|
420
|
+
if (geometry.crs !== undefined)
|
|
421
|
+
metadata.crs = geometry.crs;
|
|
422
|
+
if (geometry.crsType !== undefined)
|
|
423
|
+
metadata.crs_type = geometry.crsType;
|
|
424
|
+
if (geometry.edges !== "planar")
|
|
425
|
+
metadata.edges = geometry.edges;
|
|
426
|
+
return boundedJson(metadata, limits, "GeoArrow extension metadata").json;
|
|
427
|
+
}
|
|
428
|
+
function coordinateField(dimensions, layout) {
|
|
429
|
+
const names = dimensionNames(dimensions);
|
|
430
|
+
if (layout === "separated") {
|
|
431
|
+
return {
|
|
432
|
+
name: "coordinates",
|
|
433
|
+
type: { name: "struct", parameters: { dimensions } },
|
|
434
|
+
nullable: false,
|
|
435
|
+
children: names.map((name) => ({ name, type: { name: "float64" }, nullable: false })),
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
return {
|
|
439
|
+
name: names.join(""),
|
|
440
|
+
type: { name: "fixed_size_list", parameters: { size: names.length, valueType: "float64" } },
|
|
441
|
+
nullable: false,
|
|
442
|
+
children: [{ name: "values", type: { name: "float64" }, nullable: false }],
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
function geometryField(geometry, limits) {
|
|
446
|
+
const coordinate = coordinateField(geometry.dimensions, geometry.coordinateLayout);
|
|
447
|
+
let children;
|
|
448
|
+
if (geometry.kind === "point")
|
|
449
|
+
children = [coordinate];
|
|
450
|
+
else if (geometry.kind === "linestring") {
|
|
451
|
+
children = [
|
|
452
|
+
{
|
|
453
|
+
name: "vertices",
|
|
454
|
+
type: { name: "list", parameters: { offsetType: "int32" } },
|
|
455
|
+
nullable: false,
|
|
456
|
+
children: [coordinate],
|
|
457
|
+
},
|
|
458
|
+
];
|
|
459
|
+
}
|
|
460
|
+
else {
|
|
461
|
+
children = [
|
|
462
|
+
{
|
|
463
|
+
name: "rings",
|
|
464
|
+
type: { name: "list", parameters: { offsetType: "int32" } },
|
|
465
|
+
nullable: false,
|
|
466
|
+
children: [
|
|
467
|
+
{
|
|
468
|
+
name: "vertices",
|
|
469
|
+
type: { name: "list", parameters: { offsetType: "int32" } },
|
|
470
|
+
nullable: false,
|
|
471
|
+
children: [coordinate],
|
|
472
|
+
},
|
|
473
|
+
],
|
|
474
|
+
},
|
|
475
|
+
];
|
|
476
|
+
}
|
|
477
|
+
const encodedExtensionMetadata = extensionMetadata(geometry, limits);
|
|
478
|
+
return {
|
|
479
|
+
name: geometry.field,
|
|
480
|
+
type: {
|
|
481
|
+
name: `geoarrow.${geometry.kind}`,
|
|
482
|
+
parameters: {
|
|
483
|
+
dimensions: geometry.dimensions,
|
|
484
|
+
coordinateLayout: geometry.coordinateLayout,
|
|
485
|
+
offsetType: "int32",
|
|
486
|
+
},
|
|
487
|
+
},
|
|
488
|
+
nullable: geometry.hasNull,
|
|
489
|
+
children,
|
|
490
|
+
metadata: {
|
|
491
|
+
"ARROW:extension:name": `geoarrow.${geometry.kind}`,
|
|
492
|
+
...(encodedExtensionMetadata === "{}" ? {} : { "ARROW:extension:metadata": encodedExtensionMetadata }),
|
|
493
|
+
},
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
function allocate(ctor, length, label) {
|
|
497
|
+
try {
|
|
498
|
+
return new ctor(length);
|
|
499
|
+
}
|
|
500
|
+
catch (cause) {
|
|
501
|
+
fail("copy-limit-exceeded", `Unable to allocate bounded ${label} payload.`, { length }, cause);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
function validityBitmap(values, nullable) {
|
|
505
|
+
if (!values.some(nullable))
|
|
506
|
+
return undefined;
|
|
507
|
+
const bitmap = allocate(Uint8Array, Math.ceil(values.length / 8), "validity");
|
|
508
|
+
values.forEach((value, index) => {
|
|
509
|
+
if (!nullable(value))
|
|
510
|
+
bitmap[index >> 3] |= 1 << (index & 7);
|
|
511
|
+
});
|
|
512
|
+
return bitmap;
|
|
513
|
+
}
|
|
514
|
+
function pushBuffer(buffers, id, role, field, view) {
|
|
515
|
+
buffers.push({
|
|
516
|
+
id,
|
|
517
|
+
role,
|
|
518
|
+
field,
|
|
519
|
+
data: view.buffer,
|
|
520
|
+
byteOffset: view.byteOffset,
|
|
521
|
+
byteLength: view.byteLength,
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
function geometryPositions(geometry) {
|
|
525
|
+
const positions = [];
|
|
526
|
+
for (const value of geometry.values) {
|
|
527
|
+
if (value === null) {
|
|
528
|
+
if (geometry.kind === "point")
|
|
529
|
+
positions.push(Object.freeze(new Array(dimensionNames(geometry.dimensions).length).fill(0)));
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
if (geometry.kind === "point")
|
|
533
|
+
positions.push(value);
|
|
534
|
+
else if (geometry.kind === "linestring")
|
|
535
|
+
positions.push(...value);
|
|
536
|
+
else
|
|
537
|
+
for (const ring of value)
|
|
538
|
+
positions.push(...ring);
|
|
539
|
+
}
|
|
540
|
+
return positions;
|
|
541
|
+
}
|
|
542
|
+
function encodeGeometry(geometry, buffers) {
|
|
543
|
+
const validity = validityBitmap(geometry.values, (value) => value === null);
|
|
544
|
+
if (validity)
|
|
545
|
+
pushBuffer(buffers, `${geometry.field}.validity`, "validity", geometry.field, validity);
|
|
546
|
+
if (geometry.kind !== "point") {
|
|
547
|
+
const offsets = allocate(Int32Array, geometry.values.length + 1, "geometry offsets");
|
|
548
|
+
let offset = 0;
|
|
549
|
+
geometry.values.forEach((value, row) => {
|
|
550
|
+
offsets[row] = offset;
|
|
551
|
+
if (value !== null)
|
|
552
|
+
offset += value.length;
|
|
553
|
+
});
|
|
554
|
+
offsets[geometry.values.length] = offset;
|
|
555
|
+
pushBuffer(buffers, `${geometry.field}.offsets`, "offsets", geometry.field, offsets);
|
|
556
|
+
}
|
|
557
|
+
if (geometry.kind === "polygon") {
|
|
558
|
+
const ringOffsets = allocate(Int32Array, geometry.rings + 1, "ring offsets");
|
|
559
|
+
let ringIndex = 0;
|
|
560
|
+
let vertexOffset = 0;
|
|
561
|
+
for (const value of geometry.values) {
|
|
562
|
+
if (value === null)
|
|
563
|
+
continue;
|
|
564
|
+
for (const ring of value) {
|
|
565
|
+
ringOffsets[ringIndex] = vertexOffset;
|
|
566
|
+
vertexOffset += ring.length;
|
|
567
|
+
ringIndex += 1;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
ringOffsets[ringIndex] = vertexOffset;
|
|
571
|
+
pushBuffer(buffers, `${geometry.field}.ring-offsets`, "offsets", geometry.field, ringOffsets);
|
|
572
|
+
}
|
|
573
|
+
const names = dimensionNames(geometry.dimensions);
|
|
574
|
+
const positions = geometryPositions(geometry);
|
|
575
|
+
if (geometry.coordinateLayout === "interleaved") {
|
|
576
|
+
const coordinates = allocate(Float64Array, geometry.vertices * names.length, "interleaved coordinates");
|
|
577
|
+
let index = 0;
|
|
578
|
+
for (const position of positions)
|
|
579
|
+
for (const coordinate of position)
|
|
580
|
+
coordinates[index++] = coordinate;
|
|
581
|
+
pushBuffer(buffers, `${geometry.field}.coordinates`, "geometry", geometry.field, coordinates);
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
names.forEach((name, dimension) => {
|
|
585
|
+
const coordinates = allocate(Float64Array, geometry.vertices, `${name} coordinates`);
|
|
586
|
+
positions.forEach((position, vertex) => {
|
|
587
|
+
coordinates[vertex] = position[dimension];
|
|
588
|
+
});
|
|
589
|
+
pushBuffer(buffers, `${geometry.field}.${name}`, "geometry", geometry.field, coordinates);
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
function encodeDictionaryValues(values, maxValues, maxBytes) {
|
|
593
|
+
const dictionary = [];
|
|
594
|
+
const byValue = new Map();
|
|
595
|
+
const indices = [];
|
|
596
|
+
let hasNull = false;
|
|
597
|
+
let bytes = 0;
|
|
598
|
+
values.forEach((value, row) => {
|
|
599
|
+
if (value === null) {
|
|
600
|
+
hasNull = true;
|
|
601
|
+
indices.push(0);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
if (typeof value !== "string")
|
|
605
|
+
fail("invalid-input", `dictionary.values[${row}] must be a string or null.`);
|
|
606
|
+
let index = byValue.get(value);
|
|
607
|
+
if (index === undefined) {
|
|
608
|
+
if (dictionary.length === maxValues) {
|
|
609
|
+
fail("dictionary-limit-exceeded", `Dictionary exceeds the ${maxValues}-value conversion limit.`, {
|
|
610
|
+
limit: maxValues,
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
index = dictionary.length;
|
|
614
|
+
const encodedBytes = utf8Bytes(value);
|
|
615
|
+
if (encodedBytes > INT32_MAX - bytes || encodedBytes > maxBytes - bytes) {
|
|
616
|
+
fail("copy-limit-exceeded", "Dictionary UTF-8 values exceed the bounded int32 payload representation.", {
|
|
617
|
+
resource: "dictionary-utf8-bytes",
|
|
618
|
+
actual: bytes + encodedBytes,
|
|
619
|
+
limit: Math.min(INT32_MAX, maxBytes),
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
bytes += encodedBytes;
|
|
623
|
+
dictionary.push(value);
|
|
624
|
+
byValue.set(value, index);
|
|
625
|
+
}
|
|
626
|
+
indices.push(index);
|
|
627
|
+
});
|
|
628
|
+
return { values, dictionary, indices, hasNull, bytes };
|
|
629
|
+
}
|
|
630
|
+
function checkedAdd(total, addition, limit) {
|
|
631
|
+
if (!Number.isSafeInteger(addition) || addition < 0 || addition > limit - total) {
|
|
632
|
+
fail("copy-limit-exceeded", `Conversion payload exceeds the ${limit}-byte copy limit.`, { limit });
|
|
633
|
+
}
|
|
634
|
+
return total + addition;
|
|
635
|
+
}
|
|
636
|
+
function expectedPayloadBytes(rows, geometry, temporal, dictionary, featureIds, limit) {
|
|
637
|
+
let bytes = 0;
|
|
638
|
+
const validityBytes = Math.ceil(rows / 8);
|
|
639
|
+
if (geometry.hasNull)
|
|
640
|
+
bytes = checkedAdd(bytes, validityBytes, limit);
|
|
641
|
+
if (geometry.kind !== "point")
|
|
642
|
+
bytes = checkedAdd(bytes, (rows + 1) * 4, limit);
|
|
643
|
+
if (geometry.kind === "polygon")
|
|
644
|
+
bytes = checkedAdd(bytes, (geometry.rings + 1) * 4, limit);
|
|
645
|
+
bytes = checkedAdd(bytes, geometry.vertices * dimensionNames(geometry.dimensions).length * 8, limit);
|
|
646
|
+
if (temporal) {
|
|
647
|
+
if (temporal.values.some((value) => value === null))
|
|
648
|
+
bytes = checkedAdd(bytes, validityBytes, limit);
|
|
649
|
+
bytes = checkedAdd(bytes, rows * 8, limit);
|
|
650
|
+
}
|
|
651
|
+
if (dictionary) {
|
|
652
|
+
if (dictionary.hasNull)
|
|
653
|
+
bytes = checkedAdd(bytes, validityBytes, limit);
|
|
654
|
+
bytes = checkedAdd(bytes, rows * 4, limit);
|
|
655
|
+
bytes = checkedAdd(bytes, (dictionary.dictionary.length + 1) * 4, limit);
|
|
656
|
+
bytes = checkedAdd(bytes, dictionary.bytes, limit);
|
|
657
|
+
}
|
|
658
|
+
if (featureIds)
|
|
659
|
+
bytes = checkedAdd(bytes, rows * 4, limit);
|
|
660
|
+
return bytes;
|
|
661
|
+
}
|
|
662
|
+
function cleanField(value, label) {
|
|
663
|
+
if (typeof value !== "string" || value.trim() === "" || value !== value.trim()) {
|
|
664
|
+
fail("invalid-input", `${label} must be a clean non-empty identifier.`);
|
|
665
|
+
}
|
|
666
|
+
return value;
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Convert bounded semantic geometry/temporal/dictionary values to the normative
|
|
670
|
+
* Honua GeoArrow mapping. All returned payload buffers are newly owned, exact
|
|
671
|
+
* copy accounting is returned, and the generic transfer contract remains valid.
|
|
672
|
+
*/
|
|
673
|
+
export function createGeoArrowBatch(input, limits = {}) {
|
|
674
|
+
if (typeof input !== "object" || input === null)
|
|
675
|
+
fail("invalid-input", "GeoArrow batch input must be an object.");
|
|
676
|
+
const resolved = resolveLimits(limits);
|
|
677
|
+
const rows = arrayLength(input.geometry?.values, "geometry.values");
|
|
678
|
+
if (rows > resolved.maxRows) {
|
|
679
|
+
fail("row-limit-exceeded", `Batch has ${rows} rows; the conversion limit is ${resolved.maxRows}.`, {
|
|
680
|
+
rows,
|
|
681
|
+
limit: resolved.maxRows,
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
const geometry = normalizeGeometry(input.geometry, rows, resolved);
|
|
685
|
+
const fields = [geometryField(geometry, resolved)];
|
|
686
|
+
const fieldNames = new Set([geometry.field]);
|
|
687
|
+
const registerField = (field, label) => {
|
|
688
|
+
const cleaned = cleanField(field, label);
|
|
689
|
+
if (fieldNames.has(cleaned))
|
|
690
|
+
fail("invalid-input", `${label} duplicates field "${cleaned}".`);
|
|
691
|
+
fieldNames.add(cleaned);
|
|
692
|
+
return cleaned;
|
|
693
|
+
};
|
|
694
|
+
let temporalField;
|
|
695
|
+
if (input.temporal) {
|
|
696
|
+
temporalField = registerField(input.temporal.field, "temporal.field");
|
|
697
|
+
if (arrayLength(input.temporal.values, "temporal.values") !== rows) {
|
|
698
|
+
fail("invalid-input", "temporal.values length must match geometry.values.");
|
|
699
|
+
}
|
|
700
|
+
if (!["second", "millisecond", "microsecond", "nanosecond"].includes(input.temporal.unit)) {
|
|
701
|
+
fail("unsupported-layout", `Unsupported timestamp unit "${String(input.temporal.unit)}".`);
|
|
702
|
+
}
|
|
703
|
+
fields.push({
|
|
704
|
+
name: temporalField,
|
|
705
|
+
type: {
|
|
706
|
+
name: "timestamp",
|
|
707
|
+
parameters: {
|
|
708
|
+
unit: input.temporal.unit,
|
|
709
|
+
...(input.temporal.timezone === undefined ? {} : { timezone: input.temporal.timezone }),
|
|
710
|
+
},
|
|
711
|
+
},
|
|
712
|
+
nullable: input.temporal.values.some((value) => value === null),
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
let encodedDictionary;
|
|
716
|
+
let dictionaryField;
|
|
717
|
+
if (input.dictionary) {
|
|
718
|
+
dictionaryField = registerField(input.dictionary.field, "dictionary.field");
|
|
719
|
+
if (arrayLength(input.dictionary.values, "dictionary.values") !== rows) {
|
|
720
|
+
fail("invalid-input", "dictionary.values length must match geometry.values.");
|
|
721
|
+
}
|
|
722
|
+
encodedDictionary = encodeDictionaryValues(input.dictionary.values, resolved.maxDictionaryValues, resolved.maxCopiedBytes);
|
|
723
|
+
fields.push({
|
|
724
|
+
name: dictionaryField,
|
|
725
|
+
type: {
|
|
726
|
+
name: "dictionary",
|
|
727
|
+
parameters: { indexType: "int32", valueType: "utf8", ordered: input.dictionary.ordered ?? false },
|
|
728
|
+
},
|
|
729
|
+
nullable: encodedDictionary.hasNull,
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
let featureIdField;
|
|
733
|
+
if (input.featureIds) {
|
|
734
|
+
featureIdField = registerField(input.featureIds.field, "featureIds.field");
|
|
735
|
+
if (!Number.isSafeInteger(input.featureIds.values.length) || input.featureIds.values.length !== rows) {
|
|
736
|
+
fail("invalid-input", "featureIds.values length must match geometry.values.");
|
|
737
|
+
}
|
|
738
|
+
fields.push({ name: featureIdField, type: { name: "uint32" }, nullable: false });
|
|
739
|
+
}
|
|
740
|
+
const copiedBytes = expectedPayloadBytes(rows, geometry, input.temporal, encodedDictionary, input.featureIds, resolved.maxCopiedBytes);
|
|
741
|
+
const schemaMetadata = {
|
|
742
|
+
[META.version]: HONUA_GEOARROW_LAYOUT_VERSION,
|
|
743
|
+
[META.specVersion]: GEOARROW_SPEC_VERSION,
|
|
744
|
+
[META.geometryField]: geometry.field,
|
|
745
|
+
[META.geometryKind]: geometry.kind,
|
|
746
|
+
[META.dimensions]: geometry.dimensions,
|
|
747
|
+
[META.coordinateLayout]: geometry.coordinateLayout,
|
|
748
|
+
};
|
|
749
|
+
if (input.temporal && temporalField) {
|
|
750
|
+
schemaMetadata[META.temporalField] = temporalField;
|
|
751
|
+
schemaMetadata[META.temporalUnit] = input.temporal.unit;
|
|
752
|
+
if (input.temporal.timezone !== undefined)
|
|
753
|
+
schemaMetadata[META.temporalTimezone] = input.temporal.timezone;
|
|
754
|
+
}
|
|
755
|
+
if (input.dictionary && dictionaryField) {
|
|
756
|
+
schemaMetadata[META.dictionaryField] = dictionaryField;
|
|
757
|
+
schemaMetadata[META.dictionaryOrdered] = String(input.dictionary.ordered ?? false);
|
|
758
|
+
}
|
|
759
|
+
if (featureIdField)
|
|
760
|
+
schemaMetadata[META.featureIdField] = featureIdField;
|
|
761
|
+
const buffers = [];
|
|
762
|
+
encodeGeometry(geometry, buffers);
|
|
763
|
+
if (input.temporal && temporalField) {
|
|
764
|
+
const validity = validityBitmap(input.temporal.values, (value) => value === null);
|
|
765
|
+
if (validity)
|
|
766
|
+
pushBuffer(buffers, `${temporalField}.validity`, "validity", temporalField, validity);
|
|
767
|
+
const values = allocate(BigInt64Array, rows, "timestamp values");
|
|
768
|
+
input.temporal.values.forEach((value, index) => {
|
|
769
|
+
if (value !== null) {
|
|
770
|
+
if (typeof value !== "bigint")
|
|
771
|
+
fail("invalid-input", `temporal.values[${index}] must be a bigint or null.`);
|
|
772
|
+
values[index] = value;
|
|
773
|
+
}
|
|
774
|
+
});
|
|
775
|
+
pushBuffer(buffers, `${temporalField}.values`, "values", temporalField, values);
|
|
776
|
+
}
|
|
777
|
+
if (encodedDictionary && dictionaryField) {
|
|
778
|
+
const validity = validityBitmap(encodedDictionary.values, (value) => value === null);
|
|
779
|
+
if (validity)
|
|
780
|
+
pushBuffer(buffers, `${dictionaryField}.validity`, "validity", dictionaryField, validity);
|
|
781
|
+
const indices = allocate(Int32Array, rows, "dictionary indices");
|
|
782
|
+
indices.set(encodedDictionary.indices);
|
|
783
|
+
const offsets = allocate(Int32Array, encodedDictionary.dictionary.length + 1, "dictionary offsets");
|
|
784
|
+
const values = allocate(Uint8Array, encodedDictionary.bytes, "dictionary UTF-8 values");
|
|
785
|
+
let offset = 0;
|
|
786
|
+
const encoder = new TextEncoder();
|
|
787
|
+
encodedDictionary.dictionary.forEach((dictionaryValue, index) => {
|
|
788
|
+
offsets[index] = offset;
|
|
789
|
+
const target = values.subarray(offset);
|
|
790
|
+
const result = encoder.encodeInto(dictionaryValue, target);
|
|
791
|
+
if (result.read !== dictionaryValue.length)
|
|
792
|
+
fail("invalid-input", "Dictionary UTF-8 encoding was truncated.");
|
|
793
|
+
offset += result.written;
|
|
794
|
+
});
|
|
795
|
+
offsets[encodedDictionary.dictionary.length] = offset;
|
|
796
|
+
pushBuffer(buffers, `${dictionaryField}.indices`, "dictionary", dictionaryField, indices);
|
|
797
|
+
pushBuffer(buffers, `${dictionaryField}.offsets`, "offsets", dictionaryField, offsets);
|
|
798
|
+
pushBuffer(buffers, `${dictionaryField}.values`, "dictionary", dictionaryField, values);
|
|
799
|
+
}
|
|
800
|
+
if (input.featureIds && featureIdField) {
|
|
801
|
+
const values = allocate(Uint32Array, rows, "feature ids");
|
|
802
|
+
for (let index = 0; index < rows; index += 1) {
|
|
803
|
+
const value = input.featureIds.values[index];
|
|
804
|
+
if (!Number.isSafeInteger(value) || value < 0 || value > 0xffff_ffff) {
|
|
805
|
+
fail("invalid-input", `featureIds.values[${index}] must be a uint32.`);
|
|
806
|
+
}
|
|
807
|
+
values[index] = value;
|
|
808
|
+
}
|
|
809
|
+
pushBuffer(buffers, `${featureIdField}.values`, "values", featureIdField, values);
|
|
810
|
+
}
|
|
811
|
+
const batch = createColumnarBatch({
|
|
812
|
+
id: input.id,
|
|
813
|
+
sequence: input.sequence,
|
|
814
|
+
...(input.rowOffset === undefined ? {} : { rowOffset: input.rowOffset }),
|
|
815
|
+
rowCount: rows,
|
|
816
|
+
schema: { id: input.schemaId, fields, metadata: schemaMetadata },
|
|
817
|
+
identity: input.identity,
|
|
818
|
+
buffers,
|
|
819
|
+
}, resolved.batch);
|
|
820
|
+
const baseMetrics = inspectColumnarBatch(batch, resolved.batch);
|
|
821
|
+
if (baseMetrics.backingBytes !== copiedBytes) {
|
|
822
|
+
fail("invalid-batch", "GeoArrow conversion accounting disagrees with the columnar envelope.", {
|
|
823
|
+
copiedBytes,
|
|
824
|
+
backingBytes: baseMetrics.backingBytes,
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
return Object.freeze({
|
|
828
|
+
batch,
|
|
829
|
+
metrics: Object.freeze({
|
|
830
|
+
rows,
|
|
831
|
+
vertices: geometry.vertices,
|
|
832
|
+
rings: geometry.rings,
|
|
833
|
+
dictionaryValues: encodedDictionary?.dictionary.length ?? 0,
|
|
834
|
+
copiedBytes,
|
|
835
|
+
backingBytes: baseMetrics.backingBytes,
|
|
836
|
+
}),
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
function requireMetadata(metadata, key) {
|
|
840
|
+
const value = metadata?.[key];
|
|
841
|
+
if (value === undefined)
|
|
842
|
+
fail("invalid-batch", `GeoArrow schema metadata is missing "${key}".`, { key });
|
|
843
|
+
return value;
|
|
844
|
+
}
|
|
845
|
+
function parseExtensionMetadata(field, limits) {
|
|
846
|
+
const fieldMetadataKeys = Object.keys(field.metadata ?? {});
|
|
847
|
+
if (fieldMetadataKeys.some((key) => key !== "ARROW:extension:name" && key !== "ARROW:extension:metadata")) {
|
|
848
|
+
fail("invalid-batch", `Geometry field "${field.name}" contains undeclared extension metadata.`);
|
|
849
|
+
}
|
|
850
|
+
const encoded = field.metadata?.["ARROW:extension:metadata"] ?? "{}";
|
|
851
|
+
if (utf8Bytes(encoded) > limits.maxDescriptorBytes) {
|
|
852
|
+
fail("invalid-batch", "GeoArrow extension metadata exceeds the bounded descriptor limit.");
|
|
853
|
+
}
|
|
854
|
+
try {
|
|
855
|
+
const parsed = JSON.parse(encoded);
|
|
856
|
+
const value = boundedJson(parsed, limits, "GeoArrow extension metadata").value;
|
|
857
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
858
|
+
throw new Error("not an object");
|
|
859
|
+
if (Object.keys(value).some((key) => key !== "crs" && key !== "crs_type" && key !== "edges")) {
|
|
860
|
+
fail("invalid-batch", "GeoArrow extension metadata contains an undeclared property.");
|
|
861
|
+
}
|
|
862
|
+
const edges = value.edges ?? "planar";
|
|
863
|
+
if (!["planar", "spherical", "vincenty", "thomas", "andoyer", "karney"].includes(String(edges))) {
|
|
864
|
+
fail("unsupported-layout", `Unsupported GeoArrow edges metadata "${String(edges)}".`);
|
|
865
|
+
}
|
|
866
|
+
if (value.crs !== undefined &&
|
|
867
|
+
typeof value.crs !== "string" &&
|
|
868
|
+
(typeof value.crs !== "object" || value.crs === null || Array.isArray(value.crs))) {
|
|
869
|
+
fail("invalid-batch", "GeoArrow extension CRS metadata is malformed.");
|
|
870
|
+
}
|
|
871
|
+
const crsType = value.crs_type;
|
|
872
|
+
if (crsType !== undefined &&
|
|
873
|
+
(typeof crsType !== "string" || !GEOARROW_CRS_TYPES.includes(crsType))) {
|
|
874
|
+
fail("invalid-batch", `Unsupported GeoArrow crs_type metadata "${String(crsType)}".`);
|
|
875
|
+
}
|
|
876
|
+
if (crsType !== undefined && value.crs === undefined) {
|
|
877
|
+
fail("invalid-batch", "GeoArrow crs_type metadata requires crs metadata.");
|
|
878
|
+
}
|
|
879
|
+
return Object.freeze({
|
|
880
|
+
...(value.crs === undefined ? {} : { crs: value.crs }),
|
|
881
|
+
...(crsType === undefined ? {} : { crsType: crsType }),
|
|
882
|
+
edges: edges,
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
catch (cause) {
|
|
886
|
+
if (cause instanceof HonuaGeoArrowError) {
|
|
887
|
+
if (cause.code !== "invalid-input")
|
|
888
|
+
throw cause;
|
|
889
|
+
fail("invalid-batch", "GeoArrow extension metadata exceeds the bounded JSON contract.", cause.detail, cause);
|
|
890
|
+
}
|
|
891
|
+
fail("invalid-batch", "GeoArrow extension metadata is not valid JSON.", undefined, cause);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
function bufferMap(batch) {
|
|
895
|
+
return new Map(batch.buffers.map((buffer) => [buffer.id, buffer]));
|
|
896
|
+
}
|
|
897
|
+
function optionalBuffer(buffers, id) {
|
|
898
|
+
return buffers.get(id);
|
|
899
|
+
}
|
|
900
|
+
function requiredBuffer(buffers, id) {
|
|
901
|
+
const buffer = buffers.get(id);
|
|
902
|
+
if (!buffer)
|
|
903
|
+
fail("invalid-batch", `GeoArrow batch is missing buffer "${id}".`, { bufferId: id });
|
|
904
|
+
return buffer;
|
|
905
|
+
}
|
|
906
|
+
function typedView(buffer, ctor) {
|
|
907
|
+
if (buffer.byteOffset % ctor.BYTES_PER_ELEMENT !== 0 || buffer.byteLength % ctor.BYTES_PER_ELEMENT !== 0) {
|
|
908
|
+
fail("invalid-batch", `Buffer "${buffer.id}" is not component-aligned.`, { bufferId: buffer.id });
|
|
909
|
+
}
|
|
910
|
+
try {
|
|
911
|
+
return new ctor(buffer.data, buffer.byteOffset, buffer.byteLength / ctor.BYTES_PER_ELEMENT);
|
|
912
|
+
}
|
|
913
|
+
catch (cause) {
|
|
914
|
+
fail("invalid-batch", `Buffer "${buffer.id}" cannot be viewed with its normative component type.`, undefined, cause);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
function optionalValidity(buffers, id, rows) {
|
|
918
|
+
const descriptor = optionalBuffer(buffers, id);
|
|
919
|
+
if (!descriptor)
|
|
920
|
+
return undefined;
|
|
921
|
+
const view = typedView(descriptor, Uint8Array);
|
|
922
|
+
if (view.length !== Math.ceil(rows / 8)) {
|
|
923
|
+
fail("invalid-batch", `Validity buffer "${id}" has the wrong length.`, {
|
|
924
|
+
expected: Math.ceil(rows / 8),
|
|
925
|
+
actual: view.length,
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
return view;
|
|
929
|
+
}
|
|
930
|
+
function offsetsView(buffer, length, label) {
|
|
931
|
+
const view = typedView(buffer, Int32Array);
|
|
932
|
+
if (view.length !== length)
|
|
933
|
+
fail("invalid-batch", `${label} must contain ${length} int32 offsets.`);
|
|
934
|
+
if (view[0] !== 0)
|
|
935
|
+
fail("invalid-batch", `${label} must start at zero.`);
|
|
936
|
+
for (let index = 1; index < view.length; index += 1) {
|
|
937
|
+
if (view[index] < view[index - 1])
|
|
938
|
+
fail("invalid-batch", `${label} must be monotonic.`);
|
|
939
|
+
}
|
|
940
|
+
return view;
|
|
941
|
+
}
|
|
942
|
+
function assertCount(count, limit, code, label) {
|
|
943
|
+
if (count > limit)
|
|
944
|
+
fail(code, `${label} ${count} exceeds the conversion limit ${limit}.`, { actual: count, limit });
|
|
945
|
+
}
|
|
946
|
+
function assertType(field, name, parameters, label) {
|
|
947
|
+
if (field.type.name !== name)
|
|
948
|
+
fail("invalid-batch", `${label} must use type "${name}".`);
|
|
949
|
+
const actual = field.type.parameters;
|
|
950
|
+
const expectedEntries = Object.entries(parameters ?? {}).sort(([left], [right]) => left.localeCompare(right));
|
|
951
|
+
const actualEntries = Object.entries(actual ?? {}).sort(([left], [right]) => left.localeCompare(right));
|
|
952
|
+
if (actualEntries.length !== expectedEntries.length ||
|
|
953
|
+
actualEntries.some(([key, value], index) => key !== expectedEntries[index]?.[0] || value !== expectedEntries[index]?.[1])) {
|
|
954
|
+
fail("invalid-batch", `${label} type parameters do not match the normative GeoArrow layout.`, {
|
|
955
|
+
expected: Object.fromEntries(expectedEntries),
|
|
956
|
+
actual: Object.fromEntries(actualEntries),
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
function singleChild(field, name, label) {
|
|
961
|
+
if (field.children?.length !== 1 || field.children[0]?.name !== name) {
|
|
962
|
+
fail("invalid-batch", `${label} must contain exactly one child named "${name}".`);
|
|
963
|
+
}
|
|
964
|
+
return field.children[0];
|
|
965
|
+
}
|
|
966
|
+
function assertCoordinateSchema(coordinate, dimensions, layout, label) {
|
|
967
|
+
const names = dimensionNames(dimensions);
|
|
968
|
+
if (coordinate.nullable)
|
|
969
|
+
fail("invalid-batch", `${label} coordinate storage must be non-nullable.`);
|
|
970
|
+
if (layout === "interleaved") {
|
|
971
|
+
if (coordinate.name !== names.join(""))
|
|
972
|
+
fail("invalid-batch", `${label} has the wrong interleaved child name.`);
|
|
973
|
+
assertType(coordinate, "fixed_size_list", { size: names.length, valueType: "float64" }, label);
|
|
974
|
+
const values = singleChild(coordinate, "values", label);
|
|
975
|
+
if (values.nullable || values.children !== undefined)
|
|
976
|
+
fail("invalid-batch", `${label}.values must be a non-nullable leaf.`);
|
|
977
|
+
assertType(values, "float64", undefined, `${label}.values`);
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
if (coordinate.name !== "coordinates")
|
|
981
|
+
fail("invalid-batch", `${label} separated coordinates must be named "coordinates".`);
|
|
982
|
+
assertType(coordinate, "struct", { dimensions }, label);
|
|
983
|
+
if (coordinate.children?.length !== names.length)
|
|
984
|
+
fail("invalid-batch", `${label} has the wrong coordinate child count.`);
|
|
985
|
+
names.forEach((name, index) => {
|
|
986
|
+
const child = coordinate.children?.[index];
|
|
987
|
+
if (!child || child.name !== name || child.nullable || child.children !== undefined) {
|
|
988
|
+
fail("invalid-batch", `${label}.${name} must be a non-nullable float64 leaf in dimension order.`);
|
|
989
|
+
}
|
|
990
|
+
assertType(child, "float64", undefined, `${label}.${name}`);
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
function assertGeometrySchema(field, kind, dimensions, layout) {
|
|
994
|
+
assertType(field, `geoarrow.${kind}`, { dimensions, coordinateLayout: layout, offsetType: "int32" }, `geometry field "${field.name}"`);
|
|
995
|
+
let coordinate;
|
|
996
|
+
if (kind === "point")
|
|
997
|
+
coordinate = singleChild(field, layout === "interleaved" ? dimensionNames(dimensions).join("") : "coordinates", field.name);
|
|
998
|
+
else if (kind === "linestring") {
|
|
999
|
+
const vertices = singleChild(field, "vertices", field.name);
|
|
1000
|
+
if (vertices.nullable)
|
|
1001
|
+
fail("invalid-batch", `${field.name}.vertices must be non-nullable.`);
|
|
1002
|
+
assertType(vertices, "list", { offsetType: "int32" }, `${field.name}.vertices`);
|
|
1003
|
+
coordinate = singleChild(vertices, layout === "interleaved" ? dimensionNames(dimensions).join("") : "coordinates", `${field.name}.vertices`);
|
|
1004
|
+
}
|
|
1005
|
+
else {
|
|
1006
|
+
const rings = singleChild(field, "rings", field.name);
|
|
1007
|
+
if (rings.nullable)
|
|
1008
|
+
fail("invalid-batch", `${field.name}.rings must be non-nullable.`);
|
|
1009
|
+
assertType(rings, "list", { offsetType: "int32" }, `${field.name}.rings`);
|
|
1010
|
+
const vertices = singleChild(rings, "vertices", `${field.name}.rings`);
|
|
1011
|
+
if (vertices.nullable)
|
|
1012
|
+
fail("invalid-batch", `${field.name}.rings.vertices must be non-nullable.`);
|
|
1013
|
+
assertType(vertices, "list", { offsetType: "int32" }, `${field.name}.rings.vertices`);
|
|
1014
|
+
coordinate = singleChild(vertices, layout === "interleaved" ? dimensionNames(dimensions).join("") : "coordinates", `${field.name}.rings.vertices`);
|
|
1015
|
+
}
|
|
1016
|
+
assertCoordinateSchema(coordinate, dimensions, layout, `${field.name} coordinate storage`);
|
|
1017
|
+
}
|
|
1018
|
+
function inspectGeometry(batch, buffers, limits, scope) {
|
|
1019
|
+
const metadata = batch.schema.metadata;
|
|
1020
|
+
const fieldName = requireMetadata(metadata, META.geometryField);
|
|
1021
|
+
const field = batch.schema.fields.find((candidate) => candidate.name === fieldName);
|
|
1022
|
+
if (!field)
|
|
1023
|
+
fail("invalid-batch", `GeoArrow geometry field "${fieldName}" does not exist.`);
|
|
1024
|
+
const kind = requireMetadata(metadata, META.geometryKind);
|
|
1025
|
+
if (!["point", "linestring", "polygon"].includes(kind))
|
|
1026
|
+
fail("unsupported-layout", `Unsupported geometry kind "${kind}".`);
|
|
1027
|
+
if (field.type.name !== `geoarrow.${kind}` || field.metadata?.["ARROW:extension:name"] !== `geoarrow.${kind}`) {
|
|
1028
|
+
fail("invalid-batch", `Geometry field "${fieldName}" does not carry the expected GeoArrow extension name.`);
|
|
1029
|
+
}
|
|
1030
|
+
const dimensions = requireMetadata(metadata, META.dimensions);
|
|
1031
|
+
const names = dimensionNames(dimensions);
|
|
1032
|
+
const coordinateLayout = requireMetadata(metadata, META.coordinateLayout);
|
|
1033
|
+
if (coordinateLayout !== "interleaved" && coordinateLayout !== "separated") {
|
|
1034
|
+
fail("unsupported-layout", `Unsupported coordinate layout "${coordinateLayout}".`);
|
|
1035
|
+
}
|
|
1036
|
+
assertGeometrySchema(field, kind, dimensions, coordinateLayout);
|
|
1037
|
+
const extension = parseExtensionMetadata(field, limits);
|
|
1038
|
+
const validity = optionalValidity(buffers, `${fieldName}.validity`, batch.rowCount);
|
|
1039
|
+
if (field.nullable !== (validity !== undefined)) {
|
|
1040
|
+
fail("invalid-batch", `Geometry field "${fieldName}" nullability must match its validity buffer.`);
|
|
1041
|
+
}
|
|
1042
|
+
let offsets;
|
|
1043
|
+
let ringOffsets;
|
|
1044
|
+
let vertices;
|
|
1045
|
+
if (kind === "point")
|
|
1046
|
+
vertices = batch.rowCount;
|
|
1047
|
+
else {
|
|
1048
|
+
offsets = offsetsView(requiredBuffer(buffers, `${fieldName}.offsets`), batch.rowCount + 1, "geometry offsets");
|
|
1049
|
+
if (kind === "polygon") {
|
|
1050
|
+
const rings = offsets[offsets.length - 1];
|
|
1051
|
+
assertCount(rings, limits.maxRings, "ring-limit-exceeded", "Ring count");
|
|
1052
|
+
ringOffsets = offsetsView(requiredBuffer(buffers, `${fieldName}.ring-offsets`), rings + 1, "ring offsets");
|
|
1053
|
+
vertices = ringOffsets[ringOffsets.length - 1];
|
|
1054
|
+
}
|
|
1055
|
+
else
|
|
1056
|
+
vertices = offsets[offsets.length - 1];
|
|
1057
|
+
}
|
|
1058
|
+
assertCount(vertices, limits.maxVertices, "vertex-limit-exceeded", "Vertex count");
|
|
1059
|
+
const coordinates = {
|
|
1060
|
+
x: undefined,
|
|
1061
|
+
y: undefined,
|
|
1062
|
+
z: undefined,
|
|
1063
|
+
m: undefined,
|
|
1064
|
+
interleaved: undefined,
|
|
1065
|
+
};
|
|
1066
|
+
if (coordinateLayout === "interleaved") {
|
|
1067
|
+
const view = typedView(requiredBuffer(buffers, `${fieldName}.coordinates`), Float64Array);
|
|
1068
|
+
if (view.length !== vertices * names.length)
|
|
1069
|
+
fail("invalid-batch", "Interleaved coordinate buffer length is inconsistent with geometry offsets.");
|
|
1070
|
+
coordinates.interleaved = view;
|
|
1071
|
+
}
|
|
1072
|
+
else {
|
|
1073
|
+
names.forEach((name) => {
|
|
1074
|
+
const view = typedView(requiredBuffer(buffers, `${fieldName}.${name}`), Float64Array);
|
|
1075
|
+
if (view.length !== vertices)
|
|
1076
|
+
fail("invalid-batch", `Coordinate buffer "${name}" length is inconsistent with geometry offsets.`);
|
|
1077
|
+
coordinates[name] = view;
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
if (scope === "full") {
|
|
1081
|
+
if (kind === "point") {
|
|
1082
|
+
let allCoordinatesFinite = true;
|
|
1083
|
+
coordinateScan: for (const view of Object.values(coordinates)) {
|
|
1084
|
+
if (!view)
|
|
1085
|
+
continue;
|
|
1086
|
+
for (let index = 0; index < view.length; index += 1) {
|
|
1087
|
+
if (!Number.isFinite(view[index])) {
|
|
1088
|
+
allCoordinatesFinite = false;
|
|
1089
|
+
break coordinateScan;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
if (!allCoordinatesFinite) {
|
|
1094
|
+
for (let row = 0; row < batch.rowCount; row += 1) {
|
|
1095
|
+
if (!isValid(validity, row))
|
|
1096
|
+
continue;
|
|
1097
|
+
let allNaN = true;
|
|
1098
|
+
let allFinite = true;
|
|
1099
|
+
for (let dimension = 0; dimension < names.length; dimension += 1) {
|
|
1100
|
+
const coordinate = coordinateLayout === "interleaved"
|
|
1101
|
+
? coordinates.interleaved[row * names.length + dimension]
|
|
1102
|
+
: coordinates[names[dimension]][row];
|
|
1103
|
+
allNaN &&= Number.isNaN(coordinate);
|
|
1104
|
+
allFinite &&= Number.isFinite(coordinate);
|
|
1105
|
+
}
|
|
1106
|
+
if (!allNaN && !allFinite) {
|
|
1107
|
+
fail("invalid-batch", "Point coordinates must be finite, or all NaN for an empty Point.");
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
else {
|
|
1113
|
+
for (const view of Object.values(coordinates)) {
|
|
1114
|
+
if (!view)
|
|
1115
|
+
continue;
|
|
1116
|
+
// Indexed rather than iterated. `for...of` over a typed array pays the
|
|
1117
|
+
// iterator protocol per element, and this is the only unbounded loop a
|
|
1118
|
+
// full inspection runs: it visits every coordinate of every column, on
|
|
1119
|
+
// every inspection, including the one each realtime patch application
|
|
1120
|
+
// performs (#941). Against a million-row batch the iterator form is the
|
|
1121
|
+
// dominant cost of applying a patch; the indexed form checks exactly the
|
|
1122
|
+
// same values.
|
|
1123
|
+
for (let index = 0; index < view.length; index += 1) {
|
|
1124
|
+
if (!Number.isFinite(view[index]))
|
|
1125
|
+
fail("invalid-batch", "Coordinate buffers must contain finite values.");
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
return Object.freeze({
|
|
1131
|
+
kind,
|
|
1132
|
+
field: fieldName,
|
|
1133
|
+
dimensions,
|
|
1134
|
+
coordinateLayout,
|
|
1135
|
+
...extension,
|
|
1136
|
+
...(validity === undefined ? {} : { validity }),
|
|
1137
|
+
...(offsets === undefined ? {} : { offsets }),
|
|
1138
|
+
...(ringOffsets === undefined ? {} : { ringOffsets }),
|
|
1139
|
+
coordinates: Object.freeze(coordinates),
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
function inspectTemporal(batch, buffers) {
|
|
1143
|
+
const field = batch.schema.metadata?.[META.temporalField];
|
|
1144
|
+
if (field === undefined) {
|
|
1145
|
+
if (batch.schema.metadata?.[META.temporalUnit] !== undefined ||
|
|
1146
|
+
batch.schema.metadata?.[META.temporalTimezone] !== undefined) {
|
|
1147
|
+
fail("invalid-batch", "Timestamp unit/timezone metadata requires a declared timestamp field.");
|
|
1148
|
+
}
|
|
1149
|
+
return undefined;
|
|
1150
|
+
}
|
|
1151
|
+
const unit = requireMetadata(batch.schema.metadata, META.temporalUnit);
|
|
1152
|
+
if (!["second", "millisecond", "microsecond", "nanosecond"].includes(unit)) {
|
|
1153
|
+
fail("unsupported-layout", `Unsupported timestamp unit "${unit}".`);
|
|
1154
|
+
}
|
|
1155
|
+
const values = typedView(requiredBuffer(buffers, `${field}.values`), BigInt64Array);
|
|
1156
|
+
if (values.length !== batch.rowCount)
|
|
1157
|
+
fail("invalid-batch", "Timestamp value buffer length must equal rowCount.");
|
|
1158
|
+
const validity = optionalValidity(buffers, `${field}.validity`, batch.rowCount);
|
|
1159
|
+
const schemaField = batch.schema.fields.find((candidate) => candidate.name === field);
|
|
1160
|
+
if (!schemaField)
|
|
1161
|
+
fail("invalid-batch", `Timestamp field "${field}" does not exist in the schema.`);
|
|
1162
|
+
const timezone = batch.schema.metadata?.[META.temporalTimezone];
|
|
1163
|
+
assertType(schemaField, "timestamp", { unit, ...(timezone === undefined ? {} : { timezone }) }, `timestamp field "${field}"`);
|
|
1164
|
+
if (schemaField.children !== undefined || schemaField.metadata !== undefined) {
|
|
1165
|
+
fail("invalid-batch", `Timestamp field "${field}" must be a metadata-free leaf.`);
|
|
1166
|
+
}
|
|
1167
|
+
if (schemaField.nullable !== (validity !== undefined)) {
|
|
1168
|
+
fail("invalid-batch", `Timestamp field "${field}" nullability must match its validity buffer.`);
|
|
1169
|
+
}
|
|
1170
|
+
return Object.freeze({
|
|
1171
|
+
field,
|
|
1172
|
+
unit,
|
|
1173
|
+
...(timezone === undefined ? {} : { timezone }),
|
|
1174
|
+
...(validity === undefined ? {} : { validity }),
|
|
1175
|
+
values,
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
function inspectDictionary(batch, buffers, limits, scope) {
|
|
1179
|
+
const field = batch.schema.metadata?.[META.dictionaryField];
|
|
1180
|
+
if (field === undefined) {
|
|
1181
|
+
if (batch.schema.metadata?.[META.dictionaryOrdered] !== undefined) {
|
|
1182
|
+
fail("invalid-batch", "Dictionary ordered metadata requires a declared dictionary field.");
|
|
1183
|
+
}
|
|
1184
|
+
return undefined;
|
|
1185
|
+
}
|
|
1186
|
+
const indices = typedView(requiredBuffer(buffers, `${field}.indices`), Int32Array);
|
|
1187
|
+
if (indices.length !== batch.rowCount)
|
|
1188
|
+
fail("invalid-batch", "Dictionary index buffer length must equal rowCount.");
|
|
1189
|
+
const offsetsDescriptor = requiredBuffer(buffers, `${field}.offsets`);
|
|
1190
|
+
const offsets = typedView(offsetsDescriptor, Int32Array);
|
|
1191
|
+
if (offsets.length === 0)
|
|
1192
|
+
fail("invalid-batch", "Dictionary offsets must contain at least one value.");
|
|
1193
|
+
if (offsets.length - 1 > limits.maxDictionaryValues) {
|
|
1194
|
+
fail("dictionary-limit-exceeded", `Dictionary has ${offsets.length - 1} values; the limit is ${limits.maxDictionaryValues}.`);
|
|
1195
|
+
}
|
|
1196
|
+
offsetsView(offsetsDescriptor, offsets.length, "dictionary offsets");
|
|
1197
|
+
const values = typedView(requiredBuffer(buffers, `${field}.values`), Uint8Array);
|
|
1198
|
+
if (offsets[offsets.length - 1] !== values.length)
|
|
1199
|
+
fail("invalid-batch", "Dictionary offsets do not span the UTF-8 value buffer.");
|
|
1200
|
+
const validity = optionalValidity(buffers, `${field}.validity`, batch.rowCount);
|
|
1201
|
+
const schemaField = batch.schema.fields.find((candidate) => candidate.name === field);
|
|
1202
|
+
if (!schemaField)
|
|
1203
|
+
fail("invalid-batch", `Dictionary field "${field}" does not exist in the schema.`);
|
|
1204
|
+
const orderedMetadata = batch.schema.metadata?.[META.dictionaryOrdered];
|
|
1205
|
+
if (orderedMetadata !== "true" && orderedMetadata !== "false") {
|
|
1206
|
+
fail("invalid-batch", `Dictionary field "${field}" must declare boolean ordered metadata.`);
|
|
1207
|
+
}
|
|
1208
|
+
const ordered = orderedMetadata === "true";
|
|
1209
|
+
assertType(schemaField, "dictionary", { indexType: "int32", valueType: "utf8", ordered }, `dictionary field "${field}"`);
|
|
1210
|
+
if (schemaField.children !== undefined || schemaField.metadata !== undefined) {
|
|
1211
|
+
fail("invalid-batch", `Dictionary field "${field}" must be a metadata-free leaf.`);
|
|
1212
|
+
}
|
|
1213
|
+
if (schemaField.nullable !== (validity !== undefined)) {
|
|
1214
|
+
fail("invalid-batch", `Dictionary field "${field}" nullability must match its validity buffer.`);
|
|
1215
|
+
}
|
|
1216
|
+
if (scope === "full") {
|
|
1217
|
+
for (let row = 0; row < batch.rowCount; row += 1) {
|
|
1218
|
+
if (isValid(validity, row) && (indices[row] < 0 || indices[row] >= offsets.length - 1)) {
|
|
1219
|
+
fail("invalid-batch", `Dictionary index at row ${row} is outside the dictionary.`);
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
try {
|
|
1223
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
1224
|
+
for (let index = 0; index < offsets.length - 1; index += 1) {
|
|
1225
|
+
decoder.decode(values.subarray(offsets[index], offsets[index + 1]));
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
catch (cause) {
|
|
1229
|
+
fail("invalid-batch", "Dictionary value bytes are not valid UTF-8.", undefined, cause);
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
return Object.freeze({
|
|
1233
|
+
field,
|
|
1234
|
+
ordered,
|
|
1235
|
+
...(validity === undefined ? {} : { validity }),
|
|
1236
|
+
indices,
|
|
1237
|
+
offsets,
|
|
1238
|
+
values,
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
1241
|
+
function assertExactFields(batch, geometry, temporal, dictionary, featureIds) {
|
|
1242
|
+
const expected = [geometry.field];
|
|
1243
|
+
if (temporal)
|
|
1244
|
+
expected.push(temporal.field);
|
|
1245
|
+
if (dictionary)
|
|
1246
|
+
expected.push(dictionary.field);
|
|
1247
|
+
if (featureIds)
|
|
1248
|
+
expected.push(featureIds.field);
|
|
1249
|
+
if (batch.schema.fields.length !== expected.length ||
|
|
1250
|
+
batch.schema.fields.some((field, index) => field.name !== expected[index])) {
|
|
1251
|
+
fail("invalid-batch", "Normative GeoArrow schema fields must appear exactly in geometry/temporal/dictionary/id order.", {
|
|
1252
|
+
expected,
|
|
1253
|
+
actual: batch.schema.fields.map(({ name }) => name),
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
function assertExactBuffers(batch, geometry, temporal, dictionary, featureIds) {
|
|
1258
|
+
const expected = new Map();
|
|
1259
|
+
const add = (id, role, field) => {
|
|
1260
|
+
expected.set(id, { role, field });
|
|
1261
|
+
};
|
|
1262
|
+
if (geometry.validity)
|
|
1263
|
+
add(`${geometry.field}.validity`, "validity", geometry.field);
|
|
1264
|
+
if (geometry.kind !== "point")
|
|
1265
|
+
add(`${geometry.field}.offsets`, "offsets", geometry.field);
|
|
1266
|
+
if (geometry.kind === "polygon")
|
|
1267
|
+
add(`${geometry.field}.ring-offsets`, "offsets", geometry.field);
|
|
1268
|
+
if (geometry.coordinateLayout === "interleaved")
|
|
1269
|
+
add(`${geometry.field}.coordinates`, "geometry", geometry.field);
|
|
1270
|
+
else
|
|
1271
|
+
for (const name of dimensionNames(geometry.dimensions))
|
|
1272
|
+
add(`${geometry.field}.${name}`, "geometry", geometry.field);
|
|
1273
|
+
if (temporal) {
|
|
1274
|
+
if (temporal.validity)
|
|
1275
|
+
add(`${temporal.field}.validity`, "validity", temporal.field);
|
|
1276
|
+
add(`${temporal.field}.values`, "values", temporal.field);
|
|
1277
|
+
}
|
|
1278
|
+
if (dictionary) {
|
|
1279
|
+
if (dictionary.validity)
|
|
1280
|
+
add(`${dictionary.field}.validity`, "validity", dictionary.field);
|
|
1281
|
+
add(`${dictionary.field}.indices`, "dictionary", dictionary.field);
|
|
1282
|
+
add(`${dictionary.field}.offsets`, "offsets", dictionary.field);
|
|
1283
|
+
add(`${dictionary.field}.values`, "dictionary", dictionary.field);
|
|
1284
|
+
}
|
|
1285
|
+
if (featureIds)
|
|
1286
|
+
add(`${featureIds.field}.values`, "values", featureIds.field);
|
|
1287
|
+
if (batch.buffers.length !== expected.size) {
|
|
1288
|
+
fail("invalid-batch", "Normative GeoArrow batch contains missing or undeclared buffers.", {
|
|
1289
|
+
expected: [...expected.keys()],
|
|
1290
|
+
actual: batch.buffers.map(({ id }) => id),
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
for (const buffer of batch.buffers) {
|
|
1294
|
+
const descriptor = expected.get(buffer.id);
|
|
1295
|
+
if (!descriptor || buffer.role !== descriptor.role || buffer.field !== descriptor.field) {
|
|
1296
|
+
fail("invalid-batch", `Buffer "${buffer.id}" does not match its normative role/field declaration.`, {
|
|
1297
|
+
bufferId: buffer.id,
|
|
1298
|
+
expected: descriptor,
|
|
1299
|
+
actual: { role: buffer.role, field: buffer.field },
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
/** Validate the normative mapping and return zero-copy typed-array views. */
|
|
1305
|
+
export function inspectGeoArrowBatch(batch, limits = {}) {
|
|
1306
|
+
return inspectGeoArrowBatchScoped(batch, limits, "full");
|
|
1307
|
+
}
|
|
1308
|
+
/**
|
|
1309
|
+
* {@link inspectGeoArrowBatch} with an explicit payload-validation scope.
|
|
1310
|
+
*
|
|
1311
|
+
* Only bounded conversions inside this module pass `"window"`, and they must
|
|
1312
|
+
* validate coordinate finiteness, dictionary index range, and dictionary UTF-8
|
|
1313
|
+
* for every row they materialize. It is deliberately absent from the package
|
|
1314
|
+
* surface: an external consumer must not be able to skip payload validation.
|
|
1315
|
+
*
|
|
1316
|
+
* @internal
|
|
1317
|
+
*/
|
|
1318
|
+
export function inspectGeoArrowBatchScoped(batch, limits, scope) {
|
|
1319
|
+
const resolved = resolveLimits(limits);
|
|
1320
|
+
let baseMetrics;
|
|
1321
|
+
try {
|
|
1322
|
+
// Snapshot every descriptor and identity field before interpreting layout
|
|
1323
|
+
// metadata. Payload ArrayBuffers remain aliased by identity.
|
|
1324
|
+
batch = createColumnarBatch(batch, limits);
|
|
1325
|
+
baseMetrics = inspectColumnarBatch(batch, limits);
|
|
1326
|
+
}
|
|
1327
|
+
catch (cause) {
|
|
1328
|
+
fail("invalid-batch", "The GeoArrow batch does not satisfy the columnar transport contract.", undefined, cause);
|
|
1329
|
+
}
|
|
1330
|
+
if (batch.rowCount > resolved.maxRows) {
|
|
1331
|
+
fail("row-limit-exceeded", `Batch has ${batch.rowCount} rows; the conversion limit is ${resolved.maxRows}.`);
|
|
1332
|
+
}
|
|
1333
|
+
if (batch.schema.metadata?.[META.version] !== HONUA_GEOARROW_LAYOUT_VERSION ||
|
|
1334
|
+
batch.schema.metadata?.[META.specVersion] !== GEOARROW_SPEC_VERSION) {
|
|
1335
|
+
fail("unsupported-layout", `Batch must use Honua GeoArrow ${HONUA_GEOARROW_LAYOUT_VERSION} over GeoArrow ${GEOARROW_SPEC_VERSION}.`);
|
|
1336
|
+
}
|
|
1337
|
+
if (!batch.identity)
|
|
1338
|
+
fail("invalid-batch", "A normative GeoArrow batch must carry cache/auth/order/freshness identity.");
|
|
1339
|
+
const buffers = bufferMap(batch);
|
|
1340
|
+
const geometry = inspectGeometry(batch, buffers, resolved, scope);
|
|
1341
|
+
const temporal = inspectTemporal(batch, buffers);
|
|
1342
|
+
const dictionary = inspectDictionary(batch, buffers, resolved, scope);
|
|
1343
|
+
const featureIdField = batch.schema.metadata?.[META.featureIdField];
|
|
1344
|
+
let featureIds;
|
|
1345
|
+
if (featureIdField !== undefined) {
|
|
1346
|
+
const values = typedView(requiredBuffer(buffers, `${featureIdField}.values`), Uint32Array);
|
|
1347
|
+
if (values.length !== batch.rowCount)
|
|
1348
|
+
fail("invalid-batch", "Feature id buffer length must equal rowCount.");
|
|
1349
|
+
const schemaField = batch.schema.fields.find((candidate) => candidate.name === featureIdField);
|
|
1350
|
+
if (!schemaField)
|
|
1351
|
+
fail("invalid-batch", `Feature id field "${featureIdField}" does not exist in the schema.`);
|
|
1352
|
+
assertType(schemaField, "uint32", undefined, `feature id field "${featureIdField}"`);
|
|
1353
|
+
if (schemaField.nullable || schemaField.children !== undefined || schemaField.metadata !== undefined) {
|
|
1354
|
+
fail("invalid-batch", `Feature id field "${featureIdField}" must be a non-nullable scalar.`);
|
|
1355
|
+
}
|
|
1356
|
+
featureIds = Object.freeze({ field: featureIdField, values });
|
|
1357
|
+
}
|
|
1358
|
+
assertExactFields(batch, geometry, temporal, dictionary, featureIds);
|
|
1359
|
+
assertExactBuffers(batch, geometry, temporal, dictionary, featureIds);
|
|
1360
|
+
const vertices = geometry.kind === "point"
|
|
1361
|
+
? batch.rowCount
|
|
1362
|
+
: geometry.kind === "polygon"
|
|
1363
|
+
? geometry.ringOffsets[geometry.ringOffsets.length - 1]
|
|
1364
|
+
: geometry.offsets[geometry.offsets.length - 1];
|
|
1365
|
+
const rings = geometry.kind === "polygon" ? geometry.offsets[geometry.offsets.length - 1] : 0;
|
|
1366
|
+
return Object.freeze({
|
|
1367
|
+
batch,
|
|
1368
|
+
geometry,
|
|
1369
|
+
...(temporal === undefined ? {} : { temporal }),
|
|
1370
|
+
...(dictionary === undefined ? {} : { dictionary }),
|
|
1371
|
+
...(featureIds === undefined ? {} : { featureIds }),
|
|
1372
|
+
metrics: Object.freeze({
|
|
1373
|
+
rows: batch.rowCount,
|
|
1374
|
+
vertices,
|
|
1375
|
+
rings,
|
|
1376
|
+
dictionaryValues: dictionary ? dictionary.offsets.length - 1 : 0,
|
|
1377
|
+
copiedBytes: 0,
|
|
1378
|
+
backingBytes: baseMetrics.backingBytes,
|
|
1379
|
+
}),
|
|
1380
|
+
});
|
|
1381
|
+
}
|
|
1382
|
+
function isValid(validity, index) {
|
|
1383
|
+
return validity === undefined || (validity[index >> 3] & (1 << (index & 7))) !== 0;
|
|
1384
|
+
}
|
|
1385
|
+
/**
|
|
1386
|
+
* Row-level primitives shared with the bounded object-`Result` conversion.
|
|
1387
|
+
* They are module exports rather than package exports: `columnar/index.ts`
|
|
1388
|
+
* deliberately does not re-export them.
|
|
1389
|
+
*
|
|
1390
|
+
* @internal
|
|
1391
|
+
*/
|
|
1392
|
+
export { decodeGeometry as decodeGeoArrowGeometryAt, isValid as isGeoArrowRowValid };
|
|
1393
|
+
function positionAt(geometry, vertex) {
|
|
1394
|
+
const names = dimensionNames(geometry.dimensions);
|
|
1395
|
+
if (geometry.coordinateLayout === "interleaved") {
|
|
1396
|
+
const values = geometry.coordinates.interleaved;
|
|
1397
|
+
return Object.freeze(names.map((_, dimension) => values[vertex * names.length + dimension]));
|
|
1398
|
+
}
|
|
1399
|
+
return Object.freeze(names.map((name) => geometry.coordinates[name][vertex]));
|
|
1400
|
+
}
|
|
1401
|
+
function decodeGeometry(geometry, row) {
|
|
1402
|
+
if (!isValid(geometry.validity, row))
|
|
1403
|
+
return null;
|
|
1404
|
+
if (geometry.kind === "point")
|
|
1405
|
+
return positionAt(geometry, row);
|
|
1406
|
+
const start = geometry.offsets[row];
|
|
1407
|
+
const end = geometry.offsets[row + 1];
|
|
1408
|
+
if (geometry.kind === "linestring") {
|
|
1409
|
+
const line = [];
|
|
1410
|
+
for (let vertex = start; vertex < end; vertex += 1)
|
|
1411
|
+
line.push(positionAt(geometry, vertex));
|
|
1412
|
+
return Object.freeze(line);
|
|
1413
|
+
}
|
|
1414
|
+
const polygon = [];
|
|
1415
|
+
for (let ring = start; ring < end; ring += 1) {
|
|
1416
|
+
const ringPositions = [];
|
|
1417
|
+
for (let vertex = geometry.ringOffsets[ring]; vertex < geometry.ringOffsets[ring + 1]; vertex += 1) {
|
|
1418
|
+
ringPositions.push(positionAt(geometry, vertex));
|
|
1419
|
+
}
|
|
1420
|
+
polygon.push(Object.freeze(ringPositions));
|
|
1421
|
+
}
|
|
1422
|
+
return Object.freeze(polygon);
|
|
1423
|
+
}
|
|
1424
|
+
function decodeDictionary(dictionary) {
|
|
1425
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
1426
|
+
const values = [];
|
|
1427
|
+
for (let index = 0; index < dictionary.offsets.length - 1; index += 1) {
|
|
1428
|
+
values.push(decoder.decode(dictionary.values.subarray(dictionary.offsets[index], dictionary.offsets[index + 1])));
|
|
1429
|
+
}
|
|
1430
|
+
return Object.freeze(values);
|
|
1431
|
+
}
|
|
1432
|
+
/**
|
|
1433
|
+
* Explicitly materialize bounded semantic rows. Callers cannot accidentally
|
|
1434
|
+
* decode an unbounded result because the same finite row/vertex/ring limits are
|
|
1435
|
+
* mandatory and defaulted before any row object is created.
|
|
1436
|
+
*/
|
|
1437
|
+
export function decodeGeoArrowBatch(batch, limits = {}) {
|
|
1438
|
+
const inspection = inspectGeoArrowBatch(batch, limits);
|
|
1439
|
+
const dictionaryValues = inspection.dictionary ? decodeDictionary(inspection.dictionary) : undefined;
|
|
1440
|
+
const rows = [];
|
|
1441
|
+
for (let row = 0; row < batch.rowCount; row += 1) {
|
|
1442
|
+
const timestamp = inspection.temporal
|
|
1443
|
+
? isValid(inspection.temporal.validity, row)
|
|
1444
|
+
? inspection.temporal.values[row]
|
|
1445
|
+
: null
|
|
1446
|
+
: undefined;
|
|
1447
|
+
const dictionaryValue = inspection.dictionary
|
|
1448
|
+
? isValid(inspection.dictionary.validity, row)
|
|
1449
|
+
? dictionaryValues[inspection.dictionary.indices[row]]
|
|
1450
|
+
: null
|
|
1451
|
+
: undefined;
|
|
1452
|
+
rows.push(Object.freeze({
|
|
1453
|
+
geometry: decodeGeometry(inspection.geometry, row),
|
|
1454
|
+
...(timestamp === undefined ? {} : { timestamp }),
|
|
1455
|
+
...(dictionaryValue === undefined ? {} : { dictionaryValue }),
|
|
1456
|
+
...(inspection.featureIds === undefined ? {} : { featureId: inspection.featureIds.values[row] }),
|
|
1457
|
+
}));
|
|
1458
|
+
}
|
|
1459
|
+
return Object.freeze({
|
|
1460
|
+
rows: Object.freeze(rows),
|
|
1461
|
+
metrics: Object.freeze({
|
|
1462
|
+
rows: inspection.metrics.rows,
|
|
1463
|
+
vertices: inspection.metrics.vertices,
|
|
1464
|
+
rings: inspection.metrics.rings,
|
|
1465
|
+
dictionaryValues: inspection.metrics.dictionaryValues,
|
|
1466
|
+
materializedRows: rows.length,
|
|
1467
|
+
}),
|
|
1468
|
+
});
|
|
1469
|
+
}
|