ol
Version:
OpenLayers mapping library
1,302 lines (1,213 loc) • 47.1 kB
JavaScript
/**
* @module ol/source/GeoZarr
*/
import {FetchStore, get, open, slice, withRangeCoalescing} from 'zarrita';
import {warn} from '../console.js';
import {getCenter} from '../extent.js';
import {get as getProjection, toUserCoordinate, toUserExtent} from '../proj.js';
import {fromProjectionDefinition} from '../proj/proj4.js';
import {toSize} from '../size.js';
import WMTSTileGrid from '../tilegrid/WMTS.js';
import {getUid} from '../util.js';
import DataTileSource from './DataTile.js';
import {parseTileMatrixSet} from './ogcTileUtil.js';
const REQUIRED_ZARR_CONVENTIONS = [
'd35379db-88df-4056-af3a-620245f8e347', // multiscales
'f17cb550-5864-4468-aeb7-f3180cfb622f', // proj:
'689b58e2-cf7b-45e0-9fff-9cfc0883d6b4', // spatial:
];
/**
* @typedef {'nearest'|'linear'} ResampleMethod
*/
/**
* @typedef {Object} Band
* @property {string} name The band name.
* @property {string} group The group path relative to the `url`, containing this band
* (e.g. `'measurements/reflectance'`).
*/
/**
* @typedef {Object} Options
* @property {string} url When `bands` contains plain strings, this must be the full URL to the
* multiscales group (e.g. `'https://example.com/store.zarr/measurements/reflectance'`).
* When `bands` contains {@link Band} objects, this is the base URL from which each band's
* `group` path is resolved (e.g. `'https://example.com/store.zarr/satellite/sentinel2'`).
* @property {Array<string|Band>} bands The bands to render. Each entry is either a band name
* string (single-group mode) or a {@link Band} object specifying both the band name and the
* group it belongs to (multi-group mode). In multi-group mode, the first band's group
* determines the tile grid and must follow at least the proj: and spatial: conventions.
* If it also has a multiscales layout (all three conventions), multiple resolution levels are
* supported. Otherwise a single-resolution tile grid is derived from `spatial:bbox`,
* `proj:code`, and `spatial:shape` (or the array shape from consolidated metadata).
* Bands from additional groups do not need to follow any convention; they can be multi-scale
* (array located at `<matrixId>/<bandName>`) or single-scale (array at the group root).
* @property {import("../proj.js").ProjectionLike} [projection] Source projection. If not provided, the GeoZarr metadata
* will be read for projection information.
* @property {number} [transition=250] Duration of the opacity transition for rendering.
* To disable the opacity transition, pass `transition: 0`.
* @property {boolean} [wrapX=false] Render tiles beyond the tile grid extent.
* @property {ResampleMethod} [resample='nearest'] Resampling method if bands are not available for all multi-scale levels.
* @property {Object<string, number|string>} [dimensions] Fixed index for each non-spatial
* dimension of the band arrays, keyed by dimension name (e.g. `{time: 0}` for the first time step
* of a `[time, y, x]` cube); unspecified dimensions default to `0`. Names come from each array's
* `dimension_names`, or are the axis position as a string when it has none. Only integer indices
* are supported. Use the names from {@link getDimensions}, and change the selection on the fly with
* {@link module:ol/source/GeoZarr~GeoZarr#updateDimensions}.
*/
/**
* Source for GeoZarr stores conforming to the following conventions:
* - [Zarr multiscales convention](https://github.com/zarr-conventions/multiscales)
* - [Geospatial projection convention](https://github.com/zarr-conventions/geo-proj)
* - [Spatial convention](https://github.com/zarr-conventions/spatial)
*
* When all three conventions are present, multiple resolution levels are supported.
* When only proj: and spatial: are present, a single-resolution tile grid is derived
* from `spatial:bbox`, `proj:code`, and `spatial:shape`.
* The legacy `tile_matrix_set` attribute is also supported.
*/
export default class GeoZarr extends DataTileSource {
/**
* @param {Options} options The options.
*/
constructor(options) {
super({
state: 'loading',
tileGrid: null,
projection: options.projection || null,
transition: options.transition,
wrapX: options.wrapX,
hasAlpha: false,
});
/**
* @type {string}
* @private
*/
this.url_ = options.url;
/**
* Fixed index per non-spatial dimension name, from the `dimensions` option.
* @type {Object<string, number|string>}
* @private
*/
this.dimensions_ = options.dimensions || {};
/**
* @type {Error|null}
*/
this.error_ = null;
/**
* @type {Array<import('zarrita').Group<any>>}
* @private
*/
this.groups_ = [];
/**
* @type {any|null}
* @private
*/
this.consolidatedMetadata_ = null;
/**
* Cache of opened zarrita arrays keyed by path. Caching the Promise
* (not the resolved value) deduplicates concurrent opens for the same
* array path across tiles at the same zoom level.
* @private
* @type {Map<string, Promise<import('zarrita').Array<import('zarrita').DataType, any>>>}
*/
this.arrayCache_ = new Map();
const groupOrder = /** @type {Array<string>} */ ([]);
const bandGroupIndex = /** @type {Array<number>} */ ([]);
const bands = options.bands.map((b) => {
if (typeof b === 'string') {
bandGroupIndex.push(0);
return b;
}
let gi = groupOrder.indexOf(b.group);
if (gi === -1) {
gi = groupOrder.length;
groupOrder.push(b.group);
}
bandGroupIndex.push(gi);
return b.name;
});
/**
* @type {Array<string>|undefined}
* @private
*/
this.groupPaths_ = groupOrder.length > 0 ? groupOrder : undefined;
/**
* Maps each band index to the index of the group it belongs to in `this.groups_`.
* @type {Array<number>}
* @private
*/
this.bandGroupIndex_ = bandGroupIndex;
/**
* Pixel resolution for single-scale bands. When set, indicates that the
* band lives directly at its group root (no matrixId subdirectory) and
* provides the pixel resolution to use for coordinate calculations.
* Undefined for multi-scale bands.
* @type {Array<number|undefined>}
* @private
*/
this.bandSingleScaleResolution_ = new Array(bands.length).fill(undefined);
/**
* @type {Array<string>}
* @private
*/
this.bands_ = bands;
/**
* Per-band selection along non-spatial dimensions: `undefined` for 2-D
* arrays, otherwise an array aligned to the array rank with a fixed integer
* at each extra axis and `null` at the two spatial axes (e.g. `[2, null,
* null]` for a `[time, y, x]` array with `time: 2`).
* @type {Array<Array<number|null>|undefined>}
* @private
*/
this.bandExtraSelection_ = new Array(bands.length).fill(undefined);
/**
* Per-band spatial (y, x) axis positions, as `{row, col}`.
* @type {Array<{row: number, col: number}>}
* @private
*/
this.bandSpatialAxes_ = new Array(bands.length);
/**
* The two spatial axis names from the group's `spatial:dimensions` (`[y, x]`).
* @type {Array<string>|undefined}
* @private
*/
this.spatialDimensionNames_;
/**
* Non-spatial dimensions of the bands, exposed via {@link getDimensions}.
* @type {Array<{name: string, size: number}>}
* @private
*/
this.extraDimensions_ = [];
/**
* @type {Object<string, Array<string>> | null}
* @private
*/
this.bandsByLevel_ = null;
/**
* @type {number|undefined}
* @private
*/
this.fillValue_;
/**
* @type {ResampleMethod}
* @private
*/
this.resampleMethod_ = options.resample || 'linear';
/**
* Number of bands.
* @type {number}
*/
this.bandCount = this.bands_.length;
/**
* @type {import("../tilegrid/WMTS.js").default}
* @override
*/
this.tileGrid;
this.setLoader(this.loadTile_.bind(this));
this.configure_()
.then(() => {
this.setState('ready');
})
.catch((err) => {
this.error_ = err;
this.setState('error');
});
}
async configure_() {
const store = /** @type {FetchStore} */ (
withRangeCoalescing(new FetchStore(this.url_))
);
// Fetch group zarr.json once for both opening the group and extracting
// consolidated metadata. Without this, open() and the manual metadata
// read would each make a separate HTTP request for the same file.
const groupBytes = await store.get('/zarr.json');
if (groupBytes) {
try {
this.consolidatedMetadata_ = JSON.parse(
new TextDecoder().decode(groupBytes),
).consolidated_metadata.metadata;
} catch {
// no consolidated metadata
}
}
// Wrap the store so that child metadata (groups, arrays) is served from
// the consolidated metadata instead of making per-child HTTP requests.
const cachedStore = this.consolidatedMetadata_
? createCachedStore(store, groupBytes, this.consolidatedMetadata_)
: store;
const groupPromises = [];
if (this.groupPaths_) {
// Multi-group mode: open root, then each sub-group
const rootGroup = await open(cachedStore, {kind: 'group'});
for (const groupPath of this.groupPaths_) {
groupPromises.push(open(rootGroup.resolve(groupPath), {kind: 'group'}));
}
} else {
// Single group mode
groupPromises.push(open(cachedStore, {kind: 'group'}));
}
this.groups_.push(...(await Promise.all(groupPromises)));
const attributes =
/** @type {LegacyDatasetAttributes | DatasetAttributes} */ (
this.groups_[0].attrs
);
// The spatial: convention names the two spatial axes (`[y, x]`), so they can
// be located by name in each array's `dimension_names`.
const spatialDimensions = attributes['spatial:dimensions'];
if (Array.isArray(spatialDimensions) && spatialDimensions.length === 2) {
this.spatialDimensionNames_ = spatialDimensions;
}
// For multi-group mode, use sub-metadata for the first group so that
// consolidated metadata keys match the expected relative paths.
const consolidatedMetadata =
this.groupPaths_ && this.consolidatedMetadata_
? getSubMetadata(this.consolidatedMetadata_, this.groupPaths_[0])
: this.consolidatedMetadata_;
let hasTileSizes = false;
if (
'zarr_conventions' in attributes &&
Array.isArray(attributes.zarr_conventions) &&
REQUIRED_ZARR_CONVENTIONS.every((uuid) =>
attributes.zarr_conventions.find((c) => c.uuid === uuid),
) &&
'layout' in attributes.multiscales
) {
const {tileGrid, projection, bandsByLevel, fillValue, tileSizes} =
getTileGridInfoFromAttributes(
/** @type {DatasetAttributes} */ (attributes),
consolidatedMetadata,
this.bands_,
);
this.bandsByLevel_ = bandsByLevel;
this.tileGrid = tileGrid;
this.projection = projection;
this.fillValue_ = fillValue;
hasTileSizes = !!tileSizes;
}
if (
!hasTileSizes &&
attributes.multiscales &&
'tile_matrix_set' in attributes.multiscales
) {
// If available, use tile_matrix_set (legacy attributes) to get a tile grid, because it
// should provide a better mapping of tiles to zarr chunks.
const {tileGrid, projection} = getTileGridInfoFromLegacyAttributes(
/** @type {LegacyDatasetAttributes} */ (attributes),
);
this.tileGrid = tileGrid;
if (!this.projection) {
// If there were no required zarr conventions, we don't have a projection yet
this.projection = projection;
}
}
if (!this.tileGrid && 'spatial:bbox' in attributes) {
// Standalone single-scale group: build tile grid directly from
// spatial:bbox and spatial:shape (`[height, width]`), or the x axis size
// from the array metadata.
let xSize = attributes['spatial:shape']?.[1];
if (xSize === undefined && consolidatedMetadata) {
for (const band of this.bands_) {
const bandMeta = consolidatedMetadata[band];
if (bandMeta?.shape) {
xSize = bandMeta.shape[this.axesOf_(bandMeta).col];
break;
}
}
}
if (xSize !== undefined) {
const extent = attributes['spatial:bbox'];
const resolution = (extent[2] - extent[0]) / xSize;
if (!this.projection) {
this.projection = getProjectionFromAttributes(attributes);
}
if (consolidatedMetadata) {
this.bandsByLevel_ = {level0: []};
for (const band of this.bands_) {
if (consolidatedMetadata[band]) {
this.bandsByLevel_['level0'].push(band);
if (this.fillValue_ === undefined) {
this.fillValue_ = Number(
consolidatedMetadata[band]['fill_value'],
);
}
}
}
}
this.tileGrid = new WMTSTileGrid({
extent: extent,
origins: [[extent[0], extent[3]]],
resolutions: [resolution],
matrixIds: ['level0'],
});
for (let i = 0; i < this.bands_.length; ++i) {
if (this.bandGroupIndex_[i] === 0) {
this.bandSingleScaleResolution_[i] = resolution;
}
}
}
}
// For multi-group: determine which group owns each band and supplement
// bandsByLevel with bands from additional groups.
if (this.groupPaths_ && this.consolidatedMetadata_ && this.bandsByLevel_) {
this.resolveBandOwnership_();
}
if (this.fillValue_ !== null && this.fillValue_ !== undefined) {
this.bandCount = this.bands_.length + 1;
this.nodataBandIndex = this.bandCount;
this.hasAlpha = true;
}
if (!this.tileGrid) {
throw new Error('Could not determine tile grid');
}
// Resolve, per band, the spatial axes and the fixed indices for any
// non-spatial dimensions, and record the selectable dimensions.
for (let i = 0, ii = this.bands_.length; i < ii; ++i) {
const arrayMeta = this.getBandArrayMeta_(
this.bands_[i],
this.bandGroupIndex_[i],
);
const {row, col} = this.axesOf_(arrayMeta);
this.bandSpatialAxes_[i] = {row, col};
this.bandExtraSelection_[i] = this.resolveExtraSelection_(arrayMeta);
if (this.extraDimensions_.length === 0) {
this.extraDimensions_ = this.extraDimsOf_(arrayMeta);
}
}
const extent = this.tileGrid.getExtent();
setTimeout(() => {
this.viewResolver({
showFullExtent: true,
projection: this.projection,
resolutions: this.tileGrid.getResolutions(),
center: toUserCoordinate(getCenter(extent), this.projection),
extent: toUserExtent(extent, this.projection),
zoom: 1,
});
});
}
/**
* @param {number} z The z tile index.
* @param {number} x The x tile index.
* @param {number} y The y tile index.
* @param {import('./DataTile.js').LoaderOptions} options The loader options.
* @return {Promise} The composed tile data.
* @private
*/
async loadTile_(z, x, y, options) {
const resolutions = this.tileGrid.getResolutions();
const tileResolution = this.tileGrid.getResolution(z);
const tileExtent = this.tileGrid.getTileCoordExtent([z, x, y]);
// First pass: resolve band metadata (no async)
const bandInfos = [];
for (let i = 0, ii = this.bands_.length; i < ii; ++i) {
const band = this.bands_[i];
const groupIndex = this.bandGroupIndex_[i];
let bandMatrixId;
let bandResolution;
let bandZ = 0;
if (!this.bandsByLevel_) {
// TODO: remove this if we stop supporting legacy attributes
bandMatrixId = this.tileGrid.getMatrixId(z);
bandResolution = tileResolution;
bandZ = z;
} else {
for (
let candidateZ = 0;
candidateZ < resolutions.length;
candidateZ += 1
) {
const candidateResolution = resolutions[candidateZ];
if (bandMatrixId && candidateResolution < tileResolution) {
break;
}
const candidateMatrixId = this.tileGrid.getMatrixId(candidateZ);
if (this.bandsByLevel_[candidateMatrixId].includes(band)) {
bandMatrixId = candidateMatrixId;
bandResolution = this.tileGrid.getResolution(candidateZ);
bandZ = candidateZ;
}
}
}
if (!bandMatrixId || !bandResolution) {
throw new Error(`Could not find available resolution for band ${band}`);
}
const isSingleScale = this.bandSingleScaleResolution_[i] !== undefined;
// For single-scale bands, use the band's own pixel resolution (derived
// from array shape or spatial metadata) rather than the tile grid level
// resolution, which may give wrong pixel coordinates.
if (isSingleScale) {
bandResolution = this.bandSingleScaleResolution_[i];
}
const origin = this.tileGrid.getOrigin(bandZ);
const minCol = Math.round((tileExtent[0] - origin[0]) / bandResolution);
const maxCol = Math.round((tileExtent[2] - origin[0]) / bandResolution);
const minRow = Math.round((origin[1] - tileExtent[3]) / bandResolution);
const maxRow = Math.round((origin[1] - tileExtent[1]) / bandResolution);
bandInfos.push({
path: isSingleScale ? band : `${bandMatrixId}/${band}`,
groupIndex,
minRow,
maxRow,
minCol,
maxCol,
bandResolution,
});
}
// Open all band arrays in parallel (not sequentially)
const arrays = await Promise.all(
bandInfos.map((info) => this.openArray_(info.groupIndex, info.path)),
);
// Fire all get() calls synchronously so getRange() calls from all bands
// land in the same macrotask tick and can be batched together.
const bandResolutions = bandInfos.map((info) => info.bandResolution);
const bandChunks = await Promise.all(
arrays.map((array, i) => {
const info = bandInfos[i];
const rowSlice = slice(info.minRow, info.maxRow);
const colSlice = slice(info.minCol, info.maxCol);
const extra = this.bandExtraSelection_[i];
if (!extra) {
return get(array, [rowSlice, colSlice]);
}
// Drop the row/column slices in at the spatial axes; zarrita drops the
// integer axes, returning a 2-D chunk that composeData consumes unchanged.
const {row, col} = this.bandSpatialAxes_[i];
/** @type {Array<number|null|ReturnType<typeof slice>>} */
const selection = extra.slice();
selection[row] = rowSlice;
selection[col] = colSlice;
return get(array, selection);
}),
);
const [tileColCount, tileRowCount] = toSize(this.tileGrid.getTileSize(z));
return composeData(
bandChunks,
bandResolutions,
tileColCount,
tileRowCount,
tileResolution,
this.resampleMethod_,
this.fillValue_,
);
}
/**
* For multi-group mode: determine which group owns each band and supplement
* bandsByLevel with bands from additional groups.
* @private
*/
resolveBandOwnership_() {
const subMetadatas = this.groupPaths_.map((gp) =>
getSubMetadata(this.consolidatedMetadata_, gp),
);
for (let i = 0, ii = this.bands_.length; i < ii; ++i) {
const band = this.bands_[i];
const g = this.bandGroupIndex_[i];
if (g === 0) {
continue; // primary group bands are already in bandsByLevel_
}
let foundAtAnyLevel = false;
for (const matrixId of Object.keys(this.bandsByLevel_)) {
const bandMeta = subMetadatas[g][`${matrixId}/${band}`];
if (bandMeta) {
foundAtAnyLevel = true;
if (!this.bandsByLevel_[matrixId].includes(band)) {
this.bandsByLevel_[matrixId].push(band);
}
if (this.fillValue_ === undefined) {
this.fillValue_ = Number(bandMeta['fill_value']);
}
}
}
if (!foundAtAnyLevel) {
// Try single-scale: band lives directly at the group root (no matrixId prefix).
const bandMeta = subMetadatas[g][band];
if (bandMeta) {
for (const matrixId of Object.keys(this.bandsByLevel_)) {
if (!this.bandsByLevel_[matrixId].includes(band)) {
this.bandsByLevel_[matrixId].push(band);
}
}
if (this.fillValue_ === undefined) {
this.fillValue_ = Number(bandMeta['fill_value']);
}
// Derive the band's pixel resolution from its array shape so loadTile_
// uses correct coordinates regardless of the tile grid zoom level.
const shape = bandMeta['shape'];
const xSize = shape && shape[this.axesOf_(bandMeta).col];
if (xSize > 0) {
const extent = this.tileGrid.getExtent();
this.bandSingleScaleResolution_[i] =
(extent[2] - extent[0]) / xSize;
}
foundAtAnyLevel = true;
}
}
if (!foundAtAnyLevel) {
warn(
`Band "${band}" from group "${this.groupPaths_[g]}" is not available at any ` +
`resolution level compatible with the tile grid.`,
);
}
}
}
/**
* Open a Zarr array (path relative to its group) through the shared cache, so
* concurrent opens of the same array are deduplicated.
* @param {number} groupIndex The band's group index.
* @param {string} path The array path relative to the group.
* @return {Promise<import('zarrita').Array<import('zarrita').DataType, any>>} The array.
* @private
*/
openArray_(groupIndex, path) {
const cacheKey = `${groupIndex}:${path}`;
let array = this.arrayCache_.get(cacheKey);
if (!array) {
array = open(this.groups_[groupIndex].resolve(path), {
kind: 'array',
}).catch((err) => {
this.arrayCache_.delete(cacheKey);
throw err;
});
this.arrayCache_.set(cacheKey, array);
}
return array;
}
/**
* Consolidated metadata for a group, with keys relative to that group.
* @param {number} groupIndex The group index.
* @return {Object} The group's consolidated metadata.
* @private
*/
groupMetadata_(groupIndex) {
return this.groupPaths_
? getSubMetadata(this.consolidatedMetadata_, this.groupPaths_[groupIndex])
: this.consolidatedMetadata_;
}
/**
* Look up a band's Zarr v3 array metadata from consolidated metadata, trying
* the multi-scale key (`<matrixId>/<band>`) first and falling back to a
* single-scale key (`<band>`).
* @param {string} band The band name.
* @param {number} groupIndex The index of the band's group.
* @return {Object|undefined} The array metadata, or undefined when unavailable.
* @private
*/
getBandArrayMeta_(band, groupIndex) {
if (!this.consolidatedMetadata_) {
return undefined;
}
const meta = this.groupMetadata_(groupIndex);
if (this.bandsByLevel_) {
for (const matrixId of Object.keys(this.bandsByLevel_)) {
if (
this.bandsByLevel_[matrixId].includes(band) &&
meta[`${matrixId}/${band}`]
) {
return meta[`${matrixId}/${band}`];
}
}
}
return meta[band];
}
/**
* Locate the 1-D coordinate array for a non-spatial dimension, by name among
* the group's 1-D arrays.
* @param {string} name The dimension name.
* @return {{path: string, groupIndex: number, meta: Object}|null} The path
* (relative to the group), group index, and array metadata; or `null`.
* @private
*/
coordinateArray_(name) {
if (!this.consolidatedMetadata_) {
return null;
}
const groupIndex = this.bandGroupIndex_[0];
const meta = this.groupMetadata_(groupIndex);
const suffix = `/${name}`;
for (const path of Object.keys(meta)) {
if (path === name || path.endsWith(suffix)) {
const arrayMeta = meta[path];
if (Array.isArray(arrayMeta?.shape) && arrayMeta.shape.length === 1) {
return {path, groupIndex, meta: arrayMeta};
}
}
}
return null;
}
/**
* Get the non-spatial dimensions of the bands (e.g. `time`) that can be fixed
* through the `dimensions` option, keyed by dimension name. Each entry has its
* `size` and the `attributes` of its coordinate array (e.g. `units`, for
* interpreting the values from {@link getValue}), or `attributes: null` when
* there is no coordinate array. Resolves with an empty object for 2-D bands,
* once the source is `ready`; rejects if the source fails to load.
* @return {Promise<Object<string, {size: number, attributes: Object|null}>>}
* The selectable dimensions.
*/
async getDimensions() {
await this.ready();
/** @type {Object<string, {size: number, attributes: Object|null}>} */
const dimensions = {};
for (const dimension of this.extraDimensions_) {
const coord = this.coordinateArray_(dimension.name);
dimensions[dimension.name] = {
size: dimension.size,
attributes: coord ? (coord.meta.attributes ?? null) : null,
};
}
return dimensions;
}
/**
* Read the coordinate value at an index along a non-spatial dimension (e.g.
* the timestamp for a `time` index), for labeling the current selection. The
* value is returned raw (as stored, e.g. a `bigint` for a 64-bit integer
* axis); use the `attributes` from {@link getDimensions} to interpret it.
* Returns `null` for a dimension without a coordinate array. Available once
* the source is `ready`.
* @param {string} name The dimension name (see {@link getDimensions}).
* @param {number} index The index along the dimension.
* @return {Promise<number|bigint|null>} The coordinate value, or null.
*/
async getValue(name, index) {
await this.ready();
const coord = this.coordinateArray_(name);
if (!coord) {
return null;
}
const size = coord.meta.shape[0];
if (index < 0 || index >= size) {
throw new Error(
`GeoZarr: index ${index} out of range for dimension "${name}" (size ${size}).`,
);
}
const array = await this.openArray_(coord.groupIndex, coord.path);
const chunk = await get(array, [slice(index, index + 1)]);
return chunk.data[0];
}
/**
* Change the fixed index of one or more non-spatial dimensions (e.g. move to
* another `time` slice) without rebuilding the source. Values are merged into
* the current selection, so a partial update like `{time: 3}` leaves the other
* dimensions untouched. Takes effect immediately when the source is `ready`,
* otherwise once it becomes ready.
* @param {Object<string, number|string>} dimensions Index per dimension name
* to change; see the `dimensions` constructor option.
*/
updateDimensions(dimensions) {
this.dimensions_ = {...this.dimensions_, ...dimensions};
if (this.getState() !== 'ready') {
// configure_ reads dimensions_ when it resolves; nothing to do yet.
return;
}
// Resolve every band before assigning, so an invalid index throws (via
// resolveExtraSelection_) without leaving a half-updated selection.
const selection = this.bands_.map((band, i) =>
this.resolveExtraSelection_(
this.getBandArrayMeta_(band, this.bandGroupIndex_[i]),
),
);
this.bandExtraSelection_ = selection;
// Bump the tile key to reload tiles. Deriving it from the selection (rather
// than a counter) keeps prior selections' tiles cached, so revisiting hits.
this.setKey(getUid(this) + ':' + JSON.stringify(this.dimensions_));
}
/**
* Locate the spatial (y, x) axes of an array (see {@link getSpatialAxes}) and
* its remaining non-spatial axes.
* @param {Object|undefined} arrayMeta Zarr v3 array metadata.
* @return {{row: number, col: number, extra: Array<number>}} The row (y) and
* column (x) axis positions and the remaining extra axes, in array order.
* @private
*/
axesOf_(arrayMeta) {
const {row, col} = getSpatialAxes(this.spatialDimensionNames_, arrayMeta);
const rank = ((arrayMeta && arrayMeta['shape']) || []).length;
const extra = [];
for (let axis = 0; axis < rank; ++axis) {
if (axis !== row && axis !== col) {
extra.push(axis);
}
}
return {row, col, extra};
}
/**
* Describe the non-spatial dimensions of an array. Each is named by its
* `dimension_names` entry, or by its axis position when there are none.
* @param {Object|undefined} arrayMeta Zarr v3 array metadata.
* @return {Array<{name: string, size: number, axis: number}>} The extra dimensions, outermost first.
* @private
*/
extraDimsOf_(arrayMeta) {
const shape = arrayMeta && arrayMeta['shape'];
if (!Array.isArray(shape) || shape.length <= 2) {
return [];
}
const dimensionNames = arrayMeta['dimension_names'];
const hasNames = Array.isArray(dimensionNames);
const dims = [];
for (const axis of this.axesOf_(arrayMeta).extra) {
const raw = hasNames ? dimensionNames[axis] : undefined;
const name =
raw === null || raw === undefined ? String(axis) : String(raw);
dims.push({name, size: shape[axis], axis});
}
return dims;
}
/**
* Resolve the fixed index for each non-spatial dimension of a band array from
* the `dimensions` option. Returns `undefined` for 2-D arrays, otherwise an
* array aligned to the array rank with a fixed integer at each extra axis and
* `null` at the two spatial axes (e.g. `[2, null, null]` for a `[time, y, x]`
* array with `{time: 2}`).
* @param {Object|undefined} arrayMeta Zarr v3 array metadata.
* @return {Array<number|null>|undefined} The extra-axis selection template.
* @private
*/
resolveExtraSelection_(arrayMeta) {
const dims = this.extraDimsOf_(arrayMeta);
if (dims.length === 0) {
return undefined;
}
const names = dims.map((d) => d.name);
const dimKeys = Object.keys(this.dimensions_);
// A single unnamed dimension is lenient: any single key binds to it.
const singleUnnamed =
dims.length === 1 && !Array.isArray(arrayMeta['dimension_names']);
// Fail loud on a key matching no dimension, rather than silently rendering
// the wrong slice.
for (const key of dimKeys) {
if (!names.includes(key) && !singleUnnamed) {
throw new Error(
`GeoZarr: unknown dimension "${key}" in the \`dimensions\` option; ` +
`available: [${names.join(', ')}].`,
);
}
}
const selection = new Array(arrayMeta['shape'].length).fill(null);
for (const dim of dims) {
const name = dim.name;
let index;
if (name in this.dimensions_) {
index = this.dimensions_[name];
} else if (singleUnnamed && dimKeys.length === 1) {
index = this.dimensions_[dimKeys[0]];
} else {
index = 0; // unspecified extra dimension defaults to the first slice
}
if (typeof index === 'string') {
// Datetime-label selection is not implemented yet; only integer indices.
throw new Error(
`GeoZarr: datetime-label selection for dimension "${name}" is not yet ` +
`implemented; pass an integer index in the \`dimensions\` option.`,
);
}
if (!Number.isInteger(index) || index < 0 || index >= dim.size) {
throw new Error(
`GeoZarr: invalid index ${index} for dimension "${name}" ` +
`(size ${dim.size}).`,
);
}
selection[dim.axis] = index;
}
return selection;
}
}
/**
* Extract a sub-view of consolidated metadata for a specific group path.
* Keys in the returned object are relative to the group path.
* @param {Object} rootMetadata The root consolidated metadata.
* @param {string} groupPath The group path (e.g. 'measurements/reflectance').
* @return {Object} Sub-metadata with paths relative to the group.
*/
function getSubMetadata(rootMetadata, groupPath) {
const prefix = groupPath + '/';
const sub = {};
for (const key of Object.keys(rootMetadata)) {
if (key.startsWith(prefix)) {
sub[key.substring(prefix.length)] = rootMetadata[key];
}
}
return sub;
}
/**
* Create a store wrapper that serves Zarr v3 metadata from consolidated
* metadata, avoiding per-child HTTP requests.
* @param {import('zarrita').FetchStore} store The underlying store.
* @param {Uint8Array} groupBytes The already-fetched group zarr.json bytes.
* @param {Object} consolidatedMetadata The parsed consolidated_metadata.metadata entries.
* @return {Object} A store-compatible object.
*/
function createCachedStore(store, groupBytes, consolidatedMetadata) {
const cache = new Map();
cache.set('/zarr.json', groupBytes);
const encoder = new TextEncoder();
for (const [key, value] of Object.entries(consolidatedMetadata)) {
cache.set(`/${key}/zarr.json`, encoder.encode(JSON.stringify(value)));
}
return {
async get(key, opts) {
if (cache.has(key)) {
return cache.get(key);
}
return store.get(key, opts);
},
getRange: store.getRange?.bind(store),
};
}
/***
* @typedef {{
* multiscales: Multiscales,
* zarr_conventions: Array<{uuid: string}>,
* 'spatial:bbox': import("../extent.js").Extent,
* 'spatial:shape': Array<number>,
* 'spatial:dimensions'?: Array<string>,
* 'proj:wkt2'?: string,
* 'proj:projjson'?: Object,
* 'proj:code'?: string | null,
* }} DatasetAttributes
*/
/**
* @typedef {Object} Multiscales
* @property {Object} layout The layout.
*/
/**
* @typedef {Object} LegacyDatasetAttributes
* @property {LegacyMultiscales} multiscales The multiscales attribute.
*/
/**
* @typedef {Object} LegacyMultiscales
* @property {any} tile_matrix_limits The tile matrix limits.
* @property {any} tile_matrix_set The tile matrix set.
*/
/**
* @typedef {Object} TileGridInfo
* @property {WMTSTileGrid} tileGrid The tile grid.
* @property {import("../proj/Projection.js").default} projection The projection.
* @property {Object<string, Array<string>>} [bandsByLevel] Available bands by level.
* @property {number} [fillValue] The fill value.
* @property {Array<import("../size.js").Size>|undefined} [tileSizes] The tile sizes for each level, if available.
*/
/**
* Maximum tile size for rendering.
* @type {number}
*/
const MAX_TILE_SIZE = 512;
/**
* Minimum tile size when sharding is used.
* @type {number}
*/
const MIN_TILE_SIZE = 64;
/**
* @typedef {Object} ShardInfo
* @property {Array<number>} shardShape The shard (outer chunk) shape [rows, cols].
* @property {Array<number>} innerChunkShape The inner chunk shape [rows, cols].
*/
/**
* Locate the row (y) and column (x) axis positions of an array by matching the
* group's `spatial:dimensions` names (`[y, x]`) against the array's
* `dimension_names`, falling back to the two trailing axes when either is absent.
* @param {Array<string>|undefined} spatialDimensionNames The `spatial:dimensions` value (`[y, x]` names).
* @param {Object|undefined} arrayMeta Zarr v3 array metadata.
* @return {{row: number, col: number}} The row (y) and column (x) axis positions.
*/
function getSpatialAxes(spatialDimensionNames, arrayMeta) {
const rank = ((arrayMeta && arrayMeta['shape']) || []).length;
const names = arrayMeta && arrayMeta['dimension_names'];
if (
Array.isArray(spatialDimensionNames) &&
spatialDimensionNames.length === 2 &&
Array.isArray(names)
) {
const row = names.indexOf(spatialDimensionNames[0]);
const col = names.indexOf(spatialDimensionNames[1]);
if (row !== -1 && col !== -1) {
return {row, col};
}
}
return {row: rank - 2, col: rank - 1};
}
/**
* FIXME Remove this when GeoZarr datasets provide correct TileMatrixSet info or similar.
*
* Get the shard and inner chunk shapes from the Zarr v3 array metadata.
* Only returns info when a `sharding_indexed` codec is present, meaning
* `chunk_grid.configuration.chunk_shape` represents the shard (outer chunk) size.
* @param {Object} arrayMeta The Zarr v3 array metadata from consolidated metadata.
* @param {number} row The row (y) axis position.
* @param {number} col The column (x) axis position.
* @return {ShardInfo|undefined} The shard info, or undefined.
*/
function getShardInfo(arrayMeta, row, col) {
const chunkGrid = arrayMeta['chunk_grid'];
if (!chunkGrid || chunkGrid['name'] !== 'regular') {
return undefined;
}
const codecs = arrayMeta['codecs'];
if (!Array.isArray(codecs)) {
return undefined;
}
const shardingCodec = codecs.find((c) => c['name'] === 'sharding_indexed');
if (!shardingCodec) {
return undefined;
}
// Keep only the two spatial axes; the chunk/shard shapes are N-D for N-D arrays.
const shardShape = chunkGrid['configuration']['chunk_shape'];
const innerChunkShape = shardingCodec['configuration']['chunk_shape'];
return {
shardShape: [shardShape[row], shardShape[col]],
innerChunkShape: [innerChunkShape[row], innerChunkShape[col]],
};
}
/**
* FIXME Remove this when GeoZarr datasets provide correct TileMatrixSet info or similar.
*
* Compute a tile size that is a multiple of the inner chunk size, evenly divides
* the shard size, is at most MAX_TILE_SIZE, and is at least MIN_TILE_SIZE.
* Aligning with inner chunk boundaries avoids fetching the same inner chunk
* data for adjacent tiles.
* @param {number} shardSize The shard size in pixels along one dimension.
* @param {number} innerChunkSize The inner chunk size in pixels along one dimension.
* @return {number} The tile size.
*/
function getTileSizeForShard(shardSize, innerChunkSize) {
// Find the largest multiple of innerChunkSize that divides shardSize
// and is within [MIN_TILE_SIZE, MAX_TILE_SIZE].
const maxChunks = Math.floor(MAX_TILE_SIZE / innerChunkSize);
for (let n = maxChunks; n >= 1; --n) {
const candidate = n * innerChunkSize;
if (candidate >= MIN_TILE_SIZE && shardSize % candidate === 0) {
return candidate;
}
}
// No ideal size found. Use shard size itself when it fits, otherwise
// use the largest chunk-aligned size that fits within MAX_TILE_SIZE.
if (shardSize <= MAX_TILE_SIZE && shardSize >= MIN_TILE_SIZE) {
return shardSize;
}
if (shardSize < MIN_TILE_SIZE) {
return MIN_TILE_SIZE;
}
return Math.max(maxChunks * innerChunkSize, MIN_TILE_SIZE);
}
/**
* @param {DatasetAttributes} attributes The dataset attributes.
* @param {any} consolidatedMetadata The consolidated metadata.
* @param {Array<string>} wantedBands The wanted bands.
* @return {TileGridInfo} The tile grid info.
*/
function getTileGridInfoFromAttributes(
attributes,
consolidatedMetadata,
wantedBands,
) {
const multiscales = attributes.multiscales;
const extent = attributes['spatial:bbox'];
const projection = getProjectionFromAttributes(attributes);
const extentWidth = extent[2] - extent[0];
const origin = [extent[0], extent[3]];
/** @type {Array<{matrixId: string, resolution: number, origin: import("../coordinate.js").Coordinate, tileSize: import("../size.js").Size|undefined}>} */
const groupInfo = [];
const bandsByLevel = consolidatedMetadata ? {} : null;
let fillValue;
for (const groupMetadata of multiscales.layout) {
const matrixId = groupMetadata.asset;
const resolution = extentWidth / groupMetadata['spatial:shape'][1];
/** @type {import("../size.js").Size|undefined} */
let tileSize;
if (consolidatedMetadata) {
const availableBands = [];
for (const band of wantedBands) {
const bandArray = consolidatedMetadata[`${matrixId}/${band}`];
if (bandArray) {
availableBands.push(band);
if (fillValue === undefined) {
fillValue = Number(bandArray['fill_value']);
}
//FIXME Remove this when GeoZarr datasets provide correct TileMatrixSet info or similar
if (!tileSize) {
const {row, col} = getSpatialAxes(
attributes['spatial:dimensions'],
bandArray,
);
const shardInfo = getShardInfo(bandArray, row, col);
if (shardInfo) {
tileSize = [
getTileSizeForShard(
shardInfo.shardShape[1],
shardInfo.innerChunkShape[1],
),
getTileSizeForShard(
shardInfo.shardShape[0],
shardInfo.innerChunkShape[0],
),
];
}
}
}
}
bandsByLevel[matrixId] = availableBands;
}
groupInfo.push({
matrixId,
resolution,
origin,
tileSize,
});
}
groupInfo.sort((a, b) => b.resolution - a.resolution);
const tileSizes = groupInfo.map((g) => g.tileSize);
const hasTileSizes = tileSizes.some((s) => s !== undefined);
const tileGrid = new WMTSTileGrid({
extent: extent,
origins: groupInfo.map((g) => g.origin),
resolutions: groupInfo.map((g) => g.resolution),
matrixIds: groupInfo.map((g) => g.matrixId),
...(hasTileSizes ? {tileSizes: tileSizes.map((s) => s || [256, 256])} : {}),
});
return {tileGrid, projection, bandsByLevel, fillValue, tileSizes};
}
/**
* @param {LegacyDatasetAttributes} attributes The dataset attributes.
* @return {TileGridInfo} The tile grid info.
*/
function getTileGridInfoFromLegacyAttributes(attributes) {
const multiscales = attributes.multiscales;
const tileMatrixSet = multiscales.tile_matrix_set;
const tileMatrixLimitsObject = multiscales.tile_matrix_limits;
const numMatrices = tileMatrixSet.tileMatrices.length;
const tileMatrixLimits = new Array(numMatrices);
let overrideTileSize = false;
for (let i = 0; i < numMatrices; i += 1) {
const tileMatrix = tileMatrixSet.tileMatrices[i];
const tilematrixId = tileMatrix.id;
if (tileMatrix.tileWidth > 512 || tileMatrix.tileHeight > 512) {
// Avoid tile sizes that are too large for rendering
overrideTileSize = true;
}
tileMatrixLimits[i] = tileMatrixLimitsObject[tilematrixId];
}
const info = parseTileMatrixSet(
{},
tileMatrixSet,
undefined,
tileMatrixLimits,
);
let tileGrid = info.grid;
// Tile size sanity
if (overrideTileSize) {
tileGrid = new WMTSTileGrid({
tileSize: 512,
extent: tileGrid.getExtent(),
origins: tileGrid.getOrigins(),
resolutions: tileGrid.getResolutions(),
matrixIds: tileGrid.getMatrixIds(),
});
}
return {tileGrid, projection: info.projection};
}
/**
* @param {Array<import("zarrita").Chunk<import("zarrita").DataType>>} chunks The input chunks.
* @param {Array<number>} chunkResolutions The resolutions for each band.
* @param {number} tileColCount The number of columns in the output data.
* @param {number} tileRowCount The number of rows in the output data.
* @param {number} tileResolution The tile resolution.
* @param {ResampleMethod} resampleMethod The resampling method.
* @param {number} fillValue The fill value.
* @return {Float32Array} The tile data.
*/
function composeData(
chunks,
chunkResolutions,
tileColCount,
tileRowCount,
tileResolution,
resampleMethod,
fillValue,
) {
const chunkCount = chunks.length;
const addAlpha = fillValue !== null && fillValue !== undefined;
const isNoDataValue = isNaN(fillValue)
? (v) => isNaN(v)
: (v) => v === fillValue;
const bandCount = chunkCount + (addAlpha ? 1 : 0);
const tileData = new Float32Array(tileColCount * tileRowCount * bandCount);
for (let tileRow = 0; tileRow < tileRowCount; tileRow++) {
for (let tileCol = 0; tileCol < tileColCount; tileCol++) {
let hasData = false;
for (let chunkIndex = 0; chunkIndex < chunkCount; ++chunkIndex) {
const chunk = chunks[chunkIndex];
const chunkRowCount = chunk.shape[0];
const chunkColCount = chunk.shape[1];
const scaleFactor = tileResolution / chunkResolutions[chunkIndex];
let value = 0;
let inBounds = false;
if (scaleFactor === 1) {
if (tileRow < chunkRowCount && tileCol < chunkColCount) {
inBounds = true;
value = chunk.data[tileRow * chunkColCount + tileCol];
}
} else {
const chunkRow = tileRow * scaleFactor;
const chunkCol = tileCol * scaleFactor;
switch (resampleMethod) {
case 'nearest': {
const valueRow = Math.round(chunkRow);
const valueCol = Math.round(chunkCol);
if (valueRow < chunkRowCount && valueCol < chunkColCount) {
inBounds = true;
value = chunk.data[valueRow * chunkColCount + valueCol];
}
break;
}
case 'linear': {
const row0 = Math.floor(chunkRow);
const col0 = Math.floor(chunkCol);
if (row0 < chunkRowCount && col0 < chunkColCount) {
inBounds = true;
const row1 = Math.min(row0 + 1, chunkRowCount - 1);
const col1 = Math.min(col0 + 1, chunkColCount - 1);
const v00 = chunk.data[row0 * chunkColCount + col0];
const v01 = chunk.data[row0 * chunkColCount + col1];
const v10 = chunk.data[row1 * chunkColCount + col0];
const v11 = chunk.data[row1 * chunkColCount + col1];
const dx = chunkCol - col0;
const dy = chunkRow - row0;
value =
(1 - dy) * ((1 - dx) * v00 + dx * v01) +
dy * ((1 - dx) * v10 + dx * v11);
}
break;
}
default: {
throw new Error(`Unsupported resample method: ${resampleMethod}`);
}
}
}
if (inBounds && !isNoDataValue(value)) {
hasData = true;
}
if (isNaN(value)) {
value = 0;
}
tileData[bandCount * (tileRow * tileColCount + tileCol) + chunkIndex] =
value;
}
if (addAlpha) {
tileData[bandCount * (tileRow * tileColCount + tileCol) + chunkCount] =
hasData ? 1 : 0;
}
}
}
return tileData;
}
/**
* @param {DatasetAttributes} attributes Attriutes.
* @return {import("../proj/Projection.js").default} The projection.
*/
function getProjectionFromAttributes(attributes) {
const projCode = attributes['proj:code'];
if (projCode) {
return getProjection(projCode);
}
const projDef = attributes['proj:projjson'] || attributes['proj:wkt2'];
return fromProjectionDefinition(projDef);
}