ol-stac 1.5.0 → 1.6.0

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.
@@ -0,0 +1,1990 @@
1
+ /**
2
+ * @module ol/source/GeoZarr
3
+ */
4
+ import { FetchStore, get, open, slice, withRangeCoalescing } from 'zarrita';
5
+ import { warn } from 'ol/console.js';
6
+ import { getCenter } from 'ol/extent.js';
7
+ import { get as getProjection, toUserCoordinate, toUserExtent } from 'ol/proj.js';
8
+ import { fromProjectionDefinition } from '../shim/proj4.js';
9
+ import { toSize } from 'ol/size.js';
10
+ import WMTSTileGrid from 'ol/tilegrid/WMTS.js';
11
+ import { getUid } from 'ol/util.js';
12
+ import DataTileSource from 'ol/source/DataTile.js';
13
+ import { parseTileMatrixSet } from '../shim/ogcTileUtil.js';
14
+ const REQUIRED_ZARR_CONVENTIONS = [
15
+ 'd35379db-88df-4056-af3a-620245f8e347',
16
+ 'f17cb550-5864-4468-aeb7-f3180cfb622f',
17
+ '689b58e2-cf7b-45e0-9fff-9cfc0883d6b4', // spatial:
18
+ ];
19
+ /**
20
+ * @typedef {'nearest'|'linear'} ResampleMethod
21
+ */
22
+ /**
23
+ * @typedef {Object} Band
24
+ * @property {string} name The band name.
25
+ * @property {string} group The group path relative to the `url`, containing this band
26
+ * (e.g. `'measurements/reflectance'`).
27
+ */
28
+ /**
29
+ * @typedef {Object} GeoZarrStoreOptions
30
+ * @property {Object<string, string>} [headers] additional key-value pairs of headers to be passed with each request. Key is the header name, value the header value.
31
+ * @property {string} [credentials] How credentials shall be handled. See
32
+ * https://developer.mozilla.org/en-US/docs/Web/API/fetch for reference and possible values
33
+ */
34
+ /**
35
+ * @typedef {Object} Options
36
+ * @property {string} url When `bands` contains plain strings, this must be the full URL to the
37
+ * multiscales group (e.g. `'https://example.com/store.zarr/measurements/reflectance'`).
38
+ * When `bands` contains {@link Band} objects, this is the base URL from which each band's
39
+ * `group` path is resolved (e.g. `'https://example.com/store.zarr/satellite/sentinel2'`).
40
+ * @property {Array<string|Band>} [bands] The bands to render, for stores where each
41
+ * band is a separate array. Mutually exclusive with `variable`. Each entry is either a band name
42
+ * string (single-group mode) or a {@link Band} object specifying both the band name and the
43
+ * group it belongs to (multi-group mode). In multi-group mode, the first band's group
44
+ * determines the tile grid and must follow at least the proj: and spatial: conventions.
45
+ * If it also has a multiscales layout (all three conventions), multiple resolution levels are
46
+ * supported. Otherwise a single-resolution tile grid is derived from `spatial:bbox`,
47
+ * `proj:code`, and `spatial:shape` (or the array shape from consolidated metadata).
48
+ * Bands from additional groups do not need to follow any convention; they can be multi-scale
49
+ * (array located at `<matrixId>/<bandName>`) or single-scale (array at the group root).
50
+ * @property {GeoZarrStoreOptions} [storeOptions] Additional options to be passed to
51
+ * [zarrita](https://zarrita.dev/)'s `FetchStore` with each request to the Zarr store.
52
+ * @property {import("ol/proj.js").ProjectionLike} [projection] Source projection.
53
+ * If not provided, the GeoZarr metadata will be read for projection information.
54
+ * @property {number} [transition=250] Duration of the opacity transition for rendering.
55
+ * To disable the opacity transition, pass `transition: 0`.
56
+ * @property {boolean} [wrapX=false] Render tiles beyond the tile grid extent.
57
+ * @property {ResampleMethod} [resample='linear'] Resampling method if bands are not available for all multi-scale levels.
58
+ * @property {Object<string, number|string>} [dimensions] Fixed index for each non-spatial
59
+ * dimension of the band arrays, keyed by dimension name (e.g. `{time: 0}` for the first time step
60
+ * of a `[time, y, x]` cube); unspecified dimensions default to `0`. Names come from each array's
61
+ * `dimension_names`, or are the axis position as a string when it has none. Only integer indices
62
+ * are supported. Use the names from {@link getDimensions}, and change the selection on the fly with
63
+ * {@link module:ol/source/GeoZarr~GeoZarr#updateDimensions}.
64
+ * @property {string} [variable] The name of an n-dimensional data array (variable) to
65
+ * render, for stores where all bands are packed into a single array (e.g. a
66
+ * `(time, band, y, x)` datacube). The array must exist within each multiscale level
67
+ * group (or at the group root for single-scale stores). Mutually exclusive with `bands`.
68
+ * @property {Object<string, number|string|Array<number|string>>} [selector] For
69
+ * `variable` mode: how to slice each non-spatial dimension, keyed by dimension name
70
+ * (from the array's `dimension_names`). Values are 0-based indices (number), coordinate
71
+ * labels (string), or an array of these. At most one dimension may map to an array; its
72
+ * entries are rendered as separate bands (in the given order). Unlisted non-spatial
73
+ * dimensions default to index 0. Labels are resolved against the dimension's coordinate
74
+ * array; if that array cannot be read, pass indices instead.
75
+ * @property {import("ol/extent.js").Extent} [extent] Fallback extent of the data, in
76
+ * coordinates of the source projection. Only used when the store neither declares its
77
+ * extent (`spatial:bbox` or `bounds` attributes) nor has coordinate arrays to infer it.
78
+ * @property {boolean} [flipY] Fallback orientation: set to `true` when the data is
79
+ * stored south-up (ascending y). Only used when the orientation can neither be read
80
+ * from the store metadata nor inferred from its coordinate arrays.
81
+ */
82
+ /**
83
+ * Source for GeoZarr stores conforming to the following conventions:
84
+ * - [Zarr multiscales convention](https://github.com/zarr-conventions/multiscales)
85
+ * - [Geospatial projection convention](https://github.com/zarr-conventions/geo-proj)
86
+ * - [Spatial convention](https://github.com/zarr-conventions/spatial)
87
+ *
88
+ * When all three conventions are present, multiple resolution levels are supported.
89
+ * When only proj: and spatial: are present, a single-resolution tile grid is derived
90
+ * from `spatial:bbox`, `proj:code`, and `spatial:shape`.
91
+ * The legacy `tile_matrix_set` attribute is also supported, as is the GeoZarr-style
92
+ * `multiscales` array attribute (`[{tile_matrix_set, datasets: [{path, ...}]}]`).
93
+ *
94
+ * Two data layouts are supported:
95
+ * - One array per band (`bands` option), addressed by name at `<matrixId>/<bandName>`
96
+ * (multi-scale) or at the group root (single-scale). Supports Zarr v3.
97
+ * - A single n-dimensional data array shared by all bands (`variable` + `selector`
98
+ * options), e.g. a `(time, band, y, x)` datacube. Supports Zarr v2 and v3; the
99
+ * extent, resolution, projection and y-axis orientation are read from the store
100
+ * metadata or inferred from the coordinate arrays.
101
+ */
102
+ export default class GeoZarr extends DataTileSource {
103
+ /**
104
+ * @param {Options} options The options.
105
+ */
106
+ constructor(options) {
107
+ super({
108
+ state: 'loading',
109
+ tileGrid: /** @type {?} */ (null),
110
+ projection: /** @type {import("ol/proj.js").ProjectionLike} */ (options.projection || null),
111
+ transition: options.transition,
112
+ wrapX: options.wrapX,
113
+ hasAlpha: false,
114
+ });
115
+ /**
116
+ * @type {string}
117
+ * @private
118
+ */
119
+ this.url_ = options.url;
120
+ /**
121
+ * @type {GeoZarrStoreOptions|undefined}
122
+ * @private
123
+ */
124
+ this.storeOptions_ = options.storeOptions;
125
+ /**
126
+ * Fixed index per non-spatial dimension name, from the `dimensions` option.
127
+ * @type {Object<string, number|string>}
128
+ * @private
129
+ */
130
+ this.dimensions_ = options.dimensions || {};
131
+ /**
132
+ * @type {string|undefined}
133
+ * @private
134
+ */
135
+ this.variable_ = options.variable;
136
+ /**
137
+ * @type {Object<string, number|string|Array<number|string>>}
138
+ * @private
139
+ */
140
+ this.selector_ = options.selector || {};
141
+ /**
142
+ * @type {import("ol/extent.js").Extent|undefined}
143
+ * @private
144
+ */
145
+ this.fallbackExtent_ = options.extent;
146
+ /**
147
+ * @type {boolean|undefined}
148
+ * @private
149
+ */
150
+ this.fallbackFlipY_ = options.flipY;
151
+ /**
152
+ * The zarrita open function, pinned to v2 when the store cannot be
153
+ * probed for v3 metadata (e.g. servers answering 403 for missing keys).
154
+ * @type {Function}
155
+ * @private
156
+ */
157
+ this.openFn_ = open;
158
+ /**
159
+ * Group path prefix per tile matrix id, for stores whose levels are not
160
+ * addressed by the matrix id itself (empty string for the group root).
161
+ * `null` when the matrix id is the prefix.
162
+ * @type {Object<string, string>|null}
163
+ * @private
164
+ */
165
+ this.levelPaths_ = null;
166
+ /**
167
+ * Row axis information per tile matrix id, for data with non-square
168
+ * pixels or south-up (flipped) rows.
169
+ * @type {Object<string, {rowResolution: number, shapeY: number|undefined, flip: boolean}>|null}
170
+ * @private
171
+ */
172
+ this.levelRowInfo_ = null;
173
+ /**
174
+ * The axis selected as multiple bands through the `selector` option,
175
+ * or -1.
176
+ * @type {number}
177
+ * @private
178
+ */
179
+ this.multiAxis_ = -1;
180
+ /**
181
+ * @type {Error|null}
182
+ */
183
+ this.error_ = null;
184
+ /**
185
+ * @type {Array<import('zarrita').Group<any>>}
186
+ * @private
187
+ */
188
+ this.groups_ = [];
189
+ /**
190
+ * @type {Object<string, *>|null}
191
+ * @private
192
+ */
193
+ this.consolidatedMetadata_ = null;
194
+ /**
195
+ * Cache of opened zarrita arrays keyed by path. Caching the Promise
196
+ * (not the resolved value) deduplicates concurrent opens for the same
197
+ * array path across tiles at the same zoom level.
198
+ * @private
199
+ * @type {Map<string, Promise<import('zarrita').Array<import('zarrita').DataType, any>>>}
200
+ */
201
+ this.arrayCache_ = new Map();
202
+ const groupOrder = /** @type {Array<string>} */ ([]);
203
+ const bandGroupIndex = /** @type {Array<number>} */ ([]);
204
+ const bands = (options.bands || []).map((b) => {
205
+ if (typeof b === 'string') {
206
+ bandGroupIndex.push(0);
207
+ return b;
208
+ }
209
+ let gi = groupOrder.indexOf(b.group);
210
+ if (gi === -1) {
211
+ gi = groupOrder.length;
212
+ groupOrder.push(b.group);
213
+ }
214
+ bandGroupIndex.push(gi);
215
+ return b.name;
216
+ });
217
+ /**
218
+ * @type {Array<string>|undefined}
219
+ * @private
220
+ */
221
+ this.groupPaths_ = groupOrder.length > 0 ? groupOrder : undefined;
222
+ /**
223
+ * Maps each band index to the index of the group it belongs to in `this.groups_`.
224
+ * @type {Array<number>}
225
+ * @private
226
+ */
227
+ this.bandGroupIndex_ = bandGroupIndex;
228
+ /**
229
+ * Pixel resolution for single-scale bands. When set, indicates that the
230
+ * band lives directly at its group root (no matrixId subdirectory) and
231
+ * provides the pixel resolution to use for coordinate calculations.
232
+ * Undefined for multi-scale bands.
233
+ * @type {Array<number|undefined>}
234
+ * @private
235
+ */
236
+ this.bandSingleScaleResolution_ = new Array(bands.length).fill(undefined);
237
+ /**
238
+ * @type {Array<string>}
239
+ * @private
240
+ */
241
+ this.bands_ = bands;
242
+ /**
243
+ * Per-band selection along non-spatial dimensions: `undefined` for 2-D
244
+ * arrays, otherwise an array aligned to the array rank with a fixed integer
245
+ * at each extra axis and `null` at the two spatial axes (e.g. `[2, null,
246
+ * null]` for a `[time, y, x]` array with `time: 2`).
247
+ * @type {Array<Array<number|null>|undefined>}
248
+ * @private
249
+ */
250
+ this.bandExtraSelection_ = new Array(bands.length).fill(undefined);
251
+ /**
252
+ * Per-band spatial (y, x) axis positions, as `{row, col}`.
253
+ * @type {Array<{row: number, col: number}>}
254
+ * @private
255
+ */
256
+ this.bandSpatialAxes_ = new Array(bands.length);
257
+ /**
258
+ * The two spatial axis names from the group's `spatial:dimensions` (`[y, x]`).
259
+ * @type {Array<string>|undefined}
260
+ * @private
261
+ */
262
+ this.spatialDimensionNames_;
263
+ /**
264
+ * Non-spatial dimensions of the bands, exposed via {@link getDimensions}.
265
+ * @type {Array<{name: string, size: number}>}
266
+ * @private
267
+ */
268
+ this.extraDimensions_ = [];
269
+ /**
270
+ * @type {Object<string, Array<string>>|null|undefined}
271
+ * @private
272
+ */
273
+ this.bandsByLevel_ = null;
274
+ /**
275
+ * @type {number|undefined}
276
+ * @private
277
+ */
278
+ this.fillValue_;
279
+ /**
280
+ * @type {ResampleMethod}
281
+ * @private
282
+ */
283
+ this.resampleMethod_ = options.resample || 'linear';
284
+ /**
285
+ * Number of bands.
286
+ * @type {number}
287
+ */
288
+ this.bandCount = this.bands_.length;
289
+ if (this.variable_) {
290
+ let bandCount = 1;
291
+ for (const key in this.selector_) {
292
+ const value = this.selector_[key];
293
+ if (Array.isArray(value)) {
294
+ if (bandCount > 1) {
295
+ throw new Error('Only one selector dimension may select multiple values');
296
+ }
297
+ bandCount = value.length;
298
+ }
299
+ }
300
+ this.bandCount = bandCount;
301
+ }
302
+ /**
303
+ * @type {import("ol/tilegrid/WMTS.js").default}
304
+ * @override
305
+ */
306
+ this.tileGrid;
307
+ this.setLoader(this.loadTile_.bind(this));
308
+ this.configure_()
309
+ .then(() => {
310
+ this.setState('ready');
311
+ })
312
+ .catch((err) => {
313
+ this.error_ = err;
314
+ this.setState('error');
315
+ });
316
+ }
317
+ async configure_() {
318
+ var _a;
319
+ const overrides = /** @type {RequestInit} */ (this.storeOptions_) || {};
320
+ const store = /** @type {FetchStore} */ (withRangeCoalescing(new FetchStore(this.url_, { overrides })));
321
+ // Fetch group zarr.json once for both opening the group and extracting
322
+ // consolidated metadata. Without this, open() and the manual metadata
323
+ // read would each make a separate HTTP request for the same file.
324
+ const groupBytes = await probe(store, '/zarr.json');
325
+ if (groupBytes) {
326
+ try {
327
+ this.consolidatedMetadata_ = JSON.parse(new TextDecoder().decode(groupBytes)).consolidated_metadata.metadata;
328
+ }
329
+ catch (_b) {
330
+ // no consolidated metadata
331
+ }
332
+ }
333
+ /** @type {Object<string, *>|null} */
334
+ let v2Metadata = null;
335
+ if (!this.consolidatedMetadata_) {
336
+ // Zarr v2: consolidated metadata lives in .zmetadata
337
+ const v2Bytes = await probe(store, '/.zmetadata');
338
+ if (v2Bytes) {
339
+ try {
340
+ v2Metadata = JSON.parse(new TextDecoder().decode(v2Bytes)).metadata;
341
+ this.consolidatedMetadata_ = normalizeV2Metadata(
342
+ /** @type {Object<string, *>} */ (v2Metadata));
343
+ }
344
+ catch (_c) {
345
+ // no consolidated metadata
346
+ }
347
+ }
348
+ }
349
+ // Wrap the store so that child metadata (groups, arrays) is served from
350
+ // the consolidated metadata instead of making per-child HTTP requests.
351
+ const cachedStore = v2Metadata
352
+ ? createCachedStoreV2(store, v2Metadata)
353
+ : this.consolidatedMetadata_
354
+ ? createCachedStore(store,
355
+ /** @type {Uint8Array} */ (groupBytes), this.consolidatedMetadata_)
356
+ : withChunkCache(store);
357
+ const groupPromises = [];
358
+ if (this.groupPaths_) {
359
+ // Multi-group mode: open root, then each sub-group
360
+ const rootGroup = await open(cachedStore, { kind: 'group' });
361
+ for (const groupPath of this.groupPaths_) {
362
+ groupPromises.push(open(rootGroup.resolve(groupPath), { kind: 'group' }));
363
+ }
364
+ }
365
+ else {
366
+ // Single group mode
367
+ groupPromises.push(this.openGroup_(cachedStore));
368
+ }
369
+ this.groups_.push(...(await Promise.all(groupPromises)));
370
+ const attributes =
371
+ /** @type {LegacyDatasetAttributes | DatasetAttributes} */ (this.groups_[0].attrs);
372
+ // The spatial: convention names the two spatial axes (`[y, x]`), so they can
373
+ // be located by name in each array's `dimension_names`.
374
+ const spatialDimensions = 'spatial:dimensions' in attributes
375
+ ? /** @type {Array<string>} */ (attributes['spatial:dimensions'])
376
+ : undefined;
377
+ if (Array.isArray(spatialDimensions) && spatialDimensions.length === 2) {
378
+ this.spatialDimensionNames_ = spatialDimensions;
379
+ }
380
+ // For multi-group mode, use sub-metadata for the first group so that
381
+ // consolidated metadata keys match the expected relative paths.
382
+ const consolidatedMetadata = this.groupPaths_ && this.consolidatedMetadata_
383
+ ? getSubMetadata(this.consolidatedMetadata_, this.groupPaths_[0])
384
+ : this.consolidatedMetadata_;
385
+ if (this.variable_) {
386
+ await this.configureDatacube_(attributes, store);
387
+ }
388
+ let hasTileSizes = false;
389
+ if (!this.tileGrid &&
390
+ 'zarr_conventions' in attributes &&
391
+ Array.isArray(attributes.zarr_conventions) &&
392
+ REQUIRED_ZARR_CONVENTIONS.every((uuid) => attributes.zarr_conventions.find((c) => c.uuid === uuid)) &&
393
+ 'layout' in attributes.multiscales) {
394
+ const { tileGrid, projection, bandsByLevel, fillValue, tileSizes } = getTileGridInfoFromAttributes(
395
+ /** @type {DatasetAttributes} */ (attributes), consolidatedMetadata, this.bands_);
396
+ this.bandsByLevel_ = bandsByLevel;
397
+ this.tileGrid = tileGrid;
398
+ this.projection = projection;
399
+ this.fillValue_ = fillValue;
400
+ hasTileSizes = !!tileSizes;
401
+ }
402
+ if (!hasTileSizes &&
403
+ attributes.multiscales &&
404
+ 'tile_matrix_set' in attributes.multiscales) {
405
+ // If available, use tile_matrix_set (legacy attributes) to get a tile grid, because it
406
+ // should provide a better mapping of tiles to zarr chunks.
407
+ const { tileGrid, projection } = getTileGridInfoFromLegacyAttributes(
408
+ /** @type {LegacyDatasetAttributes} */ (attributes));
409
+ this.tileGrid = tileGrid;
410
+ if (!this.projection) {
411
+ // If there were no required zarr conventions, we don't have a projection yet
412
+ this.projection = projection;
413
+ }
414
+ }
415
+ if (!this.tileGrid && 'spatial:bbox' in attributes) {
416
+ // Standalone single-scale group: build tile grid directly from
417
+ // spatial:bbox and spatial:shape (`[height, width]`), or the x axis size
418
+ // from the array metadata.
419
+ let xSize = (_a = attributes['spatial:shape']) === null || _a === void 0 ? void 0 : _a[1];
420
+ if (xSize === undefined && consolidatedMetadata) {
421
+ for (const band of this.bands_) {
422
+ const bandMeta = consolidatedMetadata[band];
423
+ if (bandMeta === null || bandMeta === void 0 ? void 0 : bandMeta.shape) {
424
+ xSize = bandMeta.shape[this.axesOf_(bandMeta).col];
425
+ break;
426
+ }
427
+ }
428
+ }
429
+ if (xSize !== undefined) {
430
+ const extent = attributes['spatial:bbox'];
431
+ const resolution = (extent[2] - extent[0]) / xSize;
432
+ if (!this.projection) {
433
+ this.projection = getProjectionFromAttributes(attributes);
434
+ }
435
+ if (consolidatedMetadata) {
436
+ this.bandsByLevel_ = { level0: [] };
437
+ for (const band of this.bands_) {
438
+ if (consolidatedMetadata[band]) {
439
+ this.bandsByLevel_['level0'].push(band);
440
+ if (this.fillValue_ === undefined) {
441
+ this.fillValue_ = Number(consolidatedMetadata[band]['fill_value']);
442
+ }
443
+ }
444
+ }
445
+ }
446
+ this.tileGrid = new WMTSTileGrid({
447
+ extent: extent,
448
+ origins: [[extent[0], extent[3]]],
449
+ resolutions: [resolution],
450
+ matrixIds: ['level0'],
451
+ });
452
+ for (let i = 0; i < this.bands_.length; ++i) {
453
+ if (this.bandGroupIndex_[i] === 0) {
454
+ this.bandSingleScaleResolution_[i] = resolution;
455
+ }
456
+ }
457
+ }
458
+ }
459
+ // For multi-group: determine which group owns each band and supplement
460
+ // bandsByLevel with bands from additional groups.
461
+ if (this.groupPaths_ && this.consolidatedMetadata_ && this.bandsByLevel_) {
462
+ this.resolveBandOwnership_();
463
+ }
464
+ if (this.fillValue_ !== null && this.fillValue_ !== undefined) {
465
+ this.bandCount += 1;
466
+ this.nodataBandIndex = this.bandCount;
467
+ this.hasAlpha = true;
468
+ }
469
+ if (!this.tileGrid) {
470
+ throw new Error('Could not determine tile grid');
471
+ }
472
+ // Resolve, per band, the spatial axes and the fixed indices for any
473
+ // non-spatial dimensions, and record the selectable dimensions.
474
+ // (In `variable` mode, configureDatacube_ resolves these instead.)
475
+ for (let i = 0, ii = this.variable_ ? 0 : this.bands_.length; i < ii; ++i) {
476
+ const arrayMeta = this.getBandArrayMeta_(this.bands_[i], this.bandGroupIndex_[i]);
477
+ const { row, col } = this.axesOf_(arrayMeta);
478
+ this.bandSpatialAxes_[i] = { row, col };
479
+ this.bandExtraSelection_[i] = this.resolveExtraSelection_(arrayMeta, this.dimensions_);
480
+ if (this.extraDimensions_.length === 0) {
481
+ this.extraDimensions_ = this.extraDimsOf_(arrayMeta);
482
+ }
483
+ }
484
+ const extent = /** @type {import("ol/extent.js").Extent} */ (this.tileGrid.getExtent());
485
+ const projection = /** @type {import("ol/proj/Projection.js").default} */ (this.projection);
486
+ setTimeout(() => {
487
+ var _a;
488
+ (_a = this.viewResolver) === null || _a === void 0 ? void 0 : _a.call(this, {
489
+ showFullExtent: true,
490
+ projection: projection,
491
+ resolutions: this.tileGrid.getResolutions(),
492
+ center: toUserCoordinate(getCenter(extent), projection),
493
+ extent: toUserExtent(extent, projection),
494
+ zoom: 1,
495
+ });
496
+ });
497
+ }
498
+ /**
499
+ * @param {number} z The z tile index.
500
+ * @param {number} x The x tile index.
501
+ * @param {number} y The y tile index.
502
+ * @param {import('ol/source/DataTile.js').LoaderOptions} options The loader options.
503
+ * @return {Promise<import("ol/DataTile.js").Data>} The composed tile data.
504
+ * @private
505
+ */
506
+ async loadTile_(z, x, y, options) {
507
+ var _a;
508
+ const resolutions = this.tileGrid.getResolutions();
509
+ const tileResolution = this.tileGrid.getResolution(z);
510
+ const tileExtent = this.tileGrid.getTileCoordExtent([z, x, y]);
511
+ // First pass: resolve band metadata (no async)
512
+ /** @type {Array<{path: string, groupIndex: number, minRow: number, maxRow: number, minCol: number, maxCol: number, bandResolution: number, rowResolution: number, flip: boolean}>} */
513
+ const bandInfos = [];
514
+ for (let i = 0, ii = this.bands_.length; i < ii; ++i) {
515
+ const band = this.bands_[i];
516
+ const groupIndex = this.bandGroupIndex_[i];
517
+ let bandMatrixId;
518
+ let bandResolution;
519
+ let bandZ = 0;
520
+ if (!this.bandsByLevel_) {
521
+ // TODO: remove this if we stop supporting legacy attributes
522
+ bandMatrixId = this.tileGrid.getMatrixId(z);
523
+ bandResolution = tileResolution;
524
+ bandZ = z;
525
+ }
526
+ else {
527
+ for (let candidateZ = 0; candidateZ < resolutions.length; candidateZ += 1) {
528
+ const candidateResolution = resolutions[candidateZ];
529
+ if (bandMatrixId && candidateResolution < tileResolution) {
530
+ break;
531
+ }
532
+ const candidateMatrixId = this.tileGrid.getMatrixId(candidateZ);
533
+ if (this.bandsByLevel_[candidateMatrixId].includes(band)) {
534
+ bandMatrixId = candidateMatrixId;
535
+ bandResolution = this.tileGrid.getResolution(candidateZ);
536
+ bandZ = candidateZ;
537
+ }
538
+ }
539
+ }
540
+ if (!bandMatrixId || bandResolution === undefined) {
541
+ throw new Error(`Could not find available resolution for band ${band}`);
542
+ }
543
+ const resolvedBandResolution = bandResolution;
544
+ const isSingleScale = this.bandSingleScaleResolution_[i] !== undefined;
545
+ // For single-scale bands, use the band's own pixel resolution (derived
546
+ // from array shape or spatial metadata) rather than the tile grid level
547
+ // resolution, which may give wrong pixel coordinates.
548
+ if (isSingleScale) {
549
+ bandResolution = this.bandSingleScaleResolution_[i];
550
+ }
551
+ const effectiveResolution = /** @type {number} */ (bandResolution !== null && bandResolution !== void 0 ? bandResolution : resolvedBandResolution);
552
+ // Pixels are not necessarily square (e.g. a square array covering the
553
+ // full EPSG:4326 world), and rows may be stored south-up.
554
+ const rowInfo = (_a = this.levelRowInfo_) === null || _a === void 0 ? void 0 : _a[bandMatrixId];
555
+ const rowResolution = !isSingleScale && rowInfo ? rowInfo.rowResolution : effectiveResolution;
556
+ const flip = !!(rowInfo === null || rowInfo === void 0 ? void 0 : rowInfo.flip) && rowInfo.shapeY !== undefined;
557
+ // The matrix id addresses the level group, unless the level has its
558
+ // own group path (empty for the group root).
559
+ const levelPath = this.levelPaths_
560
+ ? this.levelPaths_[bandMatrixId]
561
+ : bandMatrixId;
562
+ const origin = this.tileGrid.getOrigin(bandZ);
563
+ const minCol = Math.round((tileExtent[0] - origin[0]) / effectiveResolution);
564
+ const maxCol = Math.round((tileExtent[2] - origin[0]) / effectiveResolution);
565
+ let minRow = Math.round((origin[1] - tileExtent[3]) / rowResolution);
566
+ let maxRow = Math.round((origin[1] - tileExtent[1]) / rowResolution);
567
+ if (flip) {
568
+ // South-up: read the vertically mirrored row range and flip the
569
+ // rows after reading.
570
+ const shapeY = /** @type {number} */ (rowInfo.shapeY);
571
+ [minRow, maxRow] = [
572
+ Math.max(0, shapeY - maxRow),
573
+ Math.max(0, shapeY - minRow),
574
+ ];
575
+ }
576
+ bandInfos.push({
577
+ path: isSingleScale || !levelPath ? band : `${levelPath}/${band}`,
578
+ groupIndex,
579
+ minRow,
580
+ maxRow,
581
+ minCol,
582
+ maxCol,
583
+ bandResolution: effectiveResolution,
584
+ rowResolution,
585
+ flip,
586
+ });
587
+ }
588
+ // Open all band arrays in parallel (not sequentially)
589
+ const arrays = await Promise.all(bandInfos.map((info) => this.openArray_(info.groupIndex, info.path)));
590
+ // Fire all get() calls synchronously so getRange() calls from all bands
591
+ // land in the same macrotask tick and can be batched together.
592
+ const bandResolutions = bandInfos.map((info) => info.bandResolution);
593
+ const bandRowResolutions = bandInfos.map((info) => info.rowResolution);
594
+ let bandChunks = await Promise.all(arrays.map((array, i) => {
595
+ const info = bandInfos[i];
596
+ const rowSlice = slice(info.minRow, info.maxRow);
597
+ const colSlice = slice(info.minCol, info.maxCol);
598
+ const extra = this.bandExtraSelection_[i];
599
+ if (!extra) {
600
+ return get(array, [rowSlice, colSlice]);
601
+ }
602
+ // Drop the row/column slices in at the spatial axes; zarrita drops the
603
+ // integer axes, returning a 2-D chunk that composeData consumes unchanged.
604
+ const { row, col } = this.bandSpatialAxes_[i];
605
+ /** @type {Array<number|null|ReturnType<typeof slice>>} */
606
+ const selection = extra.slice();
607
+ selection[row] = rowSlice;
608
+ selection[col] = colSlice;
609
+ return get(array, selection);
610
+ }));
611
+ bandChunks = bandChunks.map((chunk, i) => bandInfos[i].flip ? flipChunkRows(chunk) : chunk);
612
+ const [tileColCount, tileRowCount] = toSize(this.tileGrid.getTileSize(z));
613
+ return composeData(bandChunks, bandResolutions, tileColCount, tileRowCount, tileResolution, this.resampleMethod_, this.fillValue_, bandRowResolutions);
614
+ }
615
+ /**
616
+ * For multi-group mode: determine which group owns each band and supplement
617
+ * bandsByLevel with bands from additional groups.
618
+ * @private
619
+ */
620
+ resolveBandOwnership_() {
621
+ var _a;
622
+ const bandsByLevel = /** @type {Object<string, Array<string>>} */ (this.bandsByLevel_);
623
+ const groupPaths = /** @type {Array<string>} */ (this.groupPaths_);
624
+ const subMetadatas = groupPaths.map((gp) => getSubMetadata(
625
+ /** @type {Object<string, *>} */ (this.consolidatedMetadata_), gp));
626
+ for (let i = 0, ii = this.bands_.length; i < ii; ++i) {
627
+ const band = this.bands_[i];
628
+ const g = this.bandGroupIndex_[i];
629
+ if (g === 0) {
630
+ continue; // primary group bands are already in bandsByLevel_
631
+ }
632
+ let foundAtAnyLevel = false;
633
+ for (const matrixId of Object.keys(bandsByLevel)) {
634
+ const bandMeta = /** @type {Object<string, *>|undefined} */ (subMetadatas[g][`${matrixId}/${band}`]);
635
+ if (bandMeta) {
636
+ foundAtAnyLevel = true;
637
+ if (!bandsByLevel[matrixId].includes(band)) {
638
+ bandsByLevel[matrixId].push(band);
639
+ }
640
+ if (this.fillValue_ === undefined) {
641
+ this.fillValue_ = Number(bandMeta['fill_value']);
642
+ }
643
+ }
644
+ }
645
+ if (!foundAtAnyLevel) {
646
+ // Try single-scale: band lives directly at the group root (no matrixId prefix).
647
+ const bandMeta = /** @type {Object<string, *>|undefined} */ (subMetadatas[g][band]);
648
+ if (bandMeta) {
649
+ for (const matrixId of Object.keys(bandsByLevel)) {
650
+ if (!bandsByLevel[matrixId].includes(band)) {
651
+ bandsByLevel[matrixId].push(band);
652
+ }
653
+ }
654
+ if (this.fillValue_ === undefined) {
655
+ this.fillValue_ = Number(bandMeta['fill_value']);
656
+ }
657
+ // Derive the band's pixel resolution from its array shape so loadTile_
658
+ // uses correct coordinates regardless of the tile grid zoom level.
659
+ const shape = /** @type {Array<number>|undefined} */ (bandMeta['shape']);
660
+ const xSize = shape && shape[this.axesOf_(bandMeta).col];
661
+ if (xSize !== undefined && xSize > 0) {
662
+ const tileExtent = /** @type {import("ol/extent.js").Extent} */ (this.tileGrid.getExtent());
663
+ this.bandSingleScaleResolution_[i] =
664
+ (tileExtent[2] - tileExtent[0]) / xSize;
665
+ }
666
+ foundAtAnyLevel = true;
667
+ }
668
+ }
669
+ if (!foundAtAnyLevel) {
670
+ warn(`Band "${band}" from group "${(_a = this.groupPaths_) === null || _a === void 0 ? void 0 : _a[g]}" is not available at any ` +
671
+ `resolution level compatible with the tile grid.`);
672
+ }
673
+ }
674
+ }
675
+ /**
676
+ * Open a Zarr array (path relative to its group) through the shared cache, so
677
+ * concurrent opens of the same array are deduplicated.
678
+ * @param {number} groupIndex The band's group index.
679
+ * @param {string} path The array path relative to the group.
680
+ * @return {Promise<import('zarrita').Array<import('zarrita').DataType, any>>} The array.
681
+ * @private
682
+ */
683
+ openArray_(groupIndex, path) {
684
+ const cacheKey = `${groupIndex}:${path}`;
685
+ let array = this.arrayCache_.get(cacheKey);
686
+ if (!array) {
687
+ array =
688
+ /** @type {Promise<import('zarrita').Array<import('zarrita').DataType, any>>} */ (this.openFn_(this.groups_[groupIndex].resolve(path), {
689
+ kind: 'array',
690
+ })).catch((/** @type {Error} */ err) => {
691
+ this.arrayCache_.delete(cacheKey);
692
+ throw err;
693
+ });
694
+ this.arrayCache_.set(cacheKey, array);
695
+ }
696
+ return array;
697
+ }
698
+ /**
699
+ * Consolidated metadata for a group, with keys relative to that group.
700
+ * @param {number} groupIndex The group index.
701
+ * @return {Object<string, *>} The group's consolidated metadata.
702
+ * @private
703
+ */
704
+ groupMetadata_(groupIndex) {
705
+ return this.groupPaths_
706
+ ? getSubMetadata(
707
+ /** @type {Object<string, *>} */ (this.consolidatedMetadata_), this.groupPaths_[groupIndex])
708
+ : /** @type {Object<string, *>} */ (this.consolidatedMetadata_);
709
+ }
710
+ /**
711
+ * Look up a band's Zarr v3 array metadata from consolidated metadata, trying
712
+ * the multi-scale key (`<matrixId>/<band>`) first and falling back to a
713
+ * single-scale key (`<band>`).
714
+ * @param {string} band The band name.
715
+ * @param {number} groupIndex The index of the band's group.
716
+ * @return {Object<string, *>|undefined} The array metadata, or undefined when unavailable.
717
+ * @private
718
+ */
719
+ getBandArrayMeta_(band, groupIndex) {
720
+ if (!this.consolidatedMetadata_) {
721
+ return undefined;
722
+ }
723
+ const meta = this.groupMetadata_(groupIndex);
724
+ if (this.bandsByLevel_) {
725
+ for (const matrixId of Object.keys(this.bandsByLevel_)) {
726
+ if (this.bandsByLevel_[matrixId].includes(band) &&
727
+ meta[`${matrixId}/${band}`]) {
728
+ return /** @type {Object<string, *>} */ (meta[`${matrixId}/${band}`]);
729
+ }
730
+ }
731
+ }
732
+ return /** @type {Object<string, *>|undefined} */ (meta[band]);
733
+ }
734
+ /**
735
+ * Locate the 1-D coordinate array for a non-spatial dimension, by name among
736
+ * the group's 1-D arrays.
737
+ * @param {string} name The dimension name.
738
+ * @return {{path: string, groupIndex: number, meta: Object<string, *>}|null} The path
739
+ * (relative to the group), group index, and array metadata; or `null`.
740
+ * @private
741
+ */
742
+ coordinateArray_(name) {
743
+ if (!this.consolidatedMetadata_) {
744
+ return null;
745
+ }
746
+ const groupIndex = this.bandGroupIndex_[0];
747
+ const meta = this.groupMetadata_(groupIndex);
748
+ const suffix = `/${name}`;
749
+ for (const path of Object.keys(meta)) {
750
+ if (path === name || path.endsWith(suffix)) {
751
+ const arrayMeta = /** @type {Object<string, *>} */ (meta[path]);
752
+ if (Array.isArray(arrayMeta === null || arrayMeta === void 0 ? void 0 : arrayMeta.shape) && arrayMeta.shape.length === 1) {
753
+ return { path, groupIndex, meta: arrayMeta };
754
+ }
755
+ }
756
+ }
757
+ return null;
758
+ }
759
+ /**
760
+ * Get the non-spatial dimensions of the bands (e.g. `time`) that can be fixed
761
+ * through the `dimensions` option, keyed by dimension name. Each entry has its
762
+ * `size` and the `attributes` of its coordinate array (e.g. `units`, for
763
+ * interpreting the values from {@link getValue}), or `attributes: null` when
764
+ * there is no coordinate array. Resolves with an empty object for 2-D bands,
765
+ * once the source is `ready`; rejects if the source fails to load.
766
+ * @return {Promise<Object<string, {size: number, attributes: Object|null}>>}
767
+ * The selectable dimensions.
768
+ */
769
+ async getDimensions() {
770
+ var _a;
771
+ await this.ready();
772
+ /** @type {Object<string, {size: number, attributes: Object|null}>} */
773
+ const dimensions = {};
774
+ for (const dimension of this.extraDimensions_) {
775
+ const coord = this.coordinateArray_(dimension.name);
776
+ dimensions[dimension.name] = {
777
+ size: dimension.size,
778
+ attributes: coord ? ((_a = coord.meta.attributes) !== null && _a !== void 0 ? _a : null) : null,
779
+ };
780
+ }
781
+ return dimensions;
782
+ }
783
+ /**
784
+ * Read the coordinate value at an index along a non-spatial dimension (e.g.
785
+ * the timestamp for a `time` index), for labeling the current selection. The
786
+ * value is returned raw (as stored, e.g. a `bigint` for a 64-bit integer
787
+ * axis); use the `attributes` from {@link getDimensions} to interpret it.
788
+ * Returns `null` for a dimension without a coordinate array. Available once
789
+ * the source is `ready`.
790
+ * @param {string} name The dimension name (see {@link getDimensions}).
791
+ * @param {number} index The index along the dimension.
792
+ * @return {Promise<number|bigint|null>} The coordinate value, or null.
793
+ */
794
+ async getValue(name, index) {
795
+ await this.ready();
796
+ const coord = this.coordinateArray_(name);
797
+ if (!coord) {
798
+ return null;
799
+ }
800
+ const size = coord.meta.shape[0];
801
+ if (index < 0 || index >= size) {
802
+ throw new Error(`GeoZarr: index ${index} out of range for dimension "${name}" (size ${size}).`);
803
+ }
804
+ const array = await this.openArray_(coord.groupIndex, coord.path);
805
+ const chunk = await get(array, [slice(index, index + 1)]);
806
+ return /** @type {number|bigint} */ (
807
+ /** @type {ArrayLike<number|bigint>} */ (chunk.data)[0]);
808
+ }
809
+ /**
810
+ * Change the fixed index of one or more non-spatial dimensions (e.g. move to
811
+ * another `time` slice) without rebuilding the source. Values are merged into
812
+ * the current selection, so a partial update like `{time: 3}` leaves the other
813
+ * dimensions untouched. Takes effect immediately when the source is `ready`,
814
+ * otherwise once it becomes ready.
815
+ * @param {Object<string, number|string>} dimensions Index per dimension name
816
+ * to change; see the `dimensions` constructor option.
817
+ */
818
+ updateDimensions(dimensions) {
819
+ const merged = Object.assign(Object.assign({}, this.dimensions_), dimensions);
820
+ if (this.getState() !== 'ready') {
821
+ // configure_ reads dimensions_ when it resolves; nothing to do yet.
822
+ this.dimensions_ = merged;
823
+ return;
824
+ }
825
+ // Resolve every band before assigning, so an invalid name or index
826
+ // throws without leaving a half-updated selection.
827
+ const selection = this.bands_.map((band, i) => {
828
+ const arrayMeta = this.getBandArrayMeta_(band, this.bandGroupIndex_[i]);
829
+ if (!this.variable_) {
830
+ return this.resolveExtraSelection_(arrayMeta, merged);
831
+ }
832
+ // In `variable` mode, merge into the current selection so the axes
833
+ // fixed through the `selector` option are kept.
834
+ const dims = this.extraDimsOf_(arrayMeta);
835
+ const current = this.bandExtraSelection_[i];
836
+ const updated = current ? current.slice() : undefined;
837
+ for (const [name, index] of Object.entries(dimensions)) {
838
+ const dim = dims.find((d) => d.name === name);
839
+ if (!dim || !updated) {
840
+ throw new Error(`GeoZarr: unknown dimension "${name}"; ` +
841
+ `available: [${dims.map((d) => d.name).join(', ')}].`);
842
+ }
843
+ if (dim.axis === this.multiAxis_) {
844
+ throw new Error(`GeoZarr: dimension "${name}" selects the rendered bands; ` +
845
+ `set it through the \`selector\` option instead.`);
846
+ }
847
+ if (typeof index !== 'number' ||
848
+ !Number.isInteger(index) ||
849
+ index < 0 ||
850
+ index >= dim.size) {
851
+ throw new Error(`GeoZarr: invalid index ${index} for dimension "${name}" ` +
852
+ `(size ${dim.size}).`);
853
+ }
854
+ updated[dim.axis] = index;
855
+ }
856
+ return updated;
857
+ });
858
+ this.dimensions_ = merged;
859
+ this.bandExtraSelection_ = selection;
860
+ // Bump the tile key to reload tiles. Deriving it from the selection (rather
861
+ // than a counter) keeps prior selections' tiles cached, so revisiting hits.
862
+ this.setKey(getUid(this) + ':' + JSON.stringify(selection));
863
+ }
864
+ /**
865
+ * Locate the spatial (y, x) axes of an array (see {@link getSpatialAxes}) and
866
+ * its remaining non-spatial axes.
867
+ * @param {Object<string, *>|undefined} arrayMeta Zarr v3 array metadata.
868
+ * @return {{row: number, col: number, extra: Array<number>}} The row (y) and
869
+ * column (x) axis positions and the remaining extra axes, in array order.
870
+ * @private
871
+ */
872
+ axesOf_(arrayMeta) {
873
+ const { row, col } = getSpatialAxes(this.spatialDimensionNames_, arrayMeta);
874
+ const rank = ((arrayMeta && arrayMeta['shape']) || []).length;
875
+ const extra = [];
876
+ for (let axis = 0; axis < rank; ++axis) {
877
+ if (axis !== row && axis !== col) {
878
+ extra.push(axis);
879
+ }
880
+ }
881
+ return { row, col, extra };
882
+ }
883
+ /**
884
+ * Describe the non-spatial dimensions of an array. Each is named by its
885
+ * `dimension_names` entry, or by its axis position when there are none.
886
+ * @param {Object<string, *>|undefined} arrayMeta Zarr v3 array metadata.
887
+ * @return {Array<{name: string, size: number, axis: number}>} The extra dimensions, outermost first.
888
+ * @private
889
+ */
890
+ extraDimsOf_(arrayMeta) {
891
+ if (!arrayMeta) {
892
+ return [];
893
+ }
894
+ const shape = arrayMeta['shape'];
895
+ if (!Array.isArray(shape) || shape.length <= 2) {
896
+ return [];
897
+ }
898
+ const dimensionNames = arrayMeta['dimension_names'];
899
+ const hasNames = Array.isArray(dimensionNames);
900
+ const dims = [];
901
+ for (const axis of this.axesOf_(arrayMeta).extra) {
902
+ const raw = hasNames ? dimensionNames[axis] : undefined;
903
+ const name = raw === null || raw === undefined ? String(axis) : String(raw);
904
+ dims.push({ name, size: shape[axis], axis });
905
+ }
906
+ return dims;
907
+ }
908
+ /**
909
+ * Resolve the fixed index for each non-spatial dimension of a band array from
910
+ * the `dimensions` option. Returns `undefined` for 2-D arrays, otherwise an
911
+ * array aligned to the array rank with a fixed integer at each extra axis and
912
+ * `null` at the two spatial axes (e.g. `[2, null, null]` for a `[time, y, x]`
913
+ * array with `{time: 2}`).
914
+ * @param {Object<string, *>|undefined} arrayMeta Zarr v3 array metadata.
915
+ * @param {Object<string, number|string>} dimensions The dimension indices
916
+ * to resolve against.
917
+ * @return {Array<number|null>|undefined} The extra-axis selection template.
918
+ * @private
919
+ */
920
+ resolveExtraSelection_(arrayMeta, dimensions) {
921
+ if (!arrayMeta) {
922
+ return undefined;
923
+ }
924
+ const dims = this.extraDimsOf_(arrayMeta);
925
+ if (dims.length === 0) {
926
+ return undefined;
927
+ }
928
+ const names = dims.map((d) => d.name);
929
+ const dimKeys = Object.keys(dimensions);
930
+ // A single unnamed dimension is lenient: any single key binds to it.
931
+ const singleUnnamed = dims.length === 1 && !Array.isArray(arrayMeta['dimension_names']);
932
+ // Fail loud on a key matching no dimension, rather than silently rendering
933
+ // the wrong slice.
934
+ for (const key of dimKeys) {
935
+ if (!names.includes(key) && !singleUnnamed) {
936
+ throw new Error(`GeoZarr: unknown dimension "${key}" in the \`dimensions\` option; ` +
937
+ `available: [${names.join(', ')}].`);
938
+ }
939
+ }
940
+ const selection = new Array(arrayMeta['shape'].length).fill(null);
941
+ for (const dim of dims) {
942
+ const name = dim.name;
943
+ let index;
944
+ if (name in dimensions) {
945
+ index = dimensions[name];
946
+ }
947
+ else if (singleUnnamed && dimKeys.length === 1) {
948
+ index = dimensions[dimKeys[0]];
949
+ }
950
+ else {
951
+ index = 0; // unspecified extra dimension defaults to the first slice
952
+ }
953
+ if (typeof index === 'string') {
954
+ // Datetime-label selection is not implemented yet; only integer indices.
955
+ throw new Error(`GeoZarr: datetime-label selection for dimension "${name}" is not yet ` +
956
+ `implemented; pass an integer index in the \`dimensions\` option.`);
957
+ }
958
+ if (!Number.isInteger(index) || index < 0 || index >= dim.size) {
959
+ throw new Error(`GeoZarr: invalid index ${index} for dimension "${name}" ` +
960
+ `(size ${dim.size}).`);
961
+ }
962
+ selection[dim.axis] = index;
963
+ }
964
+ return selection;
965
+ }
966
+ /**
967
+ * Open the group, retrying as Zarr v2 without probing for v3 metadata
968
+ * first, for servers that answer 403 for missing keys.
969
+ * @param {*} source The store or location to open.
970
+ * @return {Promise<import('zarrita').Group<any>>} The opened group.
971
+ * @private
972
+ */
973
+ async openGroup_(source) {
974
+ try {
975
+ return await this.openFn_(source, { kind: 'group' });
976
+ }
977
+ catch (err) {
978
+ if (this.openFn_ !== open) {
979
+ throw err;
980
+ }
981
+ const group = await open.v2(source, { kind: 'group' });
982
+ this.openFn_ = open.v2;
983
+ return group;
984
+ }
985
+ }
986
+ /**
987
+ * @param {Object<string, *>} attributes The dataset attributes.
988
+ * @param {FetchStore} store The store, for metadata requests not covered
989
+ * by consolidated metadata.
990
+ * @private
991
+ */
992
+ async configureDatacube_(attributes, store) {
993
+ var _a;
994
+ const variable = /** @type {string} */ (this.variable_);
995
+ const consolidatedMetadata = this.consolidatedMetadata_;
996
+ // Collect the multiscale level paths; without multiscale metadata the
997
+ // group itself is the only level.
998
+ const multiscales = attributes['multiscales'];
999
+ /** @type {Array<{path: string, transform: Array<number>|undefined, shape: Array<number>|undefined}>} */
1000
+ const rawLevels = [];
1001
+ /** @type {string|null} */
1002
+ let crsHint = null;
1003
+ if (Array.isArray(multiscales) &&
1004
+ multiscales.length > 0 &&
1005
+ Array.isArray(multiscales[0].datasets)) {
1006
+ for (const dataset of multiscales[0].datasets) {
1007
+ const path = dataset.path === '.' || !dataset.path
1008
+ ? ''
1009
+ : String(dataset.path).replace(/\/+$/, '');
1010
+ rawLevels.push({
1011
+ path,
1012
+ transform: dataset['spatial:transform'],
1013
+ shape: dataset['spatial:shape'],
1014
+ });
1015
+ if (!crsHint && typeof dataset['crs'] === 'string') {
1016
+ crsHint = dataset['crs'];
1017
+ }
1018
+ }
1019
+ }
1020
+ else if (multiscales && Array.isArray(multiscales.layout)) {
1021
+ for (const entry of multiscales.layout) {
1022
+ rawLevels.push({
1023
+ path: String(entry.asset),
1024
+ transform: undefined,
1025
+ shape: entry['spatial:shape'],
1026
+ });
1027
+ }
1028
+ }
1029
+ else {
1030
+ rawLevels.push({
1031
+ path: '',
1032
+ transform: attributes['spatial:transform'],
1033
+ shape: attributes['spatial:shape'],
1034
+ });
1035
+ }
1036
+ /** @type {Array<{path: string, arrayPath: string, meta: any, transform: Array<number>|undefined, shape: Array<number>|undefined}>} */
1037
+ const levels = [];
1038
+ for (const raw of rawLevels) {
1039
+ const arrayPath = raw.path ? `${raw.path}/${variable}` : variable;
1040
+ const meta = consolidatedMetadata
1041
+ ? consolidatedMetadata[arrayPath]
1042
+ : undefined;
1043
+ if (consolidatedMetadata && !meta) {
1044
+ warn(`Variable "${variable}" not found at level "${raw.path || '.'}"`);
1045
+ continue;
1046
+ }
1047
+ levels.push(Object.assign(Object.assign({}, raw), { arrayPath, meta }));
1048
+ }
1049
+ if (levels.length === 0) {
1050
+ throw new Error(`Variable "${variable}" not found in any level`);
1051
+ }
1052
+ // The dimension layout is the same for all levels
1053
+ let meta = (_a = levels.find((level) => level.meta)) === null || _a === void 0 ? void 0 : _a.meta;
1054
+ if (!meta) {
1055
+ meta = await getArrayMeta(store, levels[0].arrayPath);
1056
+ levels[0].meta = meta;
1057
+ }
1058
+ if (!meta || !Array.isArray(meta.shape)) {
1059
+ throw new Error(`Could not read metadata for variable "${variable}"`);
1060
+ }
1061
+ const ndim = meta.shape.length;
1062
+ let dimensionNames = meta['dimension_names'];
1063
+ if (!dimensionNames && ndim === 2) {
1064
+ dimensionNames = ['y', 'x'];
1065
+ }
1066
+ if (!dimensionNames) {
1067
+ throw new Error(`Cannot determine dimension names for variable "${variable}"`);
1068
+ }
1069
+ const { row, col } = getSpatialAxes(attributes['spatial:dimensions'], meta);
1070
+ // Determine the extent and y axis orientation: declared metadata is
1071
+ // authoritative, otherwise both are inferred from the coordinate arrays.
1072
+ let extent = attributes['spatial:bbox'] || attributes['bounds'];
1073
+ let flipY = false;
1074
+ let orientationKnown = false;
1075
+ const transform0 = levels[0].transform;
1076
+ if (extent &&
1077
+ Array.isArray(transform0) &&
1078
+ transform0.length >= 6 &&
1079
+ transform0[4] !== 0) {
1080
+ flipY = transform0[4] > 0;
1081
+ orientationKnown = true;
1082
+ }
1083
+ else {
1084
+ // All levels share the extent; the smallest level is cheapest to read
1085
+ let coordLevel = levels[0];
1086
+ for (const level of levels) {
1087
+ if (level.meta &&
1088
+ coordLevel.meta &&
1089
+ level.meta.shape[col] < coordLevel.meta.shape[col]) {
1090
+ coordLevel = level;
1091
+ }
1092
+ }
1093
+ try {
1094
+ const [x0, x1, xCount] = await this.readCoordinateEndpoints_(coordLevel.path, dimensionNames[col]);
1095
+ const [y0, y1, yCount] = await this.readCoordinateEndpoints_(coordLevel.path, dimensionNames[row]);
1096
+ flipY = y1 > y0;
1097
+ orientationKnown = true;
1098
+ if (x1 < x0) {
1099
+ warn('Descending x coordinates are not supported.');
1100
+ }
1101
+ if (!extent) {
1102
+ // Coordinates are pixel centers; pad by half a pixel
1103
+ const xResolution = Math.abs(x1 - x0) / (xCount - 1);
1104
+ const yResolution = Math.abs(y1 - y0) / (yCount - 1);
1105
+ extent = [
1106
+ Math.min(x0, x1) - xResolution / 2,
1107
+ Math.min(y0, y1) - yResolution / 2,
1108
+ Math.max(x0, x1) + xResolution / 2,
1109
+ Math.max(y0, y1) + yResolution / 2,
1110
+ ];
1111
+ // Normalize [0, 360]-style longitudes to [-180, 180]
1112
+ if (extent[0] >= 0 && extent[2] > 180 && extent[2] <= 360.001) {
1113
+ extent = [extent[0] - 360, extent[1], extent[2] - 360, extent[3]];
1114
+ }
1115
+ }
1116
+ }
1117
+ catch (err) {
1118
+ if (!extent && !this.fallbackExtent_) {
1119
+ throw new Error(`Could not determine the extent of variable "${variable}" ` +
1120
+ `from metadata or coordinate arrays: ${ /** @type {Error} */(err).message}`);
1121
+ }
1122
+ }
1123
+ }
1124
+ if (!extent) {
1125
+ extent = this.fallbackExtent_;
1126
+ }
1127
+ if (!orientationKnown && this.fallbackFlipY_ !== undefined) {
1128
+ flipY = this.fallbackFlipY_;
1129
+ }
1130
+ const extentWidth = extent[2] - extent[0];
1131
+ const extentHeight = extent[3] - extent[1];
1132
+ /** @type {Array<{path: string, arrayPath: string, meta: any, resolution: number, rowResolution: number, origin: import("ol/coordinate.js").Coordinate}>} */
1133
+ const configured = [];
1134
+ for (const level of levels) {
1135
+ let resolution;
1136
+ // Pixels are not necessarily square (e.g. a square array covering the
1137
+ // full EPSG:4326 world), so the row resolution is tracked separately.
1138
+ let rowResolution;
1139
+ let origin;
1140
+ if (Array.isArray(level.transform) &&
1141
+ level.transform.length >= 6 &&
1142
+ level.transform[4] < 0) {
1143
+ resolution = level.transform[0];
1144
+ rowResolution = -level.transform[4];
1145
+ origin = [level.transform[2], level.transform[5]];
1146
+ }
1147
+ else if (Array.isArray(level.shape)) {
1148
+ resolution = extentWidth / level.shape[1];
1149
+ rowResolution = extentHeight / level.shape[0];
1150
+ origin = [extent[0], extent[3]];
1151
+ }
1152
+ else if (level.meta && Array.isArray(level.meta.shape)) {
1153
+ resolution = extentWidth / level.meta.shape[col];
1154
+ rowResolution = extentHeight / level.meta.shape[row];
1155
+ origin = [extent[0], extent[3]];
1156
+ }
1157
+ else {
1158
+ warn(`No resolution information for level "${level.path || '.'}"`);
1159
+ continue;
1160
+ }
1161
+ configured.push({
1162
+ path: level.path,
1163
+ arrayPath: level.arrayPath,
1164
+ meta: level.meta,
1165
+ resolution,
1166
+ rowResolution,
1167
+ origin,
1168
+ });
1169
+ }
1170
+ if (configured.length === 0) {
1171
+ throw new Error(`No usable level found for variable "${variable}"`);
1172
+ }
1173
+ configured.sort((a, b) => b.resolution - a.resolution);
1174
+ if (!this.projection) {
1175
+ this.projection = this.inferProjection_(attributes, crsHint, extent);
1176
+ }
1177
+ // Resolve the selector to per-axis indices; labels are resolved against
1178
+ // the finest level's coordinate arrays
1179
+ const finestPath = configured[configured.length - 1].path;
1180
+ /** @type {Array<{axis: number, indices: Array<number>}>} */
1181
+ const slots = [];
1182
+ this.multiAxis_ = -1;
1183
+ this.extraDimensions_ = [];
1184
+ for (let axis = 0; axis < ndim; ++axis) {
1185
+ if (axis === row || axis === col) {
1186
+ continue;
1187
+ }
1188
+ const dimName = dimensionNames[axis];
1189
+ this.extraDimensions_.push({ name: dimName, size: meta.shape[axis] });
1190
+ // `dimensions` (also merged by updateDimensions) takes precedence
1191
+ // over the initial `selector` for scalar values
1192
+ let value = dimName in this.dimensions_
1193
+ ? this.dimensions_[dimName]
1194
+ : this.selector_[dimName];
1195
+ if (value === undefined) {
1196
+ warn(`No selector value given for dimension "${dimName}", using index 0.`);
1197
+ value = 0;
1198
+ }
1199
+ /** @type {Array<number|string>} */
1200
+ let values;
1201
+ if (Array.isArray(value)) {
1202
+ values = value;
1203
+ this.multiAxis_ = axis;
1204
+ }
1205
+ else {
1206
+ values = [value];
1207
+ }
1208
+ const indices = [];
1209
+ for (const v of values) {
1210
+ if (typeof v === 'number') {
1211
+ indices.push(v);
1212
+ }
1213
+ else {
1214
+ indices.push(await this.resolveCoordinateLabel_(dimName, v, finestPath));
1215
+ }
1216
+ }
1217
+ for (const index of indices) {
1218
+ if (!Number.isInteger(index) ||
1219
+ index < 0 ||
1220
+ index >= meta.shape[axis]) {
1221
+ throw new Error(`GeoZarr: invalid index ${index} for dimension "${dimName}" ` +
1222
+ `(size ${meta.shape[axis]}).`);
1223
+ }
1224
+ }
1225
+ slots.push({ axis, indices });
1226
+ }
1227
+ // Materialize the datacube as the per-band state that loadTile_
1228
+ // consumes: each rendered band is the variable array with fixed indices
1229
+ // at the non-spatial axes.
1230
+ const count = this.bandCount;
1231
+ this.bands_ = new Array(count).fill(variable);
1232
+ this.bandGroupIndex_ = new Array(count).fill(0);
1233
+ this.bandSingleScaleResolution_ = new Array(count).fill(undefined);
1234
+ this.bandSpatialAxes_ = new Array(count).fill({ row, col });
1235
+ this.bandExtraSelection_ = new Array(count)
1236
+ .fill(undefined)
1237
+ .map((_, bandIndex) => {
1238
+ if (slots.length === 0) {
1239
+ return undefined;
1240
+ }
1241
+ const selection = new Array(ndim).fill(null);
1242
+ for (const slot of slots) {
1243
+ selection[slot.axis] =
1244
+ slot.axis === this.multiAxis_
1245
+ ? slot.indices[bandIndex]
1246
+ : slot.indices[0];
1247
+ }
1248
+ return selection;
1249
+ });
1250
+ this.fillValue_ = parseFillValue(meta['fill_value']);
1251
+ // Align tiles to shards or chunks: every tile decodes all chunks it
1252
+ // touches, so tiles much smaller than a chunk would decode the same
1253
+ // chunk over and over.
1254
+ const tileSizes = configured.map((level) => {
1255
+ var _a, _b;
1256
+ if (!level.meta) {
1257
+ return undefined;
1258
+ }
1259
+ const shardInfo = getShardInfo(level.meta, row, col);
1260
+ if (shardInfo) {
1261
+ return /** @type {import("ol/size.js").Size} */ ([
1262
+ getTileSizeForShard(shardInfo.shardShape[1], shardInfo.innerChunkShape[1]),
1263
+ getTileSizeForShard(shardInfo.shardShape[0], shardInfo.innerChunkShape[0]),
1264
+ ]);
1265
+ }
1266
+ const chunkShape = (_b = (_a = level.meta['chunk_grid']) === null || _a === void 0 ? void 0 : _a.configuration) === null || _b === void 0 ? void 0 : _b.chunk_shape;
1267
+ if (Array.isArray(chunkShape)) {
1268
+ return /** @type {import("ol/size.js").Size} */ ([
1269
+ getTileSizeForChunk(chunkShape[col], level.meta.shape[col]),
1270
+ getTileSizeForChunk(chunkShape[row], level.meta.shape[row]),
1271
+ ]);
1272
+ }
1273
+ return undefined;
1274
+ });
1275
+ const hasTileSizes = tileSizes.some((s) => s !== undefined);
1276
+ this.bandsByLevel_ = {};
1277
+ this.levelPaths_ = {};
1278
+ this.levelRowInfo_ = {};
1279
+ const matrixIds = configured.map((level, i) => level.path || String(i));
1280
+ for (let i = 0; i < configured.length; ++i) {
1281
+ const level = configured[i];
1282
+ this.bandsByLevel_[matrixIds[i]] = this.bands_;
1283
+ this.levelPaths_[matrixIds[i]] = level.path;
1284
+ this.levelRowInfo_[matrixIds[i]] = {
1285
+ rowResolution: level.rowResolution,
1286
+ shapeY: level.meta && Array.isArray(level.meta.shape)
1287
+ ? level.meta.shape[row]
1288
+ : undefined,
1289
+ flip: flipY,
1290
+ };
1291
+ }
1292
+ this.tileGrid = new WMTSTileGrid(Object.assign({ extent: extent, origins: configured.map((level) => level.origin), resolutions: configured.map((level) => level.resolution), matrixIds: matrixIds }, (hasTileSizes
1293
+ ? { tileSizes: tileSizes.map((s) => s || [256, 256]) }
1294
+ : {})));
1295
+ }
1296
+ /**
1297
+ * Determine the projection from the store metadata: the proj: convention,
1298
+ * a CRS code from the multiscale metadata or xarray-style attributes, a
1299
+ * `proj4` definition, or (for degree-like extents) EPSG:4326.
1300
+ * @param {Object<string, *>} attributes The dataset attributes.
1301
+ * @param {string|null} crsHint A CRS code from the multiscale metadata.
1302
+ * @param {import("ol/extent.js").Extent} extent The extent.
1303
+ * @return {import("ol/proj/Projection.js").default} The projection.
1304
+ * @private
1305
+ */
1306
+ inferProjection_(attributes, crsHint, extent) {
1307
+ try {
1308
+ const projection = getProjectionFromAttributes(
1309
+ /** @type {DatasetAttributes} */ (attributes));
1310
+ if (projection) {
1311
+ return projection;
1312
+ }
1313
+ }
1314
+ catch (_a) {
1315
+ // not declared through the proj: convention
1316
+ }
1317
+ const code = crsHint ||
1318
+ (typeof attributes['spatial_ref'] === 'string' &&
1319
+ attributes['spatial_ref'].includes(':')
1320
+ ? attributes['spatial_ref']
1321
+ : null);
1322
+ if (code) {
1323
+ const projection = getProjection(code);
1324
+ if (projection) {
1325
+ return projection;
1326
+ }
1327
+ }
1328
+ const definition = attributes['proj4'] || attributes['proj4_params'];
1329
+ if (typeof definition === 'string') {
1330
+ try {
1331
+ const projection = fromProjectionDefinition(definition);
1332
+ if (projection) {
1333
+ return projection;
1334
+ }
1335
+ }
1336
+ catch (err) {
1337
+ warn(`Could not register the proj4 definition: ${ /** @type {Error} */(err).message}`);
1338
+ }
1339
+ }
1340
+ if (extent &&
1341
+ extent[0] >= -360 &&
1342
+ extent[2] <= 360.001 &&
1343
+ extent[1] >= -90 &&
1344
+ extent[3] <= 90) {
1345
+ // Extent magnitude suggests degrees
1346
+ return /** @type {import('ol/proj/Projection.js').default} */ (getProjection('EPSG:4326'));
1347
+ }
1348
+ throw new Error('Could not determine the projection');
1349
+ }
1350
+ /**
1351
+ * Read the first and last value of a 1-dimensional coordinate array.
1352
+ * @param {string} levelPath The level group path ('' for the root).
1353
+ * @param {string} dimName The dimension (and coordinate array) name.
1354
+ * @return {Promise<Array<number>>} The first value, last value, and length.
1355
+ * @private
1356
+ */
1357
+ async readCoordinateEndpoints_(levelPath, dimName) {
1358
+ const path = levelPath ? `${levelPath}/${dimName}` : dimName;
1359
+ const array = await this.openArray_(0, path);
1360
+ const length = array.shape[0];
1361
+ const first = await get(array, [slice(0, 1)]);
1362
+ const last = await get(array, [slice(length - 1, length)]);
1363
+ return [
1364
+ Number(/** @type {ArrayLike<number>} */ (first.data)[0]),
1365
+ Number(/** @type {ArrayLike<number>} */ (last.data)[0]),
1366
+ length,
1367
+ ];
1368
+ }
1369
+ /**
1370
+ * Resolve a coordinate label to its index by reading the dimension's
1371
+ * coordinate array.
1372
+ * @param {string} dimName The dimension name.
1373
+ * @param {string} label The label to resolve.
1374
+ * @param {string} [levelPath] The level group path ('' for the root).
1375
+ * @return {Promise<number>} The index of the label.
1376
+ * @private
1377
+ */
1378
+ async resolveCoordinateLabel_(dimName, label, levelPath = '') {
1379
+ try {
1380
+ const path = levelPath ? `${levelPath}/${dimName}` : dimName;
1381
+ const array = await this.openArray_(0, path);
1382
+ const chunk = await get(array, [null]);
1383
+ const values = Array.from(chunk.data, (v) => String(v));
1384
+ const index = values.indexOf(label);
1385
+ if (index < 0) {
1386
+ throw new Error(`Label not found. Available: ${values.slice(0, 10).join(', ')}`);
1387
+ }
1388
+ return index;
1389
+ }
1390
+ catch (err) {
1391
+ throw new Error(`Could not resolve label "${label}" for dimension "${dimName}": ` +
1392
+ `${ /** @type {Error} */(err).message} Pass a numeric index instead.`);
1393
+ }
1394
+ }
1395
+ }
1396
+ /**
1397
+ * Extract a sub-view of consolidated metadata for a specific group path.
1398
+ * Keys in the returned object are relative to the group path.
1399
+ * @param {Object<string, *>} rootMetadata The root consolidated metadata.
1400
+ * @param {string} groupPath The group path (e.g. 'measurements/reflectance').
1401
+ * @return {Object<string, *>} Sub-metadata with paths relative to the group.
1402
+ */
1403
+ function getSubMetadata(rootMetadata, groupPath) {
1404
+ const prefix = groupPath + '/';
1405
+ /** @type {Object<string, *>} */
1406
+ const sub = {};
1407
+ for (const key of Object.keys(rootMetadata)) {
1408
+ if (key.startsWith(prefix)) {
1409
+ sub[key.substring(prefix.length)] = rootMetadata[key];
1410
+ }
1411
+ }
1412
+ return sub;
1413
+ }
1414
+ /**
1415
+ * Create a store wrapper that serves Zarr v3 metadata from consolidated
1416
+ * metadata, avoiding per-child HTTP requests.
1417
+ * @param {import('zarrita').FetchStore} store The underlying store.
1418
+ * @param {Uint8Array} groupBytes The already-fetched group zarr.json bytes.
1419
+ * @param {Object<string, *>} consolidatedMetadata The parsed consolidated_metadata.metadata entries.
1420
+ * @return {import('zarrita').Readable} A store-compatible object.
1421
+ */
1422
+ function createCachedStore(store, groupBytes, consolidatedMetadata) {
1423
+ var _a;
1424
+ const cache = new Map();
1425
+ cache.set('/zarr.json', groupBytes);
1426
+ const encoder = new TextEncoder();
1427
+ for (const [key, value] of Object.entries(consolidatedMetadata)) {
1428
+ cache.set(
1429
+ /** @type {`/${string}`} */ (`/${key}/zarr.json`), encoder.encode(JSON.stringify(value)));
1430
+ }
1431
+ return withChunkCache({
1432
+ async get(
1433
+ /** @type {string} */ key,
1434
+ /** @type {import('zarrita').GetOptions|undefined} */ opts) {
1435
+ if (cache.has(key)) {
1436
+ return cache.get(key);
1437
+ }
1438
+ return store.get(/** @type {`/${string}`} */ (key), opts);
1439
+ },
1440
+ getRange: (_a = store.getRange) === null || _a === void 0 ? void 0 : _a.bind(store),
1441
+ });
1442
+ }
1443
+ /***
1444
+ * @typedef {{
1445
+ * multiscales: Multiscales,
1446
+ * zarr_conventions: Array<{uuid: string}>,
1447
+ * 'spatial:bbox': import("ol/extent.js").Extent,
1448
+ * 'spatial:shape': Array<number>,
1449
+ * 'spatial:dimensions'?: Array<string>,
1450
+ * 'proj:wkt2'?: string,
1451
+ * 'proj:projjson'?: Object,
1452
+ * 'proj:code'?: string | null,
1453
+ * }} DatasetAttributes
1454
+ */
1455
+ /**
1456
+ * @typedef {Object} Multiscales
1457
+ * @property {Array<Object<string, *>>} layout The layout.
1458
+ */
1459
+ /**
1460
+ * @typedef {Object} LegacyDatasetAttributes
1461
+ * @property {LegacyMultiscales} multiscales The multiscales attribute.
1462
+ */
1463
+ /**
1464
+ * @typedef {Object} LegacyMultiscales
1465
+ * @property {any} tile_matrix_limits The tile matrix limits.
1466
+ * @property {any} tile_matrix_set The tile matrix set.
1467
+ */
1468
+ /**
1469
+ * @typedef {Object} TileGridInfo
1470
+ * @property {WMTSTileGrid} tileGrid The tile grid.
1471
+ * @property {import("ol/proj/Projection.js").default} projection The projection.
1472
+ * @property {Object<string, Array<string>>} [bandsByLevel] Available bands by level.
1473
+ * @property {number} [fillValue] The fill value.
1474
+ * @property {Array<import("ol/size.js").Size>|undefined} [tileSizes] The tile sizes for each level, if available.
1475
+ */
1476
+ /**
1477
+ * Maximum tile size for rendering.
1478
+ * @type {number}
1479
+ */
1480
+ const MAX_TILE_SIZE = 512;
1481
+ /**
1482
+ * Minimum tile size when sharding is used.
1483
+ * @type {number}
1484
+ */
1485
+ const MIN_TILE_SIZE = 64;
1486
+ /**
1487
+ * @typedef {Object} ShardInfo
1488
+ * @property {Array<number>} shardShape The shard (outer chunk) shape [rows, cols].
1489
+ * @property {Array<number>} innerChunkShape The inner chunk shape [rows, cols].
1490
+ */
1491
+ /**
1492
+ * Locate the row (y) and column (x) axis positions of an array by matching the
1493
+ * group's `spatial:dimensions` names (`[y, x]`) against the array's
1494
+ * `dimension_names`, falling back to the two trailing axes when either is absent.
1495
+ * @param {Array<string>|undefined} spatialDimensionNames The `spatial:dimensions` value (`[y, x]` names).
1496
+ * @param {Object<string, *>|undefined} arrayMeta Zarr v3 array metadata.
1497
+ * @return {{row: number, col: number}} The row (y) and column (x) axis positions.
1498
+ */
1499
+ function getSpatialAxes(spatialDimensionNames, arrayMeta) {
1500
+ const rank = ((arrayMeta && arrayMeta['shape']) || []).length;
1501
+ const names = arrayMeta && arrayMeta['dimension_names'];
1502
+ if (Array.isArray(spatialDimensionNames) &&
1503
+ spatialDimensionNames.length === 2 &&
1504
+ Array.isArray(names)) {
1505
+ const row = names.indexOf(spatialDimensionNames[0]);
1506
+ const col = names.indexOf(spatialDimensionNames[1]);
1507
+ if (row !== -1 && col !== -1) {
1508
+ return { row, col };
1509
+ }
1510
+ }
1511
+ return { row: rank - 2, col: rank - 1 };
1512
+ }
1513
+ /**
1514
+ * FIXME Remove this when GeoZarr datasets provide correct TileMatrixSet info or similar.
1515
+ *
1516
+ * Get the shard and inner chunk shapes from the Zarr v3 array metadata.
1517
+ * Only returns info when a `sharding_indexed` codec is present, meaning
1518
+ * `chunk_grid.configuration.chunk_shape` represents the shard (outer chunk) size.
1519
+ * @param {Object<string, *>} arrayMeta The Zarr v3 array metadata from consolidated metadata.
1520
+ * @param {number} row The row (y) axis position.
1521
+ * @param {number} col The column (x) axis position.
1522
+ * @return {ShardInfo|undefined} The shard info, or undefined.
1523
+ */
1524
+ function getShardInfo(arrayMeta, row, col) {
1525
+ const chunkGrid = arrayMeta['chunk_grid'];
1526
+ if (!chunkGrid || chunkGrid['name'] !== 'regular') {
1527
+ return undefined;
1528
+ }
1529
+ const codecs = arrayMeta['codecs'];
1530
+ if (!Array.isArray(codecs)) {
1531
+ return undefined;
1532
+ }
1533
+ const shardingCodec = codecs.find((c) => c['name'] === 'sharding_indexed');
1534
+ if (!shardingCodec) {
1535
+ return undefined;
1536
+ }
1537
+ // Keep only the two spatial axes; the chunk/shard shapes are N-D for N-D arrays.
1538
+ const shardShape = chunkGrid['configuration']['chunk_shape'];
1539
+ const innerChunkShape = shardingCodec['configuration']['chunk_shape'];
1540
+ return {
1541
+ shardShape: [shardShape[row], shardShape[col]],
1542
+ innerChunkShape: [innerChunkShape[row], innerChunkShape[col]],
1543
+ };
1544
+ }
1545
+ /**
1546
+ * FIXME Remove this when GeoZarr datasets provide correct TileMatrixSet info or similar.
1547
+ *
1548
+ * Compute a tile size that is a multiple of the inner chunk size, evenly divides
1549
+ * the shard size, is at most MAX_TILE_SIZE, and is at least MIN_TILE_SIZE.
1550
+ * Aligning with inner chunk boundaries avoids fetching the same inner chunk
1551
+ * data for adjacent tiles.
1552
+ * @param {number} shardSize The shard size in pixels along one dimension.
1553
+ * @param {number} innerChunkSize The inner chunk size in pixels along one dimension.
1554
+ * @return {number} The tile size.
1555
+ */
1556
+ function getTileSizeForShard(shardSize, innerChunkSize) {
1557
+ if (innerChunkSize > MAX_TILE_SIZE) {
1558
+ // Use the largest divisor of the inner chunk size that fits, so that a
1559
+ // whole number of tiles covers each inner chunk.
1560
+ for (let size = MAX_TILE_SIZE; size >= MIN_TILE_SIZE; --size) {
1561
+ if (innerChunkSize % size === 0) {
1562
+ return size;
1563
+ }
1564
+ }
1565
+ return MAX_TILE_SIZE;
1566
+ }
1567
+ // Find the largest multiple of innerChunkSize that divides shardSize
1568
+ // and is within [MIN_TILE_SIZE, MAX_TILE_SIZE].
1569
+ const maxChunks = Math.floor(MAX_TILE_SIZE / innerChunkSize);
1570
+ for (let n = maxChunks; n >= 1; --n) {
1571
+ const candidate = n * innerChunkSize;
1572
+ if (candidate >= MIN_TILE_SIZE && shardSize % candidate === 0) {
1573
+ return candidate;
1574
+ }
1575
+ }
1576
+ // No ideal size found. Use shard size itself when it fits, otherwise
1577
+ // use the largest chunk-aligned size that fits within MAX_TILE_SIZE.
1578
+ if (shardSize <= MAX_TILE_SIZE && shardSize >= MIN_TILE_SIZE) {
1579
+ return shardSize;
1580
+ }
1581
+ if (shardSize < MIN_TILE_SIZE) {
1582
+ return MIN_TILE_SIZE;
1583
+ }
1584
+ return Math.max(maxChunks * innerChunkSize, MIN_TILE_SIZE);
1585
+ }
1586
+ /**
1587
+ * @param {DatasetAttributes} attributes The dataset attributes.
1588
+ * @param {Object<string, *>|null|undefined} consolidatedMetadata The consolidated metadata.
1589
+ * @param {Array<string>} wantedBands The wanted bands.
1590
+ * @return {TileGridInfo} The tile grid info.
1591
+ */
1592
+ function getTileGridInfoFromAttributes(attributes, consolidatedMetadata, wantedBands) {
1593
+ const multiscales = attributes.multiscales;
1594
+ const extent = attributes['spatial:bbox'];
1595
+ const projection = getProjectionFromAttributes(attributes);
1596
+ const extentWidth = extent[2] - extent[0];
1597
+ const origin = [extent[0], extent[3]];
1598
+ /** @type {Array<{matrixId: string, resolution: number, origin: import("ol/coordinate.js").Coordinate, tileSize: import("ol/size.js").Size|undefined}>} */
1599
+ const groupInfo = [];
1600
+ /** @type {Object<string, Array<string>>|undefined} */
1601
+ const bandsByLevel = consolidatedMetadata ? {} : undefined;
1602
+ let fillValue;
1603
+ for (const groupMetadata of multiscales.layout) {
1604
+ const matrixId = /** @type {string} */ (groupMetadata['asset']);
1605
+ const spatialShape = /** @type {Array<number>} */ (groupMetadata['spatial:shape']);
1606
+ const resolution = extentWidth / spatialShape[1];
1607
+ /** @type {import("ol/size.js").Size|undefined} */
1608
+ let tileSize;
1609
+ if (consolidatedMetadata && bandsByLevel) {
1610
+ const availableBands = [];
1611
+ for (const band of wantedBands) {
1612
+ const bandArray = /** @type {Object<string, *>|undefined} */ (consolidatedMetadata[`${matrixId}/${band}`]);
1613
+ if (bandArray) {
1614
+ availableBands.push(band);
1615
+ if (fillValue === undefined) {
1616
+ fillValue = Number(bandArray['fill_value']);
1617
+ }
1618
+ //FIXME Remove this when GeoZarr datasets provide correct TileMatrixSet info or similar
1619
+ if (!tileSize) {
1620
+ const { row, col } = getSpatialAxes(attributes['spatial:dimensions'], bandArray);
1621
+ const shardInfo = getShardInfo(bandArray, row, col);
1622
+ if (shardInfo) {
1623
+ tileSize = [
1624
+ getTileSizeForShard(shardInfo.shardShape[1], shardInfo.innerChunkShape[1]),
1625
+ getTileSizeForShard(shardInfo.shardShape[0], shardInfo.innerChunkShape[0]),
1626
+ ];
1627
+ }
1628
+ }
1629
+ }
1630
+ }
1631
+ bandsByLevel[matrixId] = availableBands;
1632
+ }
1633
+ groupInfo.push({
1634
+ matrixId,
1635
+ resolution,
1636
+ origin,
1637
+ tileSize,
1638
+ });
1639
+ }
1640
+ groupInfo.sort((a, b) => b.resolution - a.resolution);
1641
+ const tileSizes = groupInfo.map((g) => g.tileSize);
1642
+ const hasTileSizes = tileSizes.some((s) => s !== undefined);
1643
+ const tileGrid = new WMTSTileGrid(Object.assign({ extent: extent, origins: groupInfo.map((g) => g.origin), resolutions: groupInfo.map((g) => g.resolution), matrixIds: groupInfo.map((g) => g.matrixId) }, (hasTileSizes
1644
+ ? {
1645
+ tileSizes: /** @type {Array<import("ol/size.js").Size>} */ (tileSizes.map((s) => s || [256, 256])),
1646
+ }
1647
+ : {})));
1648
+ return {
1649
+ tileGrid,
1650
+ projection,
1651
+ bandsByLevel,
1652
+ fillValue,
1653
+ tileSizes: hasTileSizes
1654
+ ? /** @type {Array<import("ol/size.js").Size>} */ (tileSizes.map((s) => s || [256, 256]))
1655
+ : undefined,
1656
+ };
1657
+ }
1658
+ /**
1659
+ * @param {LegacyDatasetAttributes} attributes The dataset attributes.
1660
+ * @return {TileGridInfo} The tile grid info.
1661
+ */
1662
+ function getTileGridInfoFromLegacyAttributes(attributes) {
1663
+ var _a, _b;
1664
+ const multiscales = attributes.multiscales;
1665
+ const tileMatrixSet = multiscales.tile_matrix_set;
1666
+ const tileMatrixLimitsObject = multiscales.tile_matrix_limits;
1667
+ const numMatrices = tileMatrixSet.tileMatrices.length;
1668
+ const tileMatrixLimits = new Array(numMatrices);
1669
+ let overrideTileSize = false;
1670
+ for (let i = 0; i < numMatrices; i += 1) {
1671
+ const tileMatrix = tileMatrixSet.tileMatrices[i];
1672
+ const tilematrixId = tileMatrix.id;
1673
+ if (tileMatrix.tileWidth > 512 || tileMatrix.tileHeight > 512) {
1674
+ // Avoid tile sizes that are too large for rendering
1675
+ overrideTileSize = true;
1676
+ }
1677
+ tileMatrixLimits[i] = tileMatrixLimitsObject[tilematrixId];
1678
+ }
1679
+ const info = parseTileMatrixSet({}, tileMatrixSet, undefined, tileMatrixLimits);
1680
+ let tileGrid = info.grid;
1681
+ // Tile size sanity
1682
+ if (overrideTileSize) {
1683
+ tileGrid = new WMTSTileGrid({
1684
+ tileSize: 512,
1685
+ extent: (_a = tileGrid.getExtent()) !== null && _a !== void 0 ? _a : undefined,
1686
+ origins: (_b = tileGrid.getOrigins()) !== null && _b !== void 0 ? _b : undefined,
1687
+ resolutions: tileGrid.getResolutions(),
1688
+ matrixIds: tileGrid.getMatrixIds(),
1689
+ });
1690
+ }
1691
+ return {
1692
+ tileGrid,
1693
+ projection: /** @type {import("ol/proj/Projection.js").default} */ (info.projection),
1694
+ };
1695
+ }
1696
+ /**
1697
+ * @param {Array<import("zarrita").Chunk<import("zarrita").DataType>>} chunks The input chunks.
1698
+ * @param {Array<number>} chunkResolutions The resolutions for each band.
1699
+ * @param {number} tileColCount The number of columns in the output data.
1700
+ * @param {number} tileRowCount The number of rows in the output data.
1701
+ * @param {number} tileResolution The tile resolution.
1702
+ * @param {ResampleMethod} resampleMethod The resampling method.
1703
+ * @param {number|undefined} fillValue The fill value.
1704
+ * @param {Array<number>} [chunkRowResolutions] The row resolutions for each
1705
+ * band, for data with non-square pixels. Defaults to `chunkResolutions`.
1706
+ * @return {Float32Array} The tile data.
1707
+ */
1708
+ function composeData(chunks, chunkResolutions, tileColCount, tileRowCount, tileResolution, resampleMethod, fillValue, chunkRowResolutions = chunkResolutions) {
1709
+ const chunkCount = chunks.length;
1710
+ const addAlpha = fillValue !== null && fillValue !== undefined;
1711
+ const isNoDataValue = isNaN(/** @type {number} */ (fillValue))
1712
+ ? (/** @type {number} */ v) => isNaN(v)
1713
+ : (/** @type {number} */ v) => v === fillValue;
1714
+ const bandCount = chunkCount + (addAlpha ? 1 : 0);
1715
+ const tileData = new Float32Array(tileColCount * tileRowCount * bandCount);
1716
+ for (let tileRow = 0; tileRow < tileRowCount; tileRow++) {
1717
+ for (let tileCol = 0; tileCol < tileColCount; tileCol++) {
1718
+ let hasData = false;
1719
+ for (let chunkIndex = 0; chunkIndex < chunkCount; ++chunkIndex) {
1720
+ const chunk = chunks[chunkIndex];
1721
+ const chunkRowCount = chunk.shape[0];
1722
+ const chunkColCount = chunk.shape[1];
1723
+ const chunkData = /** @type {ArrayLike<number>} */ (chunk.data);
1724
+ const scaleFactor = tileResolution / chunkResolutions[chunkIndex];
1725
+ const rowScaleFactor = tileResolution / chunkRowResolutions[chunkIndex];
1726
+ let value = 0;
1727
+ let inBounds = false;
1728
+ if (scaleFactor === 1 && rowScaleFactor === 1) {
1729
+ if (tileRow < chunkRowCount && tileCol < chunkColCount) {
1730
+ inBounds = true;
1731
+ value = chunkData[tileRow * chunkColCount + tileCol];
1732
+ }
1733
+ }
1734
+ else {
1735
+ const chunkRow = tileRow * rowScaleFactor;
1736
+ const chunkCol = tileCol * scaleFactor;
1737
+ switch (resampleMethod) {
1738
+ case 'nearest': {
1739
+ const valueRow = Math.round(chunkRow);
1740
+ const valueCol = Math.round(chunkCol);
1741
+ if (valueRow < chunkRowCount && valueCol < chunkColCount) {
1742
+ inBounds = true;
1743
+ value = chunkData[valueRow * chunkColCount + valueCol];
1744
+ }
1745
+ break;
1746
+ }
1747
+ case 'linear': {
1748
+ const row0 = Math.floor(chunkRow);
1749
+ const col0 = Math.floor(chunkCol);
1750
+ if (row0 < chunkRowCount && col0 < chunkColCount) {
1751
+ inBounds = true;
1752
+ const row1 = Math.min(row0 + 1, chunkRowCount - 1);
1753
+ const col1 = Math.min(col0 + 1, chunkColCount - 1);
1754
+ const v00 = chunkData[row0 * chunkColCount + col0];
1755
+ const v01 = chunkData[row0 * chunkColCount + col1];
1756
+ const v10 = chunkData[row1 * chunkColCount + col0];
1757
+ const v11 = chunkData[row1 * chunkColCount + col1];
1758
+ const dx = chunkCol - col0;
1759
+ const dy = chunkRow - row0;
1760
+ value =
1761
+ (1 - dy) * ((1 - dx) * v00 + dx * v01) +
1762
+ dy * ((1 - dx) * v10 + dx * v11);
1763
+ }
1764
+ break;
1765
+ }
1766
+ default: {
1767
+ throw new Error(`Unsupported resample method: ${resampleMethod}`);
1768
+ }
1769
+ }
1770
+ }
1771
+ if (inBounds && !isNoDataValue(value)) {
1772
+ hasData = true;
1773
+ }
1774
+ if (isNaN(value)) {
1775
+ value = 0;
1776
+ }
1777
+ tileData[bandCount * (tileRow * tileColCount + tileCol) + chunkIndex] =
1778
+ value;
1779
+ }
1780
+ if (addAlpha) {
1781
+ tileData[bandCount * (tileRow * tileColCount + tileCol) + chunkCount] =
1782
+ hasData ? 1 : 0;
1783
+ }
1784
+ }
1785
+ }
1786
+ return tileData;
1787
+ }
1788
+ /**
1789
+ * @param {DatasetAttributes} attributes Attriutes.
1790
+ * @return {import("ol/proj/Projection.js").default} The projection.
1791
+ */
1792
+ function getProjectionFromAttributes(attributes) {
1793
+ const projCode = attributes['proj:code'];
1794
+ if (projCode) {
1795
+ return /** @type {import("ol/proj/Projection.js").default} */ (getProjection(projCode));
1796
+ }
1797
+ const projDef = attributes['proj:projjson'] || attributes['proj:wkt2'];
1798
+ return /** @type {import("ol/proj/Projection.js").default} */ (fromProjectionDefinition(projDef));
1799
+ }
1800
+ /**
1801
+ * Maximum number of requests to keep in the per-source chunk cache.
1802
+ * @type {number}
1803
+ */
1804
+ const MAX_CACHED_CHUNKS = 32;
1805
+ /**
1806
+ * Wrap a store with a small LRU cache for chunk requests. Adjacent tiles
1807
+ * often read from the same chunk (in the extreme case, a store with a single
1808
+ * chunk per array); without this cache each tile would fetch the chunk
1809
+ * again, as zarrita does not cache requests.
1810
+ * @param {import('zarrita').FetchStore|import('zarrita').Readable} store The store to wrap.
1811
+ * @return {import('zarrita').Readable} A store-compatible object.
1812
+ */
1813
+ function withChunkCache(store) {
1814
+ var _a;
1815
+ /** @type {Map<string, Promise<Uint8Array|undefined>>} */
1816
+ const chunkCache = new Map();
1817
+ return /** @type {import('zarrita').Readable} */ ({
1818
+ async get(
1819
+ /** @type {string} */ key,
1820
+ /** @type {import('zarrita').GetOptions|undefined} */ opts) {
1821
+ if (chunkCache.has(key)) {
1822
+ // Re-insert to mark the entry as most recently used
1823
+ const cached = /** @type {Promise<Uint8Array|undefined>} */ (chunkCache.get(key));
1824
+ chunkCache.delete(key);
1825
+ chunkCache.set(key, cached);
1826
+ return cached;
1827
+ }
1828
+ const promise = /** @type {Promise<Uint8Array|undefined>} */ (store.get(/** @type {`/${string}`} */ (key), opts));
1829
+ chunkCache.set(key, promise);
1830
+ promise.catch(() => chunkCache.delete(key));
1831
+ if (chunkCache.size > MAX_CACHED_CHUNKS) {
1832
+ chunkCache.delete(
1833
+ /** @type {string} */ (chunkCache.keys().next().value));
1834
+ }
1835
+ return promise;
1836
+ },
1837
+ getRange: /** @type {*} */ (_a = (store).getRange) === null || _a === void 0 ? void 0 : _a.bind(store),
1838
+ });
1839
+ }
1840
+ /**
1841
+ * Like {@link createCachedStore}, for Zarr v2 consolidated metadata: the
1842
+ * .zmetadata entries are served under their raw keys (e.g. `path/.zarray`).
1843
+ * @param {import('zarrita').FetchStore} store The underlying store.
1844
+ * @param {Object<string, *>} v2Metadata The .zmetadata `metadata` entries.
1845
+ * @return {import('zarrita').Readable} A store-compatible object.
1846
+ */
1847
+ function createCachedStoreV2(store, v2Metadata) {
1848
+ var _a;
1849
+ const cache = new Map();
1850
+ const encoder = new TextEncoder();
1851
+ for (const [key, value] of Object.entries(v2Metadata)) {
1852
+ cache.set(`/${key}`, encoder.encode(JSON.stringify(value)));
1853
+ }
1854
+ return withChunkCache({
1855
+ async get(
1856
+ /** @type {string} */ key,
1857
+ /** @type {import('zarrita').GetOptions|undefined} */ opts) {
1858
+ if (cache.has(key)) {
1859
+ return cache.get(key);
1860
+ }
1861
+ return store.get(/** @type {`/${string}`} */ (key), opts);
1862
+ },
1863
+ getRange: (_a = store.getRange) === null || _a === void 0 ? void 0 : _a.bind(store),
1864
+ });
1865
+ }
1866
+ /**
1867
+ * Maximum tile size when tiles are aligned to (unsharded) chunks. Larger
1868
+ * than MAX_TILE_SIZE because decoding one big chunk into a single tile is
1869
+ * cheaper than decoding it once per covering tile.
1870
+ * @type {number}
1871
+ */
1872
+ const MAX_CHUNK_TILE_SIZE = 2048;
1873
+ /**
1874
+ * Compute a tile size for an unsharded array along one dimension: a multiple
1875
+ * of the chunk size when chunks are small, or (a cap of) the chunk size
1876
+ * itself when chunks are large.
1877
+ * @param {number} chunkSize The chunk size in pixels along one dimension.
1878
+ * @param {number} arraySize The array size in pixels along the same dimension.
1879
+ * @return {number} The tile size.
1880
+ */
1881
+ function getTileSizeForChunk(chunkSize, arraySize) {
1882
+ let size;
1883
+ if (chunkSize >= MAX_TILE_SIZE) {
1884
+ size = Math.min(chunkSize, MAX_CHUNK_TILE_SIZE);
1885
+ }
1886
+ else {
1887
+ size = Math.floor(MAX_TILE_SIZE / chunkSize) * chunkSize;
1888
+ }
1889
+ return Math.max(MIN_TILE_SIZE, Math.min(size, arraySize));
1890
+ }
1891
+ /**
1892
+ * Parse a Zarr fill value (which may be encoded as a string like "NaN").
1893
+ * @param {number|string|null|undefined} value The raw fill value.
1894
+ * @return {number|undefined} The fill value as a number, or undefined.
1895
+ */
1896
+ function parseFillValue(value) {
1897
+ if (typeof value === 'number') {
1898
+ return value;
1899
+ }
1900
+ if (typeof value === 'string') {
1901
+ return Number(value);
1902
+ }
1903
+ return undefined;
1904
+ }
1905
+ /**
1906
+ * Normalize Zarr v2 consolidated metadata (.zmetadata entries like
1907
+ * `path/.zarray` and `path/.zattrs`) into the Zarr v3 shape that this
1908
+ * source reads.
1909
+ * @param {Object<string, *>} v2Metadata The .zmetadata `metadata` entries.
1910
+ * @return {Object<string, *>} Array metadata by path.
1911
+ */
1912
+ function normalizeV2Metadata(v2Metadata) {
1913
+ /** @type {Object<string, *>} */
1914
+ const normalized = {};
1915
+ for (const [key, value] of Object.entries(v2Metadata)) {
1916
+ if (!key.endsWith('/.zarray')) {
1917
+ continue;
1918
+ }
1919
+ const path = key.slice(0, -'/.zarray'.length);
1920
+ const attributes = v2Metadata[`${path}/.zattrs`] || {};
1921
+ normalized[path] = {
1922
+ shape: value['shape'],
1923
+ fill_value: value['fill_value'],
1924
+ dimension_names: attributes['_ARRAY_DIMENSIONS'],
1925
+ chunk_grid: {
1926
+ name: 'regular',
1927
+ configuration: { chunk_shape: value['chunks'] },
1928
+ },
1929
+ attributes,
1930
+ };
1931
+ }
1932
+ return normalized;
1933
+ }
1934
+ /**
1935
+ * Probe the store for an optional metadata document. Absence is an expected
1936
+ * outcome (version probing), and some servers answer 403 instead of 404 for
1937
+ * missing keys, which zarrita treats as an error.
1938
+ * @param {FetchStore} store The store.
1939
+ * @param {string} key The key to probe.
1940
+ * @return {Promise<Uint8Array|undefined>} The document, if present.
1941
+ */
1942
+ function probe(store, key) {
1943
+ return store.get(/** @type {`/${string}`} */ (key)).catch(() => undefined);
1944
+ }
1945
+ /**
1946
+ * Read a single array's metadata directly from the store, for stores
1947
+ * without consolidated metadata (Zarr v3 zarr.json or v2 .zarray/.zattrs).
1948
+ * @param {FetchStore} store The store.
1949
+ * @param {string} path The array path.
1950
+ * @return {Promise<Object|undefined>} The array metadata (v3 shape).
1951
+ */
1952
+ async function getArrayMeta(store, path) {
1953
+ const decoder = new TextDecoder();
1954
+ let bytes = await probe(store, `/${path}/zarr.json`);
1955
+ if (bytes) {
1956
+ return JSON.parse(decoder.decode(bytes));
1957
+ }
1958
+ bytes = await probe(store, `/${path}/.zarray`);
1959
+ if (bytes) {
1960
+ const zarray = JSON.parse(decoder.decode(bytes));
1961
+ const attrBytes = await probe(store, `/${path}/.zattrs`);
1962
+ const attributes = attrBytes ? JSON.parse(decoder.decode(attrBytes)) : {};
1963
+ return {
1964
+ shape: zarray['shape'],
1965
+ fill_value: zarray['fill_value'],
1966
+ dimension_names: attributes['_ARRAY_DIMENSIONS'],
1967
+ chunk_grid: {
1968
+ name: 'regular',
1969
+ configuration: { chunk_shape: zarray['chunks'] },
1970
+ },
1971
+ attributes,
1972
+ };
1973
+ }
1974
+ return undefined;
1975
+ }
1976
+ /**
1977
+ * Flip the row order of a 2-dimensional chunk (for south-up data).
1978
+ * @param {{data: any, shape: Array<number>, stride: Array<number>}} chunk The chunk.
1979
+ * @return {{data: any, shape: Array<number>, stride: Array<number>}} The flipped chunk.
1980
+ */
1981
+ function flipChunkRows(chunk) {
1982
+ const [rowCount, colCount] = chunk.shape;
1983
+ const source = chunk.data;
1984
+ const data = new source.constructor(source.length);
1985
+ for (let row = 0; row < rowCount; ++row) {
1986
+ data.set(source.subarray((rowCount - 1 - row) * colCount, (rowCount - row) * colCount), row * colCount);
1987
+ }
1988
+ return { data, shape: chunk.shape, stride: [colCount, 1] };
1989
+ }
1990
+ //# sourceMappingURL=GeoZarr.js.map