webxtile 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +194 -0
  2. package/package.json +30 -0
  3. package/webxtile.js +460 -0
package/README.md ADDED
@@ -0,0 +1,194 @@
1
+ # webxtile (JS)
2
+
3
+ Browser client for the [webxtile](../py) octree format. Read-only; designed
4
+ for partial bbox loads and level-of-detail rendering in web applications.
5
+
6
+ The Python library writes a directory of msgpack tile files. This library
7
+ reads those files over HTTP, caches them in IndexedDB, and returns flat typed
8
+ arrays suitable for WebGL / point-cloud rendering.
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ npm install webxtile
14
+ ```
15
+
16
+ Or install directly from the local source tree:
17
+
18
+ ```bash
19
+ npm install ./deps/webxtile/js
20
+ ```
21
+
22
+ ## Quick start
23
+
24
+ ```js
25
+ import { WebxtileLoader } from "webxtile";
26
+
27
+ const loader = new WebxtileLoader("https://example.com/tiles");
28
+ await loader.open(); // fetches metadata.msgpack
29
+
30
+ // Full-resolution load for a 2-D bounding box
31
+ const result = await loader.loadBBox([500000, 6200000, 520000, 6220000]);
32
+
33
+ // Flat arrays for rendering
34
+ const { coords, variables, count } = result.toScatter();
35
+ // coords.x, coords.y — Float32Array, one value per grid point
36
+ // variables.resistivity — Float32Array, same length as coords
37
+ ```
38
+
39
+ ## API
40
+
41
+ ### `new WebxtileLoader(baseUrl, [options])`
42
+
43
+ | Parameter | Type | Default | Description |
44
+ |-----------|------|---------|-------------|
45
+ | `baseUrl` | `string` | — | Base URL of the tile directory (no trailing slash). |
46
+ | `options.dbName` | `string` | `"webxtile-cache"` | IndexedDB database name. Use a unique name per dataset when serving multiple datasets from the same origin. |
47
+
48
+ ---
49
+
50
+ ### `loader.open()` → `Promise<object>`
51
+
52
+ Fetches `metadata.msgpack` and opens the IndexedDB tile cache. Must be
53
+ awaited before calling `loadBBox`.
54
+
55
+ Returns the decoded metadata object.
56
+
57
+ ---
58
+
59
+ ### `loader.meta`
60
+
61
+ The metadata object loaded by `open()`, or `null` before `open()` is called.
62
+ Contains `spatial_dims`, `crs`, `z_crs`, `dim_sizes`, `var_meta`,
63
+ `coord_meta`, and `global_attrs`.
64
+
65
+ ---
66
+
67
+ ### `loader.loadBBox(bbox, [options])` → `Promise<WebxtileResult>`
68
+
69
+ Load all tiles that intersect `bbox` down to the requested depth.
70
+
71
+ | Parameter | Type | Default | Description |
72
+ |-----------|------|---------|-------------|
73
+ | `bbox` | `number[] \| null` | `null` | Spatial bounding box. `null` = no filter (load everything). |
74
+ | `options.level` | `number \| null` | `null` | Maximum octree depth. `null` = leaf tiles (full resolution). `0` = root tile only (coarsest overview). |
75
+
76
+ **`bbox` format**
77
+
78
+ - 2-D data: `[x_min, y_min, x_max, y_max]`
79
+ - 3-D data: `[x_min, y_min, z_min, x_max, y_max, z_max]`
80
+
81
+ Coordinates must be in the same CRS as the dataset (see `loader.meta.crs`).
82
+
83
+ **Level-of-detail**
84
+
85
+ Each level halves the spatial resolution relative to the level above. When a
86
+ branch of the octree terminates before the requested level is reached, the
87
+ deepest available tile for that branch is returned so that spatial coverage is
88
+ always complete.
89
+
90
+ ```js
91
+ // Coarsest overview (root tile only)
92
+ const lo = await loader.loadBBox(null, { level: 0 });
93
+
94
+ // Medium detail
95
+ const mid = await loader.loadBBox(bbox, { level: 2 });
96
+
97
+ // Full resolution (default)
98
+ const hi = await loader.loadBBox(bbox);
99
+ ```
100
+
101
+ ---
102
+
103
+ ### `loader.clearCache()` → `Promise<void>`
104
+
105
+ Evicts all tiles from both the in-memory session cache and IndexedDB. Useful
106
+ when the server-side data has been regenerated.
107
+
108
+ ---
109
+
110
+ ### `WebxtileResult`
111
+
112
+ Returned by `loadBBox`.
113
+
114
+ #### Properties
115
+
116
+ | Property | Type | Description |
117
+ |----------|------|-------------|
118
+ | `meta` | `object` | Full metadata from `metadata.msgpack`. |
119
+ | `tiles` | `object[]` | Raw decoded tile objects. |
120
+ | `spatialDims` | `string[]` | Spatial dimension names, e.g. `["x","y"]`. |
121
+ | `crs` | `string \| null` | Horizontal CRS string or null. |
122
+ | `zCrs` | `string \| null` | Vertical CRS string or null. |
123
+ | `varMeta` | `object` | Per-variable metadata (`dims`, `dtype`, `attrs`). |
124
+ | `coordMeta` | `object` | Per-coordinate metadata (may include `values` for non-spatial coords). |
125
+
126
+ #### `result.toScatter()` → `{ coords, variables, count }`
127
+
128
+ Expands all tile grids into flat parallel arrays suitable for scatter-plot or
129
+ point-cloud rendering.
130
+
131
+ For each tile the 1-D `spatial_coords` arrays are turned into a full meshgrid;
132
+ every data variable is sampled at each resulting grid point.
133
+
134
+ - `coords` — `Object<string, Float32Array>` — one array per spatial dimension,
135
+ e.g. `coords.x`, `coords.y`, `coords.z`.
136
+ - `variables` — `Object<string, Float32Array>` — one array per data variable,
137
+ same length as `coords`.
138
+ - `count` — `number` — number of grid points (length of each array).
139
+
140
+ Variables that have non-spatial dimensions (e.g. time) are sampled at index 0
141
+ of each non-spatial axis. Access `result.tiles` directly for full control.
142
+
143
+ ```js
144
+ const { coords, variables, count } = result.toScatter();
145
+
146
+ // WebGL example
147
+ const buf = gl.createBuffer();
148
+ gl.bindBuffer(gl.ARRAY_BUFFER, buf);
149
+ gl.bufferData(gl.ARRAY_BUFFER, coords.x, gl.STATIC_DRAW);
150
+ ```
151
+
152
+ #### `result.getCoord(dimName)` → `Float64Array`
153
+
154
+ Returns the merged, sorted, deduplicated coordinate values for one spatial
155
+ dimension across all loaded tiles. Useful for reconstructing a regular grid
156
+ axis without going through `toScatter`.
157
+
158
+ ```js
159
+ const xValues = result.getCoord("x"); // Float64Array, sorted ascending
160
+ ```
161
+
162
+ ## Caching
163
+
164
+ Tiles are fetched once and stored as raw msgpack bytes in IndexedDB. On
165
+ subsequent loads (within the same session or across sessions) tiles are served
166
+ from the cache without a network request.
167
+
168
+ The in-memory session cache additionally avoids repeated IndexedDB reads within
169
+ a single page load.
170
+
171
+ ## Tile format reference
172
+
173
+ Each tile file is a msgpack map with the following keys:
174
+
175
+ | Key | Type | Description |
176
+ |-----|------|-------------|
177
+ | `level` | `int` | Octree depth (0 = root). |
178
+ | `is_leaf` | `bool` | `true` for leaf nodes, `false` for internal nodes. |
179
+ | `bounds` | `number[6]` | `[x_min, y_min, z_min, x_max, y_max, z_max]` (always 6 elements; padded with `0.0` for 2-D data). |
180
+ | `shape` | `int[]` | Grid point count per spatial dimension for this tile. |
181
+ | `spatial_coords` | `object` | 1-D coordinate array per spatial dimension. |
182
+ | `variables` | `object` | Data variable arrays (float32) at this tile's resolution. |
183
+ | `children` | `string[]` | Child tile filenames (internal nodes only). |
184
+
185
+ Numpy arrays in the msgpack files are encoded by `msgpack-numpy` as plain maps
186
+ `{ nd: true, type: "<f4", shape: [...], data: <bytes> }` and are decoded
187
+ transparently to `Float32Array` / `Float64Array` by this library.
188
+
189
+ ## Dependencies
190
+
191
+ | Package | Purpose |
192
+ |---------|---------|
193
+ | [`@msgpack/msgpack`](https://github.com/msgpack/msgpack-javascript) | msgpack decoding |
194
+ | [`idb`](https://github.com/jakearchibald/idb) | Promise-based IndexedDB wrapper |
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "webxtile",
3
+ "version": "0.0.1",
4
+ "description": "Browser client for the webxtile octree format — partial bbox loads for web visualisation",
5
+ "type": "module",
6
+ "main": "webxtile.js",
7
+ "exports": {
8
+ ".": "./webxtile.js"
9
+ },
10
+ "files": [
11
+ "webxtile.js"
12
+ ],
13
+ "license": "MIT",
14
+ "keywords": [
15
+ "webxtile",
16
+ "geophysics",
17
+ "octree",
18
+ "quadtree",
19
+ "msgpack",
20
+ "xarray",
21
+ "webgl",
22
+ "visualization",
23
+ "level-of-detail",
24
+ "lod"
25
+ ],
26
+ "dependencies": {
27
+ "@msgpack/msgpack": "^3.0.0",
28
+ "idb": "^8.0.0"
29
+ }
30
+ }
package/webxtile.js ADDED
@@ -0,0 +1,460 @@
1
+ /**
2
+ * webxtile.js
3
+ * Browser client for the webxtile octree format.
4
+ * Read-only; designed for partial bbox loads for web visualisation.
5
+ *
6
+ * @example
7
+ * import { WebxtileLoader } from "webxtile";
8
+ *
9
+ * const loader = new WebxtileLoader("https://example.com/tiles");
10
+ * await loader.open(); // loads metadata.msgpack
11
+ *
12
+ * // Full-resolution load for a 2-D bbox
13
+ * const result = await loader.loadBBox([x0, y0, x1, y1]);
14
+ * // Level-of-detail (0 = coarsest overview)
15
+ * const lo = await loader.loadBBox([x0, y0, x1, y1], { level: 2 });
16
+ *
17
+ * // Flat scatter arrays for WebGL / point-cloud rendering
18
+ * const { coords, variables, count } = result.toScatter();
19
+ * // coords.x, coords.y — Float32Array, one value per grid point
20
+ * // variables.resistivity — Float32Array, same length
21
+ */
22
+
23
+ import { decode } from "@msgpack/msgpack";
24
+ import { openDB } from "idb";
25
+
26
+ // ─── NumPy array decoding ─────────────────────────────────────────────────────
27
+ //
28
+ // msgpack_numpy encodes numpy arrays as plain msgpack maps:
29
+ // { nd: true, type: '<f4', shape: [n, …], data: <bin> }
30
+ //
31
+ // The 'type' string follows NumPy's dtype.str convention:
32
+ // byte order prefix: '<' (little-endian), '>' (big-endian), '|' (n/a)
33
+ // kind+itemsize: 'u1','u2','u4','i1','i2','i4','f4','f8'
34
+
35
+ const _DTYPE_CTORS = {
36
+ '|u1': Uint8Array,
37
+ '<u2': Uint16Array, '>u2': Uint16Array,
38
+ '<u4': Uint32Array, '>u4': Uint32Array,
39
+ '|i1': Int8Array,
40
+ '<i2': Int16Array, '>i2': Int16Array,
41
+ '<i4': Int32Array, '>i4': Int32Array,
42
+ '<f4': Float32Array, '>f4': Float32Array,
43
+ '<f8': Float64Array, '>f8': Float64Array,
44
+ };
45
+
46
+ function _numpyToTyped(obj) {
47
+ const Ctor = _DTYPE_CTORS[obj.type];
48
+ if (!Ctor) throw new Error(`Unsupported numpy dtype: "${obj.type}"`);
49
+ const src = obj.data instanceof Uint8Array ? obj.data : new Uint8Array(obj.data);
50
+ // slice() copies to a new, correctly-aligned ArrayBuffer
51
+ const buf = src.buffer.slice(src.byteOffset, src.byteOffset + src.byteLength);
52
+ return new Ctor(buf);
53
+ }
54
+
55
+ function _decodeNumpy(v) {
56
+ if (v === null || typeof v !== 'object') return v;
57
+ if (v.nd === true && 'type' in v && 'data' in v) return _numpyToTyped(v);
58
+ if (Array.isArray(v)) return v.map(_decodeNumpy);
59
+ const out = {};
60
+ for (const [k, val] of Object.entries(v)) out[k] = _decodeNumpy(val);
61
+ return out;
62
+ }
63
+
64
+ // ─── Low-level fetch & decode ─────────────────────────────────────────────────
65
+
66
+ async function _fetchBytes(url) {
67
+ const res = await fetch(url);
68
+ if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${url}`);
69
+ return new Uint8Array(await res.arrayBuffer());
70
+ }
71
+
72
+ const _textDecoder = new TextDecoder();
73
+
74
+ function _msgpackKeyConverter(key) {
75
+ // msgpack_numpy (Python) may emit binary keys (msgpack bin) for dict keys
76
+ // that are bytes objects. @msgpack/msgpack decodes those as Uint8Array, which
77
+ // then causes a "key must be string or number" error. Convert to string.
78
+ if (key instanceof Uint8Array) return _textDecoder.decode(key);
79
+ return key;
80
+ }
81
+
82
+ function _decodeMsgpack(bytes) {
83
+ return _decodeNumpy(decode(bytes, { mapKeyConverter: _msgpackKeyConverter }));
84
+ }
85
+
86
+ // ─── Bounding-box intersection ────────────────────────────────────────────────
87
+
88
+ /**
89
+ * Test whether a tile's bounds overlaps a query bbox.
90
+ *
91
+ * @param {number[]} tileBounds - always 6 elements [x0,y0,z0, x1,y1,z1]
92
+ * (padded with 0.0 for 2-D data so the layout is always consistent)
93
+ * @param {number[]|null} bbox - 4 or 6 elements [x0,y0,(z0,) x1,y1,(z1)]
94
+ * @param {number} nSpatial - number of spatial axes (2 or 3)
95
+ */
96
+ function _intersects(tileBounds, bbox, nSpatial) {
97
+ if (bbox === null) return true;
98
+ for (let i = 0; i < nSpatial; i++) {
99
+ // bbox layout: [min…, max…] e.g. [xmin, ymin, xmax, ymax] for nSpatial=2
100
+ if (bbox[i + nSpatial] < tileBounds[i]) return false; // bbox_max < tile_min
101
+ if (bbox[i] > tileBounds[i + 3]) return false; // bbox_min > tile_max
102
+ }
103
+ return true;
104
+ }
105
+
106
+ // ─── WebxtileResult ───────────────────────────────────────────────────────────
107
+
108
+ /**
109
+ * Holds the tiles collected for a bbox / level query.
110
+ *
111
+ * The two main entry points for data consumption are:
112
+ * - `toScatter()` — flat parallel arrays per dimension and variable,
113
+ * suitable for point-cloud or scatter-plot style WebGL rendering.
114
+ * - `getCoord(dimName)` — merged sorted coordinate values for one dim.
115
+ * - `tiles` / `meta` — raw access for custom processing.
116
+ */
117
+ export class WebxtileResult {
118
+ /**
119
+ * @param {object} meta - decoded metadata.msgpack
120
+ * @param {object[]} tiles - decoded tile objects
121
+ */
122
+ constructor(meta, tiles) {
123
+ this._meta = meta;
124
+ this._tiles = tiles;
125
+ }
126
+
127
+ /** Full metadata object (version, spatial_dims, crs, dim_sizes, …). */
128
+ get meta() { return this._meta; }
129
+
130
+ /** Array of decoded tile objects as stored in the octree files. */
131
+ get tiles() { return this._tiles; }
132
+
133
+ /**
134
+ * Spatial dimension names in writer order, e.g. `["x", "y"]` or
135
+ * `["x", "y", "z"]`.
136
+ * @type {string[]}
137
+ */
138
+ get spatialDims() { return this._meta.spatial_dims; }
139
+
140
+ /** Horizontal CRS identifier string or null. */
141
+ get crs() { return this._meta.crs ?? null; }
142
+
143
+ /** Vertical CRS identifier string or null. */
144
+ get zCrs() { return this._meta.z_crs ?? null; }
145
+
146
+ /**
147
+ * Per-variable metadata from metadata.msgpack.
148
+ * Each entry: `{ dims: string[], dtype: string, attrs: object }`.
149
+ * @type {Object<string, {dims: string[], dtype: string, attrs: object}>}
150
+ */
151
+ get varMeta() { return this._meta.var_meta ?? {}; }
152
+
153
+ /**
154
+ * Per-coordinate metadata from metadata.msgpack.
155
+ * Each entry: `{ dims: string[], dtype: string, attrs: object, values?: TypedArray }`.
156
+ * @type {Object<string, object>}
157
+ */
158
+ get coordMeta() { return this._meta.coord_meta ?? {}; }
159
+
160
+ /**
161
+ * Returns the merged, sorted, deduplicated coordinate values for one
162
+ * spatial dimension across all loaded tiles.
163
+ *
164
+ * @param {string} dimName
165
+ * @returns {Float64Array}
166
+ */
167
+ getCoord(dimName) {
168
+ const seen = new Set();
169
+ const vals = [];
170
+ for (const tile of this._tiles) {
171
+ const arr = tile.spatial_coords?.[dimName];
172
+ if (!arr) continue;
173
+ for (let i = 0; i < arr.length; i++) {
174
+ const v = arr[i];
175
+ if (!seen.has(v)) { seen.add(v); vals.push(v); }
176
+ }
177
+ }
178
+ vals.sort((a, b) => a - b);
179
+ return new Float64Array(vals);
180
+ }
181
+
182
+ /**
183
+ * Flatten all loaded tiles into parallel scatter arrays.
184
+ *
185
+ * For each tile the 1-D spatial coordinate arrays (`spatial_coords`) are
186
+ * expanded into a full meshgrid and every data variable is read at each
187
+ * resulting grid point. The output arrays are all the same length
188
+ * (`count`).
189
+ *
190
+ * Variables with non-spatial dimensions (e.g. time) are sampled at index 0
191
+ * of every non-spatial axis. For full control over non-spatial dimensions
192
+ * use the raw `tiles` property.
193
+ *
194
+ * @returns {{ coords: Object<string,Float32Array>,
195
+ * variables: Object<string,Float32Array>,
196
+ * count: number }}
197
+ *
198
+ * @example
199
+ * const { coords, variables, count } = result.toScatter();
200
+ * gl.bufferData(gl.ARRAY_BUFFER, coords.x, gl.STATIC_DRAW);
201
+ */
202
+ toScatter() {
203
+ const spatialDims = this._meta.spatial_dims;
204
+ const nD = spatialDims.length;
205
+
206
+ const cBufs = {};
207
+ for (const d of spatialDims) cBufs[d] = [];
208
+ const vBufs = {};
209
+
210
+ for (const tile of this._tiles) {
211
+ const sc = tile.spatial_coords ?? {};
212
+
213
+ // 1-D coordinate arrays for this tile's spatial dims
214
+ const dimArrs = spatialDims.map(d => sc[d] ?? new Float64Array(0));
215
+ const nPerDim = dimArrs.map(a => a.length);
216
+ const nTotal = nPerDim.reduce((a, b) => a * b, 1);
217
+ if (nTotal === 0) continue;
218
+
219
+ // Row-major strides for the spatial meshgrid (dim 0 = outermost / slowest)
220
+ const spStrides = new Array(nD);
221
+ spStrides[nD - 1] = 1;
222
+ for (let d = nD - 2; d >= 0; d--) spStrides[d] = spStrides[d + 1] * nPerDim[d + 1];
223
+
224
+ // Fill coordinate buffers: for each flat spatial index decode per-dim idx
225
+ for (let flat = 0; flat < nTotal; flat++) {
226
+ for (let d = 0; d < nD; d++) {
227
+ cBufs[spatialDims[d]].push(dimArrs[d][Math.floor(flat / spStrides[d]) % nPerDim[d]]);
228
+ }
229
+ }
230
+
231
+ // Fill variable buffers
232
+ for (const [varName, rawArr] of Object.entries(tile.variables ?? {})) {
233
+ if (!(varName in vBufs)) vBufs[varName] = [];
234
+ const vmeta = this._meta.var_meta?.[varName];
235
+ if (!vmeta) continue;
236
+
237
+ const varDims = vmeta.dims;
238
+ // Map each variable dimension to its spatial axis index (or -1 if non-spatial)
239
+ const spAxis = varDims.map(d => spatialDims.indexOf(d));
240
+
241
+ // Size of each variable dimension in this tile
242
+ const varShape = varDims.map((d, vi) => {
243
+ const si = spAxis[vi];
244
+ return si >= 0 ? nPerDim[si] : (this._meta.dim_sizes?.[d] ?? 1);
245
+ });
246
+
247
+ // Row-major strides for rawArr in its own dimension order
248
+ const varStrides = new Array(varDims.length);
249
+ varStrides[varDims.length - 1] = 1;
250
+ for (let d = varDims.length - 2; d >= 0; d--) {
251
+ varStrides[d] = varStrides[d + 1] * varShape[d + 1];
252
+ }
253
+
254
+ for (let flat = 0; flat < nTotal; flat++) {
255
+ // Decode per-spatial-dim indices for this meshgrid point
256
+ const spIdxs = new Array(nD);
257
+ for (let d = 0; d < nD; d++) {
258
+ spIdxs[d] = Math.floor(flat / spStrides[d]) % nPerDim[d];
259
+ }
260
+ // Translate to a linear index into rawArr (non-spatial dims → 0)
261
+ let vi = 0;
262
+ for (let vd = 0; vd < varDims.length; vd++) {
263
+ const si = spAxis[vd];
264
+ vi += (si >= 0 ? spIdxs[si] : 0) * varStrides[vd];
265
+ }
266
+ vBufs[varName].push(rawArr[vi] ?? NaN);
267
+ }
268
+ }
269
+ }
270
+
271
+ const count = Object.values(cBufs)[0]?.length ?? 0;
272
+ return {
273
+ coords: Object.fromEntries(Object.entries(cBufs).map(([k, v]) => [k, new Float32Array(v)])),
274
+ variables: Object.fromEntries(Object.entries(vBufs).map(([k, v]) => [k, new Float32Array(v)])),
275
+ count,
276
+ };
277
+ }
278
+ }
279
+
280
+ // ─── WebxtileLoader ───────────────────────────────────────────────────────────
281
+
282
+ /**
283
+ * Loader for a webxtile octree dataset served over HTTP.
284
+ *
285
+ * Tiles are persisted to IndexedDB after the first network fetch so that
286
+ * repeated loads within a session (or across sessions) avoid redundant
287
+ * requests.
288
+ *
289
+ * @example
290
+ * const loader = new WebxtileLoader("https://host/tiles");
291
+ * await loader.open();
292
+ *
293
+ * // Load leaves inside a 2-D bbox (full resolution)
294
+ * const r = await loader.loadBBox([500000, 6200000, 520000, 6220000]);
295
+ *
296
+ * // Coarse overview (level 2)
297
+ * const lo = await loader.loadBBox(null, { level: 2 });
298
+ */
299
+ export class WebxtileLoader {
300
+ /**
301
+ * @param {string} baseUrl - Base URL of the tile directory (trailing
302
+ * slash optional).
303
+ * @param {object} [options]
304
+ * @param {string} [options.dbName="webxtile-cache"] - IndexedDB database
305
+ * name. Use a unique name per dataset if you serve multiple datasets from
306
+ * the same origin.
307
+ */
308
+ constructor(baseUrl, { dbName = 'webxtile-cache' } = {}) {
309
+ this._base = baseUrl.replace(/\/$/, '');
310
+ this._dbName = dbName;
311
+ this._meta = null; // set by open()
312
+ this._db = null; // IDBDatabase, set by open()
313
+ this._memCache = new Map(); // filename → decoded tile (session-level)
314
+ }
315
+
316
+ // ── Initialisation ──────────────────────────────────────────────────────────
317
+
318
+ /**
319
+ * Load `metadata.msgpack` and open the IndexedDB tile cache.
320
+ * Must be awaited before calling `loadBBox`.
321
+ *
322
+ * @returns {Promise<object>} Decoded metadata object.
323
+ */
324
+ async open() {
325
+ const [meta, db] = await Promise.all([
326
+ this._fetchAndDecode('metadata.msgpack'),
327
+ openDB(this._dbName, 1, {
328
+ upgrade(db) {
329
+ db.createObjectStore('tiles');
330
+ },
331
+ }),
332
+ ]);
333
+ this._meta = meta;
334
+ this._db = db;
335
+ return meta;
336
+ }
337
+
338
+ /**
339
+ * Metadata loaded from `metadata.msgpack`.
340
+ * `null` until `open()` resolves.
341
+ * @type {object|null}
342
+ */
343
+ get meta() { return this._meta; }
344
+
345
+ // ── Tile fetch and cache ────────────────────────────────────────────────────
346
+
347
+ async _fetchAndDecode(filename) {
348
+ const bytes = await _fetchBytes(`${this._base}/${filename}`);
349
+ return _decodeMsgpack(bytes);
350
+ }
351
+
352
+ async _loadTile(filename) {
353
+ // 1. In-memory session cache
354
+ if (this._memCache.has(filename)) return this._memCache.get(filename);
355
+
356
+ // 2. IndexedDB persistent cache (raw bytes stored → decode on retrieval)
357
+ if (this._db) {
358
+ const cached = await this._db.get('tiles', filename);
359
+ if (cached instanceof Uint8Array) {
360
+ const tile = _decodeMsgpack(cached);
361
+ this._memCache.set(filename, tile);
362
+ return tile;
363
+ }
364
+ }
365
+
366
+ // 3. Network fetch
367
+ const bytes = await _fetchBytes(`${this._base}/${filename}`);
368
+
369
+ // Persist raw bytes for future use; ignore quota errors silently
370
+ if (this._db) {
371
+ this._db.put('tiles', bytes, filename).catch(() => {});
372
+ }
373
+
374
+ const tile = _decodeMsgpack(bytes);
375
+ this._memCache.set(filename, tile);
376
+ return tile;
377
+ }
378
+
379
+ // ── Octree traversal ────────────────────────────────────────────────────────
380
+
381
+ /**
382
+ * Recursively collect all tiles that satisfy the bbox and level constraints,
383
+ * mirroring the Python `_collect_tiles` logic.
384
+ *
385
+ * @param {string} filename - tile filename relative to base URL
386
+ * @param {number[]|null} bbox - query bbox (null = no spatial filter)
387
+ * @param {number|null} level - max depth (null = leaves)
388
+ * @param {number} nSpatial - 2 or 3
389
+ * @returns {Promise<object[]>}
390
+ */
391
+ async _collectTiles(filename, bbox, level, nSpatial) {
392
+ const tile = await this._loadTile(filename);
393
+
394
+ // Prune branches that don't intersect the query bbox
395
+ if (!_intersects(tile.bounds, bbox, nSpatial)) return [];
396
+
397
+ const isLeaf = tile.is_leaf ?? (tile.children == null);
398
+ const tileLevel = tile.level ?? 0;
399
+
400
+ // Return this tile if it is a leaf or we have hit the requested depth
401
+ if (isLeaf || (level !== null && tileLevel >= level)) return [tile];
402
+
403
+ // Recurse into children in parallel for throughput
404
+ const children = tile.children ?? [];
405
+ const childGroups = await Promise.all(
406
+ children.map(child => this._collectTiles(child, bbox, level, nSpatial))
407
+ );
408
+ const collected = childGroups.flat();
409
+
410
+ // If the bbox filtered out all children, fall back to this (coarser) tile
411
+ // so the caller always receives at least a low-res result for the region
412
+ return collected.length > 0 ? collected : [tile];
413
+ }
414
+
415
+ // ── Public API ──────────────────────────────────────────────────────────────
416
+
417
+ /**
418
+ * Load all tiles intersecting `bbox` down to the requested `level`.
419
+ *
420
+ * @param {number[]|null} [bbox=null]
421
+ * Spatial bounding box in the same coordinate system as the dataset.
422
+ * - 2-D: `[x_min, y_min, x_max, y_max]`
423
+ * - 3-D: `[x_min, y_min, z_min, x_max, y_max, z_max]`
424
+ * Pass `null` to load the entire dataset (no spatial filter).
425
+ *
426
+ * @param {object} [options={}]
427
+ * @param {number|null} [options.level=null]
428
+ * Maximum octree depth to descend.
429
+ * - `null` (default): load all leaf tiles (full resolution).
430
+ * - `0`: load only the root tile (coarsest overview).
431
+ * - `N`: load tiles at depth N; uses the deepest available leaf for
432
+ * branches that terminate before depth N.
433
+ *
434
+ * @returns {Promise<GridResult>}
435
+ */
436
+ async loadBBox(bbox = null, { level = null } = {}) {
437
+ if (!this._meta) throw new Error('Call open() before loadBBox()');
438
+
439
+ const nSpatial = this._meta.spatial_dims.length;
440
+ const rootFile = this._meta.root_tile ?? 'root.msgpack';
441
+ const tiles = await this._collectTiles(rootFile, bbox, level, nSpatial);
442
+ return new WebxtileResult(this._meta, tiles);
443
+ }
444
+
445
+ /**
446
+ * Clear all cached tiles from both the in-memory cache and IndexedDB.
447
+ * Useful when the server-side data has been regenerated.
448
+ *
449
+ * @returns {Promise<void>}
450
+ */
451
+ async clearCache() {
452
+ this._memCache.clear();
453
+ if (this._db) {
454
+ const tx = this._db.transaction('tiles', 'readwrite');
455
+ const store = tx.objectStore('tiles');
456
+ await store.clear();
457
+ await tx.done;
458
+ }
459
+ }
460
+ }