diff --git a/src/initialize-store.js b/src/initialize-store.js deleted file mode 100644 index c9d56c7..0000000 --- a/src/initialize-store.js +++ /dev/null @@ -1,121 +0,0 @@ -import zarr from 'zarr-js' - -import { getPyramidMetadata } from './utils' - -const initializeStore = async (source, version, variable, coordinateKeys) => { - let metadata - let loaders - let dimensions - let shape - let chunks - let fill_value - let dtype - let levels, maxZoom, tileSize, crs - const coordinates = {} - switch (version) { - case 'v2': - await new Promise((resolve) => - zarr(window.fetch, version).openGroup(source, (err, l, m) => { - loaders = l - metadata = m - resolve() - }) - ) - ;({ levels, maxZoom, tileSize, crs } = getPyramidMetadata( - metadata.metadata['.zattrs'].multiscales - )) - - const zattrs = metadata.metadata[`${levels[0]}/${variable}/.zattrs`] - const zarray = metadata.metadata[`${levels[0]}/${variable}/.zarray`] - dimensions = zattrs['_ARRAY_DIMENSIONS'] - shape = zarray.shape - chunks = zarray.chunks - fill_value = zarray.fill_value - dtype = zarray.dtype - - await Promise.all( - coordinateKeys.map( - (key) => - new Promise((resolve) => { - loaders[`${levels[0]}/${key}`]([0], (err, chunk) => { - coordinates[key] = Array.from(chunk.data) - resolve() - }) - }) - ) - ) - - break - case 'v3': - metadata = await fetch(`${source}/zarr.json`).then((res) => res.json()) - ;({ levels, maxZoom, tileSize, crs } = getPyramidMetadata( - metadata.attributes.multiscales - )) - - const arrayMetadata = await fetch( - `${source}/${levels[0]}/${variable}/zarr.json` - ).then((res) => res.json()) - - dimensions = arrayMetadata.attributes['_ARRAY_DIMENSIONS'] - shape = arrayMetadata.shape - const isSharded = arrayMetadata.codecs[0].name == 'sharding_indexed' - chunks = isSharded - ? arrayMetadata.codecs[0].configuration.chunk_shape - : arrayMetadata.chunk_grid.configuration.chunk_shape - fill_value = arrayMetadata.fill_value - // dtype = arrayMetadata.data_type - - loaders = {} - await Promise.all([ - ...levels.map( - (level) => - new Promise((resolve) => { - zarr(window.fetch, version).open( - `${source}/${level}/${variable}`, - (err, get) => { - loaders[`${level}/${variable}`] = get - resolve() - }, - level === 0 ? arrayMetadata : null - ) - }) - ), - ...coordinateKeys.map( - (key) => - new Promise((resolve) => { - zarr(window.fetch, version).open( - `${source}/${levels[0]}/${key}`, - (err, get) => { - get([0], (err, chunk) => { - coordinates[key] = Array.from(chunk.data) - resolve() - }) - } - ) - }) - ), - ]) - break - default: - throw new Error( - `Unexpected Zarr version: ${version}. Must be one of 'v1', 'v2'.` - ) - } - - return { - metadata, - loaders, - dimensions, - shape, - chunks, - fill_value, - dtype, - coordinates, - levels, - maxZoom, - tileSize, - crs, - } -} - -export default initializeStore diff --git a/src/raster.js b/src/raster.js index 0b5ef8e..28449cc 100644 --- a/src/raster.js +++ b/src/raster.js @@ -1,10 +1,11 @@ -import React, { useRef, useEffect, useState } from 'react' +import React, { useRef, useEffect, useState, useMemo } from 'react' import { useRegl } from './regl' import { useMap } from './map-provider' import { useControls } from './use-controls' import { createTiles } from './tiles' import { useRegion } from './region/context' import { useSetLoading } from './loading' +import ZarrStore from './zarr-store' const Raster = (props) => { const { @@ -32,6 +33,17 @@ const Raster = (props) => { camera.current = { center: center, zoom: zoom } + const store = useMemo( + () => + new ZarrStore({ + source: props.source, + version: props.version, + variable: props.variable, + coordinateKeys: Object.keys(selector), + }), + [props.source, props.version, props.variable] + ) + const queryRegion = async (r, s) => { const queryStart = new Date().getTime() lastQueried.current = queryStart @@ -46,11 +58,18 @@ const Raster = (props) => { } } + useEffect(() => { + return () => { + store.cleanup() + } + }, [store]) + useEffect(() => { tiles.current = createTiles(regl, { ...props, setLoading, clearLoading, + store, invalidate: () => { map.triggerRepaint() }, @@ -58,7 +77,7 @@ const Raster = (props) => { setRegionDataInvalidated(new Date().getTime()) }, }) - }, []) + }, [store]) useEffect(() => { if (props.setLoading) { diff --git a/src/tile.js b/src/tile.js index b5e3a39..3cf1cfc 100644 --- a/src/tile.js +++ b/src/tile.js @@ -46,25 +46,27 @@ class Tile { async loadChunks(chunks) { const updated = await Promise.all( - chunks.map( - (chunk) => - new Promise((resolve) => { - const key = chunk.join('.') - if (this.chunkedData[key]) { - resolve(false) - } else { - this._loading[key] = true - this._ready[key] = new Promise((innerResolve) => { - this._loader(chunk, (err, data) => { - this.chunkedData[key] = data - this._loading[key] = false - innerResolve(true) - resolve(true) - }) - }) - } - }) - ) + chunks.map((chunk) => { + const key = chunk.join('.') + if (this.chunkedData[key]) { + return false + } else { + this._loading[key] = true + this._ready[key] = this._loader(chunk) + .then((data) => { + this.chunkedData[key] = data + this._loading[key] = false + return true + }) + .catch((err) => { + console.error('Error loading chunk', key, err) + this._loading[key] = false + return false + }) + + return this._ready[key] + } + }) ) return updated.some(Boolean) diff --git a/src/tile.test.js b/src/tile.test.js index e1dcf82..89a2ab4 100644 --- a/src/tile.test.js +++ b/src/tile.test.js @@ -28,12 +28,9 @@ describe('Tile', () => { buffer = jest.fn() defaults = { key: '0,0,0', - loader: jest.fn().mockImplementation((chunk, cb) => - cb( - null, // error - createMockChunk(chunk) - ) - ), + loader: jest + .fn() + .mockImplementation((chunk) => Promise.resolve(createMockChunk(chunk))), shape: [10, 1, 1], chunks: [5, 1, 1], dimensions: ['year', 'y', 'x'], @@ -148,14 +145,8 @@ describe('Tile', () => { ]) expect(defaults.loader).toHaveBeenCalledTimes(2) - expect(defaults.loader).toHaveBeenCalledWith( - [0, 0, 0], - expect.anything() - ) - expect(defaults.loader).toHaveBeenCalledWith( - [1, 0, 0], - expect.anything() - ) + expect(defaults.loader).toHaveBeenCalledWith([0, 0, 0]) + expect(defaults.loader).toHaveBeenCalledWith([1, 0, 0]) }) it('does not repeat loading for any chunks have been loaded', async () => { @@ -267,15 +258,11 @@ describe('Tile', () => { beforeEach(() => { resolvers = [] - const loader = jest.fn().mockImplementation((chunk, cb) => - new Promise((resolve) => { - resolvers.push(resolve) - }).then(() => { - cb( - null, // error - createMockChunk(chunk) - ) - }) + const loader = jest.fn().mockImplementation( + (chunk) => + new Promise((resolve) => { + resolvers.push(() => resolve(createMockChunk(chunk))) + }) ) tile = new Tile({ ...defaults, loader }) }) @@ -441,12 +428,11 @@ describe('Tile', () => { const selector = {} const tile = new Tile({ ...defaults, - loader: jest.fn().mockImplementation((chunk, cb) => - cb( - null, // error - ndarray([1, 2, 3, 4], [4, 1, 1]) - ) - ), + loader: jest + .fn() + .mockImplementation((chunk) => + Promise.resolve(ndarray([1, 2, 3, 4], [4, 1, 1])) + ), shape: [4, 1, 1], chunks: [4, 1, 1], dimensions: ['band', 'y', 'x'], @@ -491,12 +477,11 @@ describe('Tile', () => { const selector = {} const tile = new Tile({ ...defaults, - loader: jest.fn().mockImplementation((chunk, cb) => - cb( - null, // error - ndarray([1, 2, 3, 4], [2, 2]) - ) - ), + loader: jest + .fn() + .mockImplementation((chunk) => + Promise.resolve(ndarray([1, 2, 3, 4], [2, 2])) + ), shape: [2, 2], chunks: [2, 2], dimensions: ['y', 'x'], diff --git a/src/tiles.js b/src/tiles.js index 206e9bd..b86fec4 100644 --- a/src/tiles.js +++ b/src/tiles.js @@ -22,13 +22,11 @@ import { } from './utils' import { DEFAULT_FILL_VALUES } from './constants' import Tile from './tile' -import initializeStore from './initialize-store' export const createTiles = (regl, opts) => { return new Tiles(opts) function Tiles({ - source, colormap, clim, opacity, @@ -45,12 +43,11 @@ export const createTiles = (regl, opts) => { invalidateRegion, setMetadata, order, - version = 'v2', projection, maxCachedTiles = 500, + store, }) { this.tiles = {} - this.loaders = {} this.active = {} this.display = display this.clim = clim @@ -111,81 +108,80 @@ export const createTiles = (regl, opts) => { } } }) - this.initialized = new Promise((resolve) => { + this.availableLevels = new Set() + if (!store) { + throw new Error('Tiles requires a ZarrStore instance.') + } + this.store = store + + this.initialized = (async () => { const loadingID = this.setLoading('metadata') - initializeStore(source, version, variable, Object.keys(selector)).then( - ({ - metadata, - loaders, - dimensions, - shape, - chunks, - fill_value, - dtype, - coordinates, - levels, - maxZoom, - tileSize, - crs, - }) => { - if (setMetadata) setMetadata(metadata) - this.maxZoom = maxZoom - this.level = zoomToLevel(this.zoom, maxZoom) - const position = getPositions(tileSize, mode) - this.position = regl.buffer(position) - this.size = tileSize - // Respect `projection` prop when provided, otherwise rely on `crs` value from metadata - this.projectionIndex = projection - ? ['mercator', 'equirectangular'].indexOf(projection) - : ['EPSG:3857', 'EPSG:4326'].indexOf(crs) - this.projection = ['mercator', 'equirectangular'][ - this.projectionIndex - ] - - if (!this.projection) { - this.projection = null - throw new Error( - projection - ? `Unexpected \`projection\` prop provided: '${projection}'. Must be one of 'mercator', 'equirectangular'.` - : `Unexpected \`crs\` found in metadata: '${crs}'. Must be one of 'EPSG:3857', 'EPSG:4326'.` - ) - } - if (mode === 'grid' || mode === 'dotgrid') { - this.count = position.length - } - if (mode === 'texture') { - this.count = 6 - } + await this.store.initialized() + const { + metadata, + dimensions, + shape, + chunks, + fill_value, + dtype, + coordinates, + levels, + maxZoom, + tileSize, + crs, + } = this.store.describe() + + if (setMetadata) setMetadata(metadata) + this.maxZoom = maxZoom + this.level = zoomToLevel(this.zoom, maxZoom) + const position = getPositions(tileSize, mode) + this.position = regl.buffer(position) + this.size = tileSize + // Respect `projection` prop when provided, otherwise rely on `crs` value from metadata + this.projectionIndex = projection + ? ['mercator', 'equirectangular'].indexOf(projection) + : ['EPSG:3857', 'EPSG:4326'].indexOf(crs) + this.projection = ['mercator', 'equirectangular'][this.projectionIndex] - this.dimensions = dimensions - this.shape = shape - this.chunks = chunks - this.fillValue = fillValue ?? fill_value ?? DEFAULT_FILL_VALUES[dtype] + if (!this.projection) { + this.projection = null + throw new Error( + projection + ? `Unexpected \`projection\` prop provided: '${projection}'. Must be one of 'mercator', 'equirectangular'.` + : `Unexpected \`crs\` found in metadata: '${crs}'. Must be one of 'EPSG:3857', 'EPSG:4326'.` + ) + } - if (mode === 'texture') { - const emptyTexture = ndarray( - new Float32Array(Array(1).fill(this.fillValue)), - [1, 1] - ) - initialize = () => regl.texture(emptyTexture) - } + if (mode === 'grid' || mode === 'dotgrid') { + this.count = position.length + } + if (mode === 'texture') { + this.count = 6 + } - this.ndim = this.dimensions.length + this.dimensions = dimensions + this.shape = shape + this.chunks = chunks + this.fillValue = fillValue ?? fill_value ?? DEFAULT_FILL_VALUES[dtype] - this.coordinates = coordinates + if (mode === 'texture') { + const emptyTexture = ndarray( + new Float32Array(Array(1).fill(this.fillValue)), + [1, 1] + ) + initialize = () => regl.texture(emptyTexture) + } - levels.forEach((z) => { - const loader = loaders[z + '/' + variable] - this.loaders[z] = loader - }) + this.ndim = this.dimensions.length - resolve(true) - this.clearLoading(loadingID) - this.invalidate() - } - ) - }) + this.coordinates = coordinates + this.availableLevels = new Set(levels) + + this.clearLoading(loadingID) + this.invalidate() + return true + })() this.drawTiles = regl({ vert: vert(mode, this.bands), @@ -322,12 +318,14 @@ export const createTiles = (regl, opts) => { this._initializeTile = (key, level) => { if (!this.tiles[key]) { - const loader = this.loaders[level] - if (!loader) return + if (!this.availableLevels.has(level)) return this._removeOldestTile() + const loadChunk = (chunk) => + this.store.getChunk(`${level}/${this.variable}`, chunk) + this.tiles[key] = new Tile({ key, - loader, + loader: loadChunk, shape: this.shape, chunks: this.chunks, dimensions: this.dimensions, @@ -381,7 +379,7 @@ export const createTiles = (regl, opts) => { Object.keys(this.active).map( (key) => new Promise((resolve) => { - if (this.loaders[level]) { + if (this.availableLevels.has(level)) { const tileIndex = keyToTile(key) const tile = this._initializeTile(key, level) diff --git a/src/zarr-store.js b/src/zarr-store.js new file mode 100644 index 0000000..a28ff5d --- /dev/null +++ b/src/zarr-store.js @@ -0,0 +1,187 @@ +import zarr from 'zarr-js' +import { getPyramidMetadata } from './utils' + +class ZarrStore { + constructor({ source, version = 'v2', variable, coordinateKeys = [] }) { + if (!source) { + throw new Error('source is a required parameter') + } + if (!variable) { + throw new Error('variable is a required parameter') + } + this.source = source + this.version = version + this.variable = variable + this.coordinateKeys = coordinateKeys + this.metadata = null + this.arrayMetadata = null + this.dimensions = null + this.shape = null + this.chunks = null + this.fill_value = null + this.dtype = null + this.levels = [] + this.maxZoom = null + this.tileSize = null + this.crs = null + this.coordinates = {} + this._getterCache = new Map() + + this._initialized = this._initialize() + } + + async initialized() { + await this._initialized + return true + } + + async _initialize() { + if (this.version === 'v2') { + await this._loadV2() + } else if (this.version === 'v3') { + await this._loadV3() + } else { + throw new Error( + `Unexpected Zarr version: ${this.version}. Must be one of 'v2', 'v3'.` + ) + } + + if (this.coordinateKeys.length) { + await Promise.all( + this.coordinateKeys.map((key) => + this.getChunk(`${this.levels[0]}/${key}`, [0]).then((chunk) => { + this.coordinates[key] = Array.from(chunk.data) + }) + ) + ) + } + + return this + } + + cleanup() { + this._getterCache.clear() + this.coordinates = {} + } + + describe() { + return { + metadata: this.metadata, + dimensions: this.dimensions, + shape: this.shape, + chunks: this.chunks, + fill_value: this.fill_value, + dtype: this.dtype, + coordinates: this.coordinates, + levels: this.levels, + maxZoom: this.maxZoom, + tileSize: this.tileSize, + crs: this.crs, + } + } + + async getChunk(key, chunkIndices) { + let handle = this._getterCache.get(key) + + if (!handle) { + let meta = null + if (this.version === 'v2') { + meta = this.metadata?.metadata?.[`${key}/.zarray`] || null + } else if (this.version === 'v3') { + meta = key.startsWith(`${this.levels[0]}/${this.variable}`) + ? this.arrayMetadata + : null + } + + handle = new Promise((resolve, reject) => { + zarr(window.fetch, this.version).open( + `${this.source}/${key}`, + (err, get) => (err ? reject(err) : resolve(get)), + meta + ) + }).catch((err) => { + this._getterCache.delete(key) + throw err + }) + this._getterCache.set(key, handle) + } + + const get = await handle + return new Promise((resolve, reject) => { + get(chunkIndices, (err, out) => (err ? reject(err) : resolve(out))) + }) + } + + async _loadV2() { + const cacheKey = `${this.version}:${this.source}` + let zmetadata = ZarrStore._cache.get(cacheKey) + if (!zmetadata) { + zmetadata = await fetch(`${this.source}/.zmetadata`).then((res) => + res.json() + ) + ZarrStore._cache.set(cacheKey, zmetadata) + } + + this.metadata = { metadata: zmetadata.metadata } + + const pyramid = getPyramidMetadata( + zmetadata.metadata['.zattrs'].multiscales + ) + this.levels = pyramid.levels + this.maxZoom = pyramid.maxZoom + this.tileSize = pyramid.tileSize + this.crs = pyramid.crs + + const basePath = `${this.levels[0]}/${this.variable}` + const zattrs = this.metadata.metadata[`${basePath}/.zattrs`] + const zarray = this.metadata.metadata[`${basePath}/.zarray`] + + this.dimensions = zattrs['_ARRAY_DIMENSIONS'] + this.shape = zarray.shape + this.chunks = zarray.chunks + this.fill_value = zarray.fill_value + this.dtype = zarray.dtype + } + + async _loadV3() { + const metadataCacheKey = `${this.version}:${this.source}` + let metadata = ZarrStore._cache.get(metadataCacheKey) + if (!metadata) { + metadata = await fetch(`${this.source}/zarr.json`).then((res) => + res.json() + ) + ZarrStore._cache.set(metadataCacheKey, metadata) + } + this.metadata = metadata + + const pyramid = getPyramidMetadata(this.metadata.attributes.multiscales) + this.levels = pyramid.levels + this.maxZoom = pyramid.maxZoom + this.tileSize = pyramid.tileSize + this.crs = pyramid.crs + + const arrayKey = `${this.levels[0]}/${this.variable}` + const arrayCacheKey = `${this.version}:${this.source}/${arrayKey}` + let arrayMetadata = ZarrStore._cache.get(arrayCacheKey) + if (!arrayMetadata) { + arrayMetadata = await fetch(`${this.source}/${arrayKey}/zarr.json`).then( + (res) => res.json() + ) + ZarrStore._cache.set(arrayCacheKey, arrayMetadata) + } + this.arrayMetadata = arrayMetadata + + this.dimensions = this.arrayMetadata.attributes['_ARRAY_DIMENSIONS'] + this.shape = this.arrayMetadata.shape + const isSharded = this.arrayMetadata.codecs[0].name === 'sharding_indexed' + this.chunks = isSharded + ? this.arrayMetadata.codecs[0].configuration.chunk_shape + : this.arrayMetadata.chunk_grid.configuration.chunk_shape + this.fill_value = this.arrayMetadata.fill_value + this.dtype = this.arrayMetadata.data_type || null + } +} + +ZarrStore._cache = new Map() + +export default ZarrStore diff --git a/src/zarr-store.test.js b/src/zarr-store.test.js new file mode 100644 index 0000000..25df80c --- /dev/null +++ b/src/zarr-store.test.js @@ -0,0 +1,367 @@ +jest.mock('zarr-js', () => { + const handlers = new Map() + const openMock = jest.fn((url, callback) => { + const handler = handlers.get(url) + if (!handler) { + callback(new Error(`Missing handler for URL: ${url}`)) + return + } + callback(null, handler) + }) + + const factory = jest.fn(() => ({ open: openMock })) + + factory.__set = (url, handler) => handlers.set(url, handler) + factory.__clear = () => { + handlers.clear() + openMock.mockClear() + factory.mockClear() + } + factory.__openMock = openMock + + return factory +}) + +import zarr from 'zarr-js' +import ZarrStore from './zarr-store' + +const mockJsonResponse = (value) => + Promise.resolve({ + json: () => Promise.resolve(value), + }) + +const createV2Metadata = () => ({ + metadata: { + '.zattrs': { + multiscales: [ + { + datasets: [ + { path: '0', pixels_per_tile: 256, crs: 'EPSG:3857' }, + { path: '1', pixels_per_tile: 256, crs: 'EPSG:3857' }, + ], + }, + ], + }, + '0/temp/.zattrs': { + _ARRAY_DIMENSIONS: ['time', 'y', 'x'], + }, + '0/temp/.zarray': { + shape: [2, 256, 256], + chunks: [1, 256, 256], + fill_value: -9999, + dtype: ' ({ + attributes: { + multiscales: [ + { + datasets: [ + { path: '0', pixels_per_tile: 256, crs: 'EPSG:3857' }, + { path: '1', pixels_per_tile: 256, crs: 'EPSG:3857' }, + ], + }, + ], + }, +}) + +const createV3ArrayMetadata = () => ({ + attributes: { + _ARRAY_DIMENSIONS: ['time', 'y', 'x'], + }, + shape: [2, 256, 256], + chunk_grid: { + configuration: { + chunk_shape: [1, 256, 256], + }, + }, + codecs: [{ name: 'gzip' }], + fill_value: 0, + data_type: ' { + const handler = jest.fn((chunkIndices, callback) => { + callback(null, { data: new Float32Array(values) }) + }) + zarr.__set(url, handler) + return handler +} + +describe('ZarrStore', () => { + const originalGlobalFetch = global.fetch + const hasWindow = typeof window !== 'undefined' + const originalWindowFetch = hasWindow ? window.fetch : undefined + + let fetchMock + let createdWindow + + beforeEach(() => { + createdWindow = false + fetchMock = jest.fn() + + global.fetch = fetchMock + + if (!hasWindow) { + global.window = {} + createdWindow = true + } + window.fetch = fetchMock + + zarr.__clear() + ZarrStore._cache.clear() + }) + + afterEach(() => { + if (originalGlobalFetch !== undefined) { + global.fetch = originalGlobalFetch + } else { + delete global.fetch + } + + if (createdWindow) { + delete global.window + } else if (hasWindow) { + if (originalWindowFetch !== undefined) { + window.fetch = originalWindowFetch + } else { + delete window.fetch + } + } + }) + + it('requires source and variable options', () => { + expect(() => new ZarrStore({ variable: 'temp' })).toThrow( + 'source is a required parameter' + ) + expect(() => new ZarrStore({ source: 'https://example.com' })).toThrow( + 'variable is a required parameter' + ) + }) + + describe('v2', () => { + it('loads metadata and coordinates arrays on initialization', async () => { + const source = 'https://example.com/v2' + const coordinateValues = [2000, 2001] + + const zmetadata = createV2Metadata() + registerChunk(`${source}/0/time`, coordinateValues) + + fetchMock.mockResolvedValueOnce(mockJsonResponse(zmetadata)) + + const store = new ZarrStore({ + source, + version: 'v2', + variable: 'temp', + coordinateKeys: ['time'], + }) + + await expect(store.initialized()).resolves.toBe(true) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(`${source}/.zmetadata`) + + expect(store.describe()).toEqual({ + metadata: { metadata: zmetadata.metadata }, + dimensions: ['time', 'y', 'x'], + shape: [2, 256, 256], + chunks: [1, 256, 256], + fill_value: -9999, + dtype: ' { + const source = 'https://example.com/v2-cached' + const coordinateValues = [2000, 2001] + + const zmetadata = createV2Metadata() + registerChunk(`${source}/0/time`, coordinateValues) + + fetchMock.mockResolvedValueOnce(mockJsonResponse(zmetadata)) + + const firstStore = new ZarrStore({ + source, + version: 'v2', + variable: 'temp', + coordinateKeys: ['time'], + }) + + await expect(firstStore.initialized()).resolves.toBe(true) + expect(fetchMock).toHaveBeenCalledTimes(1) + + fetchMock.mockClear() + + const secondStore = new ZarrStore({ + source, + version: 'v2', + variable: 'temp', + coordinateKeys: ['time'], + }) + + await expect(secondStore.initialized()).resolves.toBe(true) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('returns cached values when requesting the same chunk', async () => { + const source = 'https://example.com/v2-chunks' + const zmetadata = createV2Metadata() + const chunkValues = [1, 2, 3, 4] + + fetchMock.mockResolvedValueOnce(mockJsonResponse(zmetadata)) + const chunkHandler = registerChunk(`${source}/0/temp`, chunkValues) + + const store = new ZarrStore({ + source, + version: 'v2', + variable: 'temp', + }) + + await expect(store.initialized()).resolves.toBe(true) + + const firstChunk = await store.getChunk('0/temp', [0, 0]) + expect(firstChunk.data).toEqual(new Float32Array(chunkValues)) + + const secondChunk = await store.getChunk('0/temp', [0, 1]) + expect(secondChunk.data).toEqual(new Float32Array(chunkValues)) + + expect(zarr).toHaveBeenCalledTimes(1) + expect(zarr.__openMock).toHaveBeenCalledTimes(1) + expect(zarr.__openMock).toHaveBeenCalledWith( + `${source}/0/temp`, + expect.any(Function), + expect.anything() + ) + expect(chunkHandler).toHaveBeenCalledTimes(2) + expect(chunkHandler).toHaveBeenNthCalledWith( + 1, + [0, 0], + expect.any(Function) + ) + expect(chunkHandler).toHaveBeenNthCalledWith( + 2, + [0, 1], + expect.any(Function) + ) + }) + }) + + describe('v3', () => { + it('loads metadata and coordinates arrays on initialization', async () => { + const source = 'https://example.com/v3' + const metadata = createV3Metadata() + const arrayMetadata = createV3ArrayMetadata() + + fetchMock + .mockResolvedValueOnce(mockJsonResponse(metadata)) + .mockResolvedValueOnce(mockJsonResponse(arrayMetadata)) + + const store = new ZarrStore({ + source, + version: 'v3', + variable: 'temp', + }) + + await expect(store.initialized()).resolves.toBe(true) + + expect(fetchMock).toHaveBeenNthCalledWith(1, `${source}/zarr.json`) + expect(fetchMock).toHaveBeenNthCalledWith(2, `${source}/0/temp/zarr.json`) + + expect(store.describe()).toEqual({ + metadata, + dimensions: ['time', 'y', 'x'], + shape: [2, 256, 256], + chunks: [1, 256, 256], + fill_value: 0, + dtype: ' { + const source = 'https://example.com/v3-cached' + const metadata = createV3Metadata() + const arrayMetadata = createV3ArrayMetadata() + + fetchMock + .mockResolvedValueOnce(mockJsonResponse(metadata)) + .mockResolvedValueOnce(mockJsonResponse(arrayMetadata)) + + const firstStore = new ZarrStore({ + source, + version: 'v3', + variable: 'temp', + }) + + await expect(firstStore.initialized()).resolves.toBe(true) + expect(fetchMock).toHaveBeenCalledTimes(2) + + fetchMock.mockClear() + + const secondStore = new ZarrStore({ + source, + version: 'v3', + variable: 'temp', + }) + + await expect(secondStore.initialized()).resolves.toBe(true) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('returns cached values when requesting the same chunk', async () => { + const source = 'https://example.com/v3-chunks' + const metadata = createV3Metadata() + const arrayMetadata = createV3ArrayMetadata() + const chunkValues = [5, 6, 7, 8] + + fetchMock + .mockResolvedValueOnce(mockJsonResponse(metadata)) + .mockResolvedValueOnce(mockJsonResponse(arrayMetadata)) + const chunkHandler = registerChunk(`${source}/0/temp`, chunkValues) + + const store = new ZarrStore({ + source, + version: 'v3', + variable: 'temp', + }) + + await expect(store.initialized()).resolves.toBe(true) + + const firstChunk = await store.getChunk('0/temp', [0, 0]) + expect(firstChunk.data).toEqual(new Float32Array(chunkValues)) + + const secondChunk = await store.getChunk('0/temp', [0, 1]) + expect(secondChunk.data).toEqual(new Float32Array(chunkValues)) + + expect(zarr).toHaveBeenCalledTimes(1) + expect(zarr.__openMock).toHaveBeenCalledTimes(1) + expect(zarr.__openMock).toHaveBeenCalledWith( + `${source}/0/temp`, + expect.any(Function), + expect.anything() + ) + expect(chunkHandler).toHaveBeenCalledTimes(2) + expect(chunkHandler).toHaveBeenNthCalledWith( + 1, + [0, 0], + expect.any(Function) + ) + expect(chunkHandler).toHaveBeenNthCalledWith( + 2, + [0, 1], + expect.any(Function) + ) + }) + }) +})