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.
- package/README.md +194 -0
- package/package.json +30 -0
- 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
|
+
}
|