From f4b3b8fbc6a61f0d018e075113fb105bead207a9 Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Sun, 14 Jun 2026 21:44:30 +0300 Subject: [PATCH 1/4] feat: LRU cache and unit tests action --- .github/workflows/verify.yml | 5 +- package-lock.json | 848 ++++++++++++++++++++++++++- package.json | 7 +- src/components/map/EarthMap.tsx | 10 +- src/lib/cache/lru.test.ts | 33 ++ src/lib/cache/lru.ts | 35 ++ src/lib/zarr/ZarrChunkReader.test.ts | 82 +++ src/lib/zarr/ZarrChunkReader.ts | 94 +++ src/lib/zarr/chunks.test.ts | 67 +++ src/lib/zarr/chunks.ts | 118 ++++ src/lib/zarr/store.ts | 66 ++- vitest.config.ts | 14 + 12 files changed, 1356 insertions(+), 23 deletions(-) create mode 100644 src/lib/cache/lru.test.ts create mode 100644 src/lib/cache/lru.ts create mode 100644 src/lib/zarr/ZarrChunkReader.test.ts create mode 100644 src/lib/zarr/ZarrChunkReader.ts create mode 100644 src/lib/zarr/chunks.test.ts create mode 100644 src/lib/zarr/chunks.ts create mode 100644 vitest.config.ts diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index bb3b9d4..bc05af1 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -5,7 +5,7 @@ on: jobs: verify: - name: Lint and build + name: Lint, test, and build runs-on: ubuntu-latest steps: - name: Checkout @@ -23,5 +23,8 @@ jobs: - name: Lint run: npm run lint + - name: Test + run: npm run test + - name: Build run: npm run build diff --git a/package-lock.json b/package-lock.json index 8f0ed47..6adff83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,8 @@ "eslint": "^9.0.0", "eslint-config-next": "16.2.9", "tailwindcss": "^4", - "typescript": "^6" + "typescript": "^6", + "vitest": "^4.1.8" } }, "node_modules/@alloc/quick-lru": { @@ -2363,6 +2364,16 @@ "license": "MIT", "peer": true }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@polymer/polymer": { "version": "3.5.2", "resolved": "https://registry.npmjs.org/@polymer/polymer/-/polymer-3.5.2.tgz", @@ -2394,6 +2405,307 @@ "integrity": "sha512-4VpAyMHOqydSvPlEyHwXaE+AkIdR03nX+Qhlxsk2D/IW4OVmDZgIsvJB1cDzyEEtcfKcnaEbfXeiPgejBceT6g==", "license": "MIT" }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -2401,6 +2713,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -2824,6 +3143,17 @@ "@types/node": "*" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/command-line-args": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/command-line-args/-/command-line-args-5.2.3.tgz", @@ -3156,6 +3486,13 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -3458,9 +3795,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -4079,6 +4416,119 @@ "integrity": "sha512-Rc2suX5meI0S3bfdZuA7JMFBGkJ875ApfVyq2WHELjBiiG22My/l7/8zPpH/CfFVQHuVLd8NLR0nv6vi0BYYKA==", "license": "MIT" }, + "node_modules/@vitest/expect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.8", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.8", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@webcomponents/shadycss": { "version": "1.11.2", "resolved": "https://registry.npmjs.org/@webcomponents/shadycss/-/shadycss-1.11.2.tgz", @@ -4402,6 +4852,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/assign-symbols": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", @@ -4694,6 +5154,16 @@ "colorbrewer": "1.5.6" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -5931,6 +6401,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -6427,6 +6904,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -6437,6 +6924,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend-shallow": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", @@ -6674,6 +7171,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -8708,6 +9220,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -8837,6 +9363,13 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/pbf": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", @@ -8939,9 +9472,9 @@ } }, "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -8959,7 +9492,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -9259,6 +9792,40 @@ "license": "Unlicense", "peer": true }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -9615,6 +10182,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/snappyjs": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/snappyjs/-/snappyjs-0.6.1.tgz", @@ -9722,6 +10296,20 @@ "dev": true, "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -10045,10 +10633,27 @@ "license": "MIT", "peer": true }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -10099,6 +10704,16 @@ "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", "license": "ISC" }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -10484,6 +11099,200 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -10589,6 +11398,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index a21976e..ec79f0b 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@deck.gl/react": "^9.3.3", @@ -26,6 +28,7 @@ "eslint": "^9.0.0", "eslint-config-next": "16.2.9", "tailwindcss": "^4", - "typescript": "^6" + "typescript": "^6", + "vitest": "^4.1.8" } } diff --git a/src/components/map/EarthMap.tsx b/src/components/map/EarthMap.tsx index a213dfd..411c2f8 100644 --- a/src/components/map/EarthMap.tsx +++ b/src/components/map/EarthMap.tsx @@ -9,7 +9,8 @@ import "maplibre-gl/dist/maplibre-gl.css"; import { geoPointToZarrGrid } from "@/lib/map/geogrid"; import { TEAL_ON_DARK_RGB } from "@/lib/constants/theme"; import { DEFAULT_MAP_VIEW, MAP_BASE_STYLES } from "@/lib/map/viewState"; -import { fetchZarrTimeSeries, openZarrStore } from "@/lib/zarr/store"; +import { openZarrStore } from "@/lib/zarr/store"; +import { ZarrChunkReader } from "@/lib/zarr/ZarrChunkReader"; import type { MapSelection } from "@/types/map"; import { useTheme } from "@/providers/ThemeProvider"; import { MapReadout } from "@/components/map/MapReadout"; @@ -23,6 +24,7 @@ export function EarthMap({ className }: EarthMapProps) { const dsRef = useRef> | null>( null, ); + const readerRef = useRef(null); const requestIdRef = useRef(0); const [selection, setSelection] = useState(null); @@ -42,9 +44,11 @@ export function EarthMap({ className }: EarthMapProps) { if (!dsRef.current) { dsRef.current = await openZarrStore(); } + if (!readerRef.current) { + readerRef.current = new ZarrChunkReader(dsRef.current); + } - const { values, units } = await fetchZarrTimeSeries( - dsRef.current, + const { values, units } = await readerRef.current.getTimeSeries( nextSelection.grid, ); diff --git a/src/lib/cache/lru.test.ts b/src/lib/cache/lru.test.ts new file mode 100644 index 0000000..b76b8f7 --- /dev/null +++ b/src/lib/cache/lru.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { LRUCache } from "@/lib/cache/lru"; + +describe("LRUCache", () => { + it("evicts the least recently used entry when full", () => { + const cache = new LRUCache(2); + cache.set("A", 1); + cache.set("B", 2); + cache.get("A"); + cache.set("C", 3); + + expect(cache.get("B")).toBeUndefined(); + expect(cache.get("A")).toBe(1); + expect(cache.get("C")).toBe(3); + }); + + it("returns falsy values on cache hit", () => { + const cache = new LRUCache(2); + cache.set("zero", 0); + + expect(cache.get("zero")).toBe(0); + }); + + it("promotes an existing key on set without evicting others", () => { + const cache = new LRUCache(2); + cache.set("A", 1); + cache.set("B", 2); + cache.set("B", 99); + + expect(cache.get("A")).toBe(1); + expect(cache.get("B")).toBe(99); + }); +}); diff --git a/src/lib/cache/lru.ts b/src/lib/cache/lru.ts new file mode 100644 index 0000000..6da792d --- /dev/null +++ b/src/lib/cache/lru.ts @@ -0,0 +1,35 @@ +export class LRUCache { + private cache: Map; + private maxSize: number; + + constructor(maxSize: number) { + this.cache = new Map(); + this.maxSize = maxSize; + } + + isCacheFull(): boolean { + return this.cache.size >= this.maxSize; + } + + get(key: K): V | undefined { + if (!this.cache.has(key)) { + return undefined; + } + const value = this.cache.get(key)!; + this.cache.delete(key); + this.cache.set(key, value); + return value; + } + + set(key: K, value: V): void { + if (this.cache.has(key)) { + this.cache.delete(key); + } else if (this.isCacheFull()) { + const lruKey = this.cache.keys().next().value; + if (lruKey !== undefined) { + this.cache.delete(lruKey); + } + } + this.cache.set(key, value); + } +} \ No newline at end of file diff --git a/src/lib/zarr/ZarrChunkReader.test.ts b/src/lib/zarr/ZarrChunkReader.test.ts new file mode 100644 index 0000000..3b5ba05 --- /dev/null +++ b/src/lib/zarr/ZarrChunkReader.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ZarrChunkReader } from "@/lib/zarr/ZarrChunkReader"; +import { fetchChunk, type ZarrStore } from "@/lib/zarr/store"; +import type { GridCell } from "@/types/map"; + +vi.mock("zarrita", () => ({ + open: vi.fn().mockResolvedValue({ + chunks: [1461, 24, 40, 40], + }), +})); + +vi.mock("@/lib/zarr/store", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + fetchChunk: vi.fn(), + }; +}); + +const mockFetchChunk = vi.mocked(fetchChunk); + +const ds = { + store: {}, + root: { resolve: vi.fn((name: string) => name) }, +} as unknown as ZarrStore; + +function makeGrid(latIndex: number, lonIndex: number): GridCell { + return { + lon: 0, + lat: 0, + latIndex, + lonIndex, + }; +} + +function makeChunkData(shape: readonly [number, number, number, number]) { + const [timeCount, hourCount, latCount, lonCount] = shape; + const data = new Float32Array(timeCount * hourCount * latCount * lonCount); + + for (let t = 0; t < timeCount; t++) { + for (let h = 0; h < hourCount; h++) { + for (let lat = 0; lat < latCount; lat++) { + for (let lon = 0; lon < lonCount; lon++) { + const index = + ((t * hourCount + h) * latCount + lat) * lonCount + lon; + data[index] = t * 1000 + h * 100 + lat * 10 + lon; + } + } + } + } + + return data; +} + +describe("ZarrChunkReader", () => { + beforeEach(() => { + mockFetchChunk.mockReset(); + }); + + it("fetches once and reuses the cache for nearby pixels", async () => { + const shape = [2, 2, 40, 40] as const; + const data = makeChunkData(shape); + + mockFetchChunk.mockResolvedValue({ + data, + shape, + chunkKey: "NEE:1:1", + localOffset: { localLat: 10, localLon: 10 }, + variable: "NEE", + units: "gC m-2 h-1", + }); + + const reader = new ZarrChunkReader(ds); + const first = await reader.getTimeSeries(makeGrid(50, 50)); + const second = await reader.getTimeSeries(makeGrid(51, 51)); + + expect(mockFetchChunk).toHaveBeenCalledTimes(1); + expect(first.units).toBe("gC m-2 h-1"); + expect(Array.from(first.values)).toEqual([110, 210, 1110, 1210]); + expect(Array.from(second.values)).toEqual([121, 221, 1121, 1221]); + }); +}); diff --git a/src/lib/zarr/ZarrChunkReader.ts b/src/lib/zarr/ZarrChunkReader.ts new file mode 100644 index 0000000..2c89187 --- /dev/null +++ b/src/lib/zarr/ZarrChunkReader.ts @@ -0,0 +1,94 @@ +import * as zarr from "zarrita"; +import { LRUCache } from "@/lib/cache/lru"; +import { ZARR_STORE } from "@/lib/constants/store"; +import { + extractTimeSeries, + pixelToChunkKey, + pixelToLocalOffset, +} from "@/lib/zarr/chunks"; +import { fetchChunk, type ZarrStore } from "@/lib/zarr/store"; +import type { GridCell } from "@/types/map"; + +type CachedChunk = { + data: Float32Array; + shape: readonly number[]; + units?: string; +}; + +type SpatialChunkSizes = { + chunkLat: number; + chunkLon: number; +}; + +export class ZarrChunkReader { + private ds: ZarrStore; + private cache: LRUCache; + private spatialChunksByVariable = new Map(); + + constructor(ds: ZarrStore, maxCacheSize = 16) { + this.ds = ds; + this.cache = new LRUCache(maxCacheSize); + } + + private async getSpatialChunkSizes( + variable: string, + ): Promise { + const cached = this.spatialChunksByVariable.get(variable); + if (cached) return cached; + + const array = await zarr.open(this.ds.root.resolve(variable), { + kind: "array", + }); + const sizes: SpatialChunkSizes = { + chunkLat: array.chunks[2], + chunkLon: array.chunks[3], + }; + this.spatialChunksByVariable.set(variable, sizes); + return sizes; + } + + async getTimeSeries( + grid: GridCell, + variable = ZARR_STORE.defaultVariable, + ): Promise<{ values: Float32Array; variable: string; units?: string }> { + const { chunkLat, chunkLon } = await this.getSpatialChunkSizes(variable); + + const chunkKey = pixelToChunkKey( + variable, + grid.latIndex, + grid.lonIndex, + chunkLat, + chunkLon, + ); + const localOffset = pixelToLocalOffset( + grid.latIndex, + grid.lonIndex, + chunkLat, + chunkLon, + ); + + let cached = this.cache.get(chunkKey); + if (!cached) { + const chunk = await fetchChunk( + this.ds, + grid.latIndex, + grid.lonIndex, + variable, + ); + cached = { + data: chunk.data, + shape: chunk.shape, + units: chunk.units, + }; + this.cache.set(chunkKey, cached); + } + + const values = extractTimeSeries( + cached.data, + cached.shape, + localOffset, + ); + + return { values, variable, units: cached.units }; + } +} diff --git a/src/lib/zarr/chunks.test.ts b/src/lib/zarr/chunks.test.ts new file mode 100644 index 0000000..0cbc2eb --- /dev/null +++ b/src/lib/zarr/chunks.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { + chunkIndexToSlice, + extractTimeSeries, + pixelToChunkKey, + pixelToLocalOffset, + spatialChunkToSlices, +} from "@/lib/zarr/chunks"; + +describe("chunk helpers", () => { + it("maps pixels in the same spatial chunk to the same key", () => { + const keyA = pixelToChunkKey("NEE", 50, 50, 40, 40); + const keyB = pixelToChunkKey("NEE", 51, 51, 40, 40); + + expect(keyA).toBe("NEE:1:1"); + expect(keyB).toBe(keyA); + }); + + it("computes local offsets inside a chunk", () => { + expect(pixelToLocalOffset(50, 50, 40, 40)).toEqual({ + localLat: 10, + localLon: 10, + }); + expect(pixelToLocalOffset(51, 52, 40, 40)).toEqual({ + localLat: 11, + localLon: 12, + }); + }); + + it("clamps the last chunk slice to the axis length", () => { + expect(chunkIndexToSlice(89, 40, 3600)).toEqual([3560, 3600]); + }); + + it("builds lat/lon slice ranges for a spatial chunk", () => { + expect(spatialChunkToSlices(1, 2, 40, 40, 3600, 7200)).toEqual({ + latSlice: [40, 80], + lonSlice: [80, 120], + }); + }); +}); + +describe("extractTimeSeries", () => { + it("pulls one pixel series out of a flattened 4D chunk", () => { + const shape = [2, 2, 4, 4] as const; + const [timeCount, hourCount, latCount, lonCount] = shape; + const data = new Float32Array(timeCount * hourCount * latCount * lonCount); + + for (let t = 0; t < timeCount; t++) { + for (let h = 0; h < hourCount; h++) { + for (let lat = 0; lat < latCount; lat++) { + for (let lon = 0; lon < lonCount; lon++) { + const index = + ((t * hourCount + h) * latCount + lat) * lonCount + lon; + data[index] = t * 1000 + h * 100 + lat * 10 + lon; + } + } + } + } + + const series = extractTimeSeries(data, shape, { + localLat: 1, + localLon: 2, + }); + + expect(Array.from(series)).toEqual([12, 112, 1012, 1112]); + }); +}); diff --git a/src/lib/zarr/chunks.ts b/src/lib/zarr/chunks.ts new file mode 100644 index 0000000..464d801 --- /dev/null +++ b/src/lib/zarr/chunks.ts @@ -0,0 +1,118 @@ +/** Half-open interval `[start, stop)` for one axis slice passed to zarrita. */ +export type AxisSlice = [start: number, stop: number]; + +export type ChunkIndices = { + chunkLatIdx: number; + chunkLonIdx: number; +}; + +export type LocalOffset = { + localLat: number; + localLon: number; +}; + +export type SpatialChunkSlices = { + latSlice: AxisSlice; + lonSlice: AxisSlice; +}; + +/** Map a pixel index to its spatial chunk index along one axis. */ +export function indexToChunkIndex(index: number, chunkSize: number): number { + return Math.floor(index / chunkSize); +} + +/** Convert a chunk index to a half-open `[start, stop)` slice, clamped to axis length. */ +export function chunkIndexToSlice( + chunkIdx: number, + chunkSize: number, + axisLength: number, +): AxisSlice { + const start = chunkIdx * chunkSize; + const stop = Math.min(start + chunkSize, axisLength); + return [start, stop]; +} + +export function pixelToChunkIndices( + latIndex: number, + lonIndex: number, + chunkLat: number, + chunkLon: number, +): ChunkIndices { + return { + chunkLatIdx: indexToChunkIndex(latIndex, chunkLat), + chunkLonIdx: indexToChunkIndex(lonIndex, chunkLon), + }; +} + +/** Cache key for one spatial chunk of a variable. */ +export function pixelToChunkKey( + variable: string, + latIndex: number, + lonIndex: number, + chunkLat: number, + chunkLon: number, +): string { + const { chunkLatIdx, chunkLonIdx } = pixelToChunkIndices( + latIndex, + lonIndex, + chunkLat, + chunkLon, + ); + return `${variable}:${chunkLatIdx}:${chunkLonIdx}`; +} + +export function pixelToLocalOffset( + latIndex: number, + lonIndex: number, + chunkLat: number, + chunkLon: number, +): LocalOffset { + const { chunkLatIdx, chunkLonIdx } = pixelToChunkIndices( + latIndex, + lonIndex, + chunkLat, + chunkLon, + ); + return { + localLat: latIndex - chunkLatIdx * chunkLat, + localLon: lonIndex - chunkLonIdx * chunkLon, + }; +} + +/** Lat/lon slice ranges for fetching one spatial chunk from a 4D `[time, hour, lat, lon]` array. */ +export function spatialChunkToSlices( + chunkLatIdx: number, + chunkLonIdx: number, + chunkLat: number, + chunkLon: number, + latCount: number, + lonCount: number, +): SpatialChunkSlices { + return { + latSlice: chunkIndexToSlice(chunkLatIdx, chunkLat, latCount), + lonSlice: chunkIndexToSlice(chunkLonIdx, chunkLon, lonCount), + }; +} + +/** Pick one pixel's time series out of a downloaded spatial chunk. */ +export function extractTimeSeries( + data: Float32Array, + shape: readonly number[], + localOffset: LocalOffset, +): Float32Array { + const [timeCount, hourCount, latCount, lonCount] = shape; + const { localLat, localLon } = localOffset; + + const series = new Float32Array(timeCount * hourCount); + let out = 0; + + for (let t = 0; t < timeCount; t++) { + for (let h = 0; h < hourCount; h++) { + const index = + ((t * hourCount + h) * latCount + localLat) * lonCount + localLon; + series[out++] = data[index]!; + } + } + + return series; +} diff --git a/src/lib/zarr/store.ts b/src/lib/zarr/store.ts index 7968058..3b5c656 100644 --- a/src/lib/zarr/store.ts +++ b/src/lib/zarr/store.ts @@ -1,6 +1,14 @@ import * as zarr from "zarrita"; import { ZARR_STORE } from "@/lib/constants/store"; -import type { GridCell } from "@/types/map"; +import { + pixelToChunkIndices, + pixelToChunkKey, + pixelToLocalOffset, + spatialChunkToSlices, + type LocalOffset, +} from "@/lib/zarr/chunks"; + +export { extractTimeSeries } from "@/lib/zarr/chunks"; export type ZarrStore = Awaited>; @@ -13,18 +21,64 @@ export async function openZarrStore(url = ZARR_STORE.url) { }; } -export async function fetchZarrTimeSeries( +export type FetchedSpatialChunk = { + data: Float32Array; + shape: readonly number[]; + chunkKey: string; + localOffset: LocalOffset; + variable: string; + units?: string; +}; + +/** Download the full spatial Zarr chunk that contains `(latIndex, lonIndex)`. */ +export async function fetchChunk( ds: ZarrStore, - grid: GridCell, + latIndex: number, + lonIndex: number, variable = ZARR_STORE.defaultVariable, -): Promise<{ values: Float32Array; variable: string; units?: string }> { +): Promise { const array = await zarr.open(ds.root.resolve(variable), { kind: "array" }); - const result = await zarr.get(array, [null, null, grid.latIndex, grid.lonIndex]); /* days, hours, lat, lon */ + const [, , latCount, lonCount] = array.shape; + const chunkLat = array.chunks[2]; + const chunkLon = array.chunks[3]; + + const { chunkLatIdx, chunkLonIdx } = pixelToChunkIndices( + latIndex, + lonIndex, + chunkLat, + chunkLon, + ); + + const { latSlice, lonSlice } = spatialChunkToSlices( + chunkLatIdx, + chunkLonIdx, + chunkLat, + chunkLon, + latCount, + lonCount, + ); + + const result = await zarr.get(array, [ + null, + null, + zarr.slice(...latSlice), + zarr.slice(...lonSlice), + ]); + const units = typeof array.attrs.units === "string" ? array.attrs.units : undefined; return { - values: result.data as Float32Array, + data: result.data as Float32Array, + shape: result.shape, + chunkKey: pixelToChunkKey( + variable, + latIndex, + lonIndex, + chunkLat, + chunkLon, + ), + localOffset: pixelToLocalOffset(latIndex, lonIndex, chunkLat, chunkLon), variable, units, }; diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..9a12e98 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,14 @@ +import path from "node:path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.test.ts"], + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +}); From f1039980ff128304f9cde418fef1bb0162104e8c Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Sun, 14 Jun 2026 22:04:44 +0300 Subject: [PATCH 2/4] feat: optimization --- src/components/map/EarthMap.tsx | 6 +- src/components/map/MapReadout.tsx | 6 +- src/lib/cache/lru.test.ts | 11 ++ src/lib/cache/lru.ts | 4 + src/lib/zarr/ZarrChunkReader.test.ts | 93 +++++++++--- src/lib/zarr/ZarrChunkReader.ts | 209 ++++++++++++++++++++------- src/lib/zarr/chunks.test.ts | 59 ++++++-- src/lib/zarr/chunks.ts | 100 ++++++++----- src/lib/zarr/store.ts | 67 ++------- 9 files changed, 369 insertions(+), 186 deletions(-) diff --git a/src/components/map/EarthMap.tsx b/src/components/map/EarthMap.tsx index 411c2f8..235e814 100644 --- a/src/components/map/EarthMap.tsx +++ b/src/components/map/EarthMap.tsx @@ -30,6 +30,7 @@ export function EarthMap({ className }: EarthMapProps) { const [selection, setSelection] = useState(null); const [loadingSeries, setLoadingSeries] = useState(false); const [seriesError, setSeriesError] = useState(null); + const [seriesLength, setSeriesLength] = useState(null); const [seriesPreview, setSeriesPreview] = useState(null); const [seriesUnits, setSeriesUnits] = useState(null); @@ -37,6 +38,7 @@ export function EarthMap({ className }: EarthMapProps) { const requestId = ++requestIdRef.current; setLoadingSeries(true); setSeriesError(null); + setSeriesLength(null); setSeriesPreview(null); setSeriesUnits(null); @@ -54,7 +56,8 @@ export function EarthMap({ className }: EarthMapProps) { if (requestId !== requestIdRef.current) return; - setSeriesPreview(Array.from(values)); + setSeriesLength(values.length); + setSeriesPreview(Array.from(values.subarray(0, 3))); setSeriesUnits(units ?? null); } catch (error) { if (requestId !== requestIdRef.current) return; @@ -128,6 +131,7 @@ export function EarthMap({ className }: EarthMapProps) { selection={selection} loadingSeries={loadingSeries} seriesError={seriesError} + seriesLength={seriesLength} seriesPreview={seriesPreview} seriesUnits={seriesUnits} /> diff --git a/src/components/map/MapReadout.tsx b/src/components/map/MapReadout.tsx index 8dce3a1..4d53454 100644 --- a/src/components/map/MapReadout.tsx +++ b/src/components/map/MapReadout.tsx @@ -8,6 +8,7 @@ type MapReadoutProps = { selection: MapSelection | null; loadingSeries: boolean; seriesError: string | null; + seriesLength: number | null; seriesPreview: number[] | null; seriesUnits: string | null; }; @@ -16,6 +17,7 @@ export function MapReadout({ selection, loadingSeries, seriesError, + seriesLength, seriesPreview, seriesUnits, }: MapReadoutProps) { @@ -56,8 +58,8 @@ export function MapReadout({ {!loadingSeries && !seriesError && seriesPreview && - `${seriesPreview.length} steps · first ${seriesPreview - .slice(0, 3) + seriesLength !== null && + `${seriesLength} steps · first ${seriesPreview .map((value) => value.toFixed(2)) .join(", ")}${seriesUnits ? ` ${seriesUnits}` : ""}`} diff --git a/src/lib/cache/lru.test.ts b/src/lib/cache/lru.test.ts index b76b8f7..d6d1b7e 100644 --- a/src/lib/cache/lru.test.ts +++ b/src/lib/cache/lru.test.ts @@ -30,4 +30,15 @@ describe("LRUCache", () => { expect(cache.get("A")).toBe(1); expect(cache.get("B")).toBe(99); }); + + it("checks key presence without promoting", () => { + const cache = new LRUCache(2); + cache.set("A", 1); + cache.set("B", 2); + + expect(cache.has("A")).toBe(true); + cache.set("C", 3); + + expect(cache.get("A")).toBeUndefined(); + }); }); diff --git a/src/lib/cache/lru.ts b/src/lib/cache/lru.ts index 6da792d..ccf8c30 100644 --- a/src/lib/cache/lru.ts +++ b/src/lib/cache/lru.ts @@ -32,4 +32,8 @@ export class LRUCache { } this.cache.set(key, value); } + + has(key: K): boolean { + return this.cache.has(key); + } } \ No newline at end of file diff --git a/src/lib/zarr/ZarrChunkReader.test.ts b/src/lib/zarr/ZarrChunkReader.test.ts index 3b5ba05..381f145 100644 --- a/src/lib/zarr/ZarrChunkReader.test.ts +++ b/src/lib/zarr/ZarrChunkReader.test.ts @@ -1,23 +1,27 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as zarr from "zarrita"; import { ZarrChunkReader } from "@/lib/zarr/ZarrChunkReader"; -import { fetchChunk, type ZarrStore } from "@/lib/zarr/store"; +import { fetchPixelTimeSeries, type ZarrStore } from "@/lib/zarr/store"; import type { GridCell } from "@/types/map"; -vi.mock("zarrita", () => ({ - open: vi.fn().mockResolvedValue({ - chunks: [1461, 24, 40, 40], - }), -})); +vi.mock("zarrita", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + open: vi.fn(), + }; +}); vi.mock("@/lib/zarr/store", async (importOriginal) => { const original = await importOriginal(); return { ...original, - fetchChunk: vi.fn(), + fetchPixelTimeSeries: vi.fn(), }; }); -const mockFetchChunk = vi.mocked(fetchChunk); +const mockOpen = vi.mocked(zarr.open); +const mockFetchPixelTimeSeries = vi.mocked(fetchPixelTimeSeries); const ds = { store: {}, @@ -53,30 +57,75 @@ function makeChunkData(shape: readonly [number, number, number, number]) { } describe("ZarrChunkReader", () => { + const mockGetChunk = vi.fn(); + beforeEach(() => { - mockFetchChunk.mockReset(); + mockGetChunk.mockReset(); + mockFetchPixelTimeSeries.mockReset(); + mockOpen.mockResolvedValue({ + shape: [4, 2, 40, 40], + chunks: [2, 2, 40, 40], + attrs: { units: "gC m-2 h-1" }, + getChunk: mockGetChunk, + } as never); }); - it("fetches once and reuses the cache for nearby pixels", async () => { - const shape = [2, 2, 40, 40] as const; - const data = makeChunkData(shape); - - mockFetchChunk.mockResolvedValue({ - data, - shape, - chunkKey: "NEE:1:1", - localOffset: { localLat: 10, localLon: 10 }, + it("uses the fast pixel fetch on cache miss and prefetches native chunks", async () => { + mockFetchPixelTimeSeries.mockResolvedValue({ + values: new Float32Array([1, 2, 3, 4]), variable: "NEE", units: "gC m-2 h-1", }); + mockGetChunk.mockImplementation(async (coords: number[]) => { + const [timeChunkIdx] = coords; + const shape = [2, 2, 40, 40] as const; + const data = makeChunkData(shape); + for (let i = 0; i < data.length; i++) { + data[i] += timeChunkIdx * 10_000; + } + return { data, shape: [...shape] }; + }); const reader = new ZarrChunkReader(ds); const first = await reader.getTimeSeries(makeGrid(50, 50)); + + expect(mockFetchPixelTimeSeries).toHaveBeenCalledTimes(1); + expect(Array.from(first.values)).toEqual([1, 2, 3, 4]); + + await vi.waitFor(() => { + expect(mockGetChunk).toHaveBeenCalledTimes(2); + }); + }); + + it("serves nearby pixels from native cache after prefetch", async () => { + mockFetchPixelTimeSeries.mockResolvedValue({ + values: new Float32Array([1, 2, 3, 4]), + variable: "NEE", + units: "gC m-2 h-1", + }); + mockGetChunk.mockImplementation(async (coords: number[]) => { + const [timeChunkIdx] = coords; + const shape = [2, 2, 40, 40] as const; + const data = makeChunkData(shape); + for (let i = 0; i < data.length; i++) { + data[i] += timeChunkIdx * 10_000; + } + return { data, shape: [...shape] }; + }); + + const reader = new ZarrChunkReader(ds); + await reader.getTimeSeries(makeGrid(50, 50)); + await new Promise((resolve) => setTimeout(resolve, 0)); + + mockFetchPixelTimeSeries.mockClear(); + mockGetChunk.mockClear(); + const second = await reader.getTimeSeries(makeGrid(51, 51)); - expect(mockFetchChunk).toHaveBeenCalledTimes(1); - expect(first.units).toBe("gC m-2 h-1"); - expect(Array.from(first.values)).toEqual([110, 210, 1110, 1210]); - expect(Array.from(second.values)).toEqual([121, 221, 1121, 1221]); + expect(mockFetchPixelTimeSeries).not.toHaveBeenCalled(); + expect(mockGetChunk).not.toHaveBeenCalled(); + expect(Array.from(second.values)).toEqual([ + 121, 221, 1121, 1221, 10_121, 10_221, 11_121, 11_221, + ]); }); }); diff --git a/src/lib/zarr/ZarrChunkReader.ts b/src/lib/zarr/ZarrChunkReader.ts index 2c89187..e2af4ca 100644 --- a/src/lib/zarr/ZarrChunkReader.ts +++ b/src/lib/zarr/ZarrChunkReader.ts @@ -2,93 +2,192 @@ import * as zarr from "zarrita"; import { LRUCache } from "@/lib/cache/lru"; import { ZARR_STORE } from "@/lib/constants/store"; import { - extractTimeSeries, - pixelToChunkKey, - pixelToLocalOffset, + extractPixelFromNativeChunk, + nativeChunkKey, + pixelToNativeChunkContext, + stitchTimeSeries, + type ArrayChunkSizes, + type LocalOffset, + type PixelNativeChunkContext, } from "@/lib/zarr/chunks"; -import { fetchChunk, type ZarrStore } from "@/lib/zarr/store"; +import { fetchPixelTimeSeries, type ZarrStore } from "@/lib/zarr/store"; import type { GridCell } from "@/types/map"; -type CachedChunk = { +type CachedNativeChunk = { data: Float32Array; shape: readonly number[]; - units?: string; }; -type SpatialChunkSizes = { - chunkLat: number; - chunkLon: number; +type ZarrArray = { + shape: number[]; + chunks: number[]; + attrs: Record; + getChunk( + chunkCoords: number[], + ): Promise<{ data: Float32Array; shape: number[] }>; }; export class ZarrChunkReader { private ds: ZarrStore; - private cache: LRUCache; - private spatialChunksByVariable = new Map(); + private cache: LRUCache; + private arraysByVariable = new Map(); + private prefetchInFlight = new Map>(); - constructor(ds: ZarrStore, maxCacheSize = 16) { + /** Default holds ~8 pixels of full history (6 native chunks per pixel). */ + constructor(ds: ZarrStore, maxCacheSize = 48) { this.ds = ds; this.cache = new LRUCache(maxCacheSize); } - private async getSpatialChunkSizes( - variable: string, - ): Promise { - const cached = this.spatialChunksByVariable.get(variable); + private async getArray(variable: string): Promise { + const cached = this.arraysByVariable.get(variable); if (cached) return cached; - const array = await zarr.open(this.ds.root.resolve(variable), { + const array = (await zarr.open(this.ds.root.resolve(variable), { kind: "array", + })) as ZarrArray; + this.arraysByVariable.set(variable, array); + return array; + } + + private getChunkSizes(array: ZarrArray): ArrayChunkSizes { + const [time, hour, lat, lon] = array.chunks; + return { time, hour, lat, lon }; + } + + private nativeCoords( + context: PixelNativeChunkContext, + timeChunkIdx: number, + ) { + return { + timeChunkIdx, + hourChunkIdx: 0, + latChunkIdx: context.chunkLatIdx, + lonChunkIdx: context.chunkLonIdx, + }; + } + + private hasAllNativeChunks( + variable: string, + context: PixelNativeChunkContext, + ): boolean { + return context.timeChunkIndices.every((timeChunkIdx) => + this.cache.has( + nativeChunkKey(variable, this.nativeCoords(context, timeChunkIdx)), + ), + ); + } + + private async loadNativeChunk( + array: ZarrArray, + variable: string, + coords: { + timeChunkIdx: number; + hourChunkIdx: number; + latChunkIdx: number; + lonChunkIdx: number; + }, + ): Promise { + const key = nativeChunkKey(variable, coords); + const cached = this.cache.get(key); + if (cached) return cached; + + const chunk = await array.getChunk([ + coords.timeChunkIdx, + coords.hourChunkIdx, + coords.latChunkIdx, + coords.lonChunkIdx, + ]); + + const entry: CachedNativeChunk = { + data: chunk.data as Float32Array, + shape: chunk.shape, + }; + this.cache.set(key, entry); + return entry; + } + + private buildFromNativeCache( + variable: string, + context: PixelNativeChunkContext, + units?: string, + ): { values: Float32Array; variable: string; units?: string } { + const localOffset: LocalOffset = { + localLat: context.localLat, + localLon: context.localLon, + }; + + const segments = context.timeChunkIndices.map((timeChunkIdx) => { + const key = nativeChunkKey( + variable, + this.nativeCoords(context, timeChunkIdx), + ); + const chunk = this.cache.get(key)!; + return extractPixelFromNativeChunk( + chunk.data, + chunk.shape, + localOffset, + ); }); - const sizes: SpatialChunkSizes = { - chunkLat: array.chunks[2], - chunkLon: array.chunks[3], + + return { + values: stitchTimeSeries(segments), + variable, + units, }; - this.spatialChunksByVariable.set(variable, sizes); - return sizes; + } + + private prefetchNativeChunks( + array: ZarrArray, + variable: string, + context: PixelNativeChunkContext, + ): void { + const prefetchKey = `${variable}:${context.chunkLatIdx}:${context.chunkLonIdx}`; + if (this.prefetchInFlight.has(prefetchKey)) return; + + const missing = context.timeChunkIndices.filter( + (timeChunkIdx) => + !this.cache.has( + nativeChunkKey(variable, this.nativeCoords(context, timeChunkIdx)), + ), + ); + if (missing.length === 0) return; + + const promise = Promise.all( + missing.map((timeChunkIdx) => + this.loadNativeChunk(array, variable, this.nativeCoords(context, timeChunkIdx)), + ), + ) + .then(() => undefined) + .finally(() => { + this.prefetchInFlight.delete(prefetchKey); + }); + + this.prefetchInFlight.set(prefetchKey, promise); } async getTimeSeries( grid: GridCell, variable = ZARR_STORE.defaultVariable, ): Promise<{ values: Float32Array; variable: string; units?: string }> { - const { chunkLat, chunkLon } = await this.getSpatialChunkSizes(variable); - - const chunkKey = pixelToChunkKey( - variable, - grid.latIndex, - grid.lonIndex, - chunkLat, - chunkLon, - ); - const localOffset = pixelToLocalOffset( + const array = await this.getArray(variable); + const chunkSizes = this.getChunkSizes(array); + const [timeCount] = array.shape; + const context = pixelToNativeChunkContext( grid.latIndex, grid.lonIndex, - chunkLat, - chunkLon, + timeCount, + chunkSizes, ); + const units = + typeof array.attrs.units === "string" ? array.attrs.units : undefined; - let cached = this.cache.get(chunkKey); - if (!cached) { - const chunk = await fetchChunk( - this.ds, - grid.latIndex, - grid.lonIndex, - variable, - ); - cached = { - data: chunk.data, - shape: chunk.shape, - units: chunk.units, - }; - this.cache.set(chunkKey, cached); + if (this.hasAllNativeChunks(variable, context)) { + return this.buildFromNativeCache(variable, context, units); } - const values = extractTimeSeries( - cached.data, - cached.shape, - localOffset, - ); - - return { values, variable, units: cached.units }; + const pixel = await fetchPixelTimeSeries(this.ds, grid, variable); + this.prefetchNativeChunks(array, variable, context); + return pixel; } } diff --git a/src/lib/zarr/chunks.test.ts b/src/lib/zarr/chunks.test.ts index 0cbc2eb..7b18685 100644 --- a/src/lib/zarr/chunks.test.ts +++ b/src/lib/zarr/chunks.test.ts @@ -1,19 +1,23 @@ import { describe, expect, it } from "vitest"; import { chunkIndexToSlice, - extractTimeSeries, - pixelToChunkKey, + extractPixelFromNativeChunk, + nativeChunkKey, pixelToLocalOffset, - spatialChunkToSlices, + pixelToNativeChunkContext, + stitchTimeSeries, } from "@/lib/zarr/chunks"; describe("chunk helpers", () => { - it("maps pixels in the same spatial chunk to the same key", () => { - const keyA = pixelToChunkKey("NEE", 50, 50, 40, 40); - const keyB = pixelToChunkKey("NEE", 51, 51, 40, 40); - - expect(keyA).toBe("NEE:1:1"); - expect(keyB).toBe(keyA); + it("builds native chunk cache keys", () => { + expect( + nativeChunkKey("NEE", { + timeChunkIdx: 2, + hourChunkIdx: 0, + latChunkIdx: 1, + lonChunkIdx: 3, + }), + ).toBe("NEE:2:0:1:3"); }); it("computes local offsets inside a chunk", () => { @@ -31,16 +35,26 @@ describe("chunk helpers", () => { expect(chunkIndexToSlice(89, 40, 3600)).toEqual([3560, 3600]); }); - it("builds lat/lon slice ranges for a spatial chunk", () => { - expect(spatialChunkToSlices(1, 2, 40, 40, 3600, 7200)).toEqual({ - latSlice: [40, 80], - lonSlice: [80, 120], + it("lists native time-chunk indices for a pixel", () => { + expect( + pixelToNativeChunkContext(50, 50, 7670, { + time: 1461, + hour: 24, + lat: 40, + lon: 40, + }), + ).toEqual({ + chunkLatIdx: 1, + chunkLonIdx: 1, + localLat: 10, + localLon: 10, + timeChunkIndices: [0, 1, 2, 3, 4, 5], }); }); }); -describe("extractTimeSeries", () => { - it("pulls one pixel series out of a flattened 4D chunk", () => { +describe("extractPixelFromNativeChunk", () => { + it("pulls one pixel series out of a flattened native chunk", () => { const shape = [2, 2, 4, 4] as const; const [timeCount, hourCount, latCount, lonCount] = shape; const data = new Float32Array(timeCount * hourCount * latCount * lonCount); @@ -57,7 +71,7 @@ describe("extractTimeSeries", () => { } } - const series = extractTimeSeries(data, shape, { + const series = extractPixelFromNativeChunk(data, shape, { localLat: 1, localLon: 2, }); @@ -65,3 +79,16 @@ describe("extractTimeSeries", () => { expect(Array.from(series)).toEqual([12, 112, 1012, 1112]); }); }); + +describe("stitchTimeSeries", () => { + it("concatenates native-chunk segments in order", () => { + expect( + Array.from( + stitchTimeSeries([ + new Float32Array([1, 2]), + new Float32Array([3, 4]), + ]), + ), + ).toEqual([1, 2, 3, 4]); + }); +}); diff --git a/src/lib/zarr/chunks.ts b/src/lib/zarr/chunks.ts index 464d801..d39e28a 100644 --- a/src/lib/zarr/chunks.ts +++ b/src/lib/zarr/chunks.ts @@ -11,12 +11,26 @@ export type LocalOffset = { localLon: number; }; -export type SpatialChunkSlices = { - latSlice: AxisSlice; - lonSlice: AxisSlice; +export type ArrayChunkSizes = { + time: number; + hour: number; + lat: number; + lon: number; }; -/** Map a pixel index to its spatial chunk index along one axis. */ +export type NativeChunkCoords = { + timeChunkIdx: number; + hourChunkIdx: number; + latChunkIdx: number; + lonChunkIdx: number; +}; + +export type PixelNativeChunkContext = ChunkIndices & + LocalOffset & { + timeChunkIndices: number[]; + }; + +/** Map a pixel index to its chunk index along one axis. */ export function indexToChunkIndex(index: number, chunkSize: number): number { return Math.floor(index / chunkSize); } @@ -44,58 +58,65 @@ export function pixelToChunkIndices( }; } -/** Cache key for one spatial chunk of a variable. */ -export function pixelToChunkKey( - variable: string, +export function pixelToLocalOffset( latIndex: number, lonIndex: number, chunkLat: number, chunkLon: number, -): string { +): LocalOffset { const { chunkLatIdx, chunkLonIdx } = pixelToChunkIndices( latIndex, lonIndex, chunkLat, chunkLon, ); - return `${variable}:${chunkLatIdx}:${chunkLonIdx}`; + return { + localLat: latIndex - chunkLatIdx * chunkLat, + localLon: lonIndex - chunkLonIdx * chunkLon, + }; } -export function pixelToLocalOffset( +/** Cache key for one on-disk Zarr chunk. */ +export function nativeChunkKey( + variable: string, + coords: NativeChunkCoords, +): string { + return `${variable}:${coords.timeChunkIdx}:${coords.hourChunkIdx}:${coords.latChunkIdx}:${coords.lonChunkIdx}`; +} + +export function listTimeChunkIndices( + timeCount: number, + chunkTime: number, +): number[] { + const chunkCount = Math.ceil(timeCount / chunkTime); + return Array.from({ length: chunkCount }, (_, index) => index); +} + +/** Resolve native-chunk indices and local offsets for one pixel. */ +export function pixelToNativeChunkContext( latIndex: number, lonIndex: number, - chunkLat: number, - chunkLon: number, -): LocalOffset { + timeCount: number, + chunkSizes: ArrayChunkSizes, +): PixelNativeChunkContext { const { chunkLatIdx, chunkLonIdx } = pixelToChunkIndices( latIndex, lonIndex, - chunkLat, - chunkLon, + chunkSizes.lat, + chunkSizes.lon, ); - return { - localLat: latIndex - chunkLatIdx * chunkLat, - localLon: lonIndex - chunkLonIdx * chunkLon, - }; -} -/** Lat/lon slice ranges for fetching one spatial chunk from a 4D `[time, hour, lat, lon]` array. */ -export function spatialChunkToSlices( - chunkLatIdx: number, - chunkLonIdx: number, - chunkLat: number, - chunkLon: number, - latCount: number, - lonCount: number, -): SpatialChunkSlices { return { - latSlice: chunkIndexToSlice(chunkLatIdx, chunkLat, latCount), - lonSlice: chunkIndexToSlice(chunkLonIdx, chunkLon, lonCount), + chunkLatIdx, + chunkLonIdx, + localLat: latIndex - chunkLatIdx * chunkSizes.lat, + localLon: lonIndex - chunkLonIdx * chunkSizes.lon, + timeChunkIndices: listTimeChunkIndices(timeCount, chunkSizes.time), }; } -/** Pick one pixel's time series out of a downloaded spatial chunk. */ -export function extractTimeSeries( +/** Pick one pixel's series out of a single native on-disk chunk. */ +export function extractPixelFromNativeChunk( data: Float32Array, shape: readonly number[], localOffset: LocalOffset, @@ -116,3 +137,16 @@ export function extractTimeSeries( return series; } + +export function stitchTimeSeries(segments: Float32Array[]): Float32Array { + const length = segments.reduce((total, segment) => total + segment.length, 0); + const series = new Float32Array(length); + let offset = 0; + + for (const segment of segments) { + series.set(segment, offset); + offset += segment.length; + } + + return series; +} diff --git a/src/lib/zarr/store.ts b/src/lib/zarr/store.ts index 3b5c656..db9e3d2 100644 --- a/src/lib/zarr/store.ts +++ b/src/lib/zarr/store.ts @@ -1,84 +1,37 @@ import * as zarr from "zarrita"; import { ZARR_STORE } from "@/lib/constants/store"; -import { - pixelToChunkIndices, - pixelToChunkKey, - pixelToLocalOffset, - spatialChunkToSlices, - type LocalOffset, -} from "@/lib/zarr/chunks"; - -export { extractTimeSeries } from "@/lib/zarr/chunks"; +import type { GridCell } from "@/types/map"; export type ZarrStore = Awaited>; export async function openZarrStore(url = ZARR_STORE.url) { const raw = new zarr.FetchStore(url); - const store = await zarr.withConsolidatedMetadata(raw); + const consolidated = await zarr.withConsolidatedMetadata(raw); + const store = zarr.withByteCaching(consolidated); return { store, root: zarr.root(store), }; } -export type FetchedSpatialChunk = { - data: Float32Array; - shape: readonly number[]; - chunkKey: string; - localOffset: LocalOffset; - variable: string; - units?: string; -}; - -/** Download the full spatial Zarr chunk that contains `(latIndex, lonIndex)`. */ -export async function fetchChunk( +/** Fast path: one pixel, full time × hour, via zarrita's built-in slice assembly. */ +export async function fetchPixelTimeSeries( ds: ZarrStore, - latIndex: number, - lonIndex: number, + grid: GridCell, variable = ZARR_STORE.defaultVariable, -): Promise { +): Promise<{ values: Float32Array; variable: string; units?: string }> { const array = await zarr.open(ds.root.resolve(variable), { kind: "array" }); - const [, , latCount, lonCount] = array.shape; - const chunkLat = array.chunks[2]; - const chunkLon = array.chunks[3]; - - const { chunkLatIdx, chunkLonIdx } = pixelToChunkIndices( - latIndex, - lonIndex, - chunkLat, - chunkLon, - ); - - const { latSlice, lonSlice } = spatialChunkToSlices( - chunkLatIdx, - chunkLonIdx, - chunkLat, - chunkLon, - latCount, - lonCount, - ); - const result = await zarr.get(array, [ null, null, - zarr.slice(...latSlice), - zarr.slice(...lonSlice), + grid.latIndex, + grid.lonIndex, ]); - const units = typeof array.attrs.units === "string" ? array.attrs.units : undefined; return { - data: result.data as Float32Array, - shape: result.shape, - chunkKey: pixelToChunkKey( - variable, - latIndex, - lonIndex, - chunkLat, - chunkLon, - ), - localOffset: pixelToLocalOffset(latIndex, lonIndex, chunkLat, chunkLon), + values: result.data as Float32Array, variable, units, }; From a9ff46503c89437da1ad5594bfcc72d3cd25a425 Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Sun, 14 Jun 2026 22:13:10 +0300 Subject: [PATCH 3/4] refactor: optimize and improve after code review --- src/components/map/EarthMap.tsx | 21 ++++--- src/lib/cache/lru.test.ts | 15 +++++ src/lib/cache/lru.ts | 10 ++- src/lib/zarr/ZarrChunkReader.test.ts | 60 ++++++++++++++++-- src/lib/zarr/ZarrChunkReader.ts | 92 ++++++++++++++++++---------- src/lib/zarr/store.ts | 11 +++- 6 files changed, 155 insertions(+), 54 deletions(-) diff --git a/src/components/map/EarthMap.tsx b/src/components/map/EarthMap.tsx index 235e814..cc8959b 100644 --- a/src/components/map/EarthMap.tsx +++ b/src/components/map/EarthMap.tsx @@ -21,10 +21,7 @@ type EarthMapProps = { export function EarthMap({ className }: EarthMapProps) { const { isLight } = useTheme(); - const dsRef = useRef> | null>( - null, - ); - const readerRef = useRef(null); + const readerPromiseRef = useRef | null>(null); const requestIdRef = useRef(0); const [selection, setSelection] = useState(null); @@ -43,14 +40,18 @@ export function EarthMap({ className }: EarthMapProps) { setSeriesUnits(null); try { - if (!dsRef.current) { - dsRef.current = await openZarrStore(); - } - if (!readerRef.current) { - readerRef.current = new ZarrChunkReader(dsRef.current); + if (!readerPromiseRef.current) { + readerPromiseRef.current = openZarrStore() + .then((ds) => new ZarrChunkReader(ds)) + .catch((error) => { + readerPromiseRef.current = null; + throw error; + }); } - const { values, units } = await readerRef.current.getTimeSeries( + const reader = await readerPromiseRef.current; + + const { values, units } = await reader.getTimeSeries( nextSelection.grid, ); diff --git a/src/lib/cache/lru.test.ts b/src/lib/cache/lru.test.ts index d6d1b7e..8758a24 100644 --- a/src/lib/cache/lru.test.ts +++ b/src/lib/cache/lru.test.ts @@ -41,4 +41,19 @@ describe("LRUCache", () => { expect(cache.get("A")).toBeUndefined(); }); + + it("deletes a cached entry", () => { + const cache = new LRUCache(2); + cache.set("A", 1); + + expect(cache.delete("A")).toBe(true); + expect(cache.get("A")).toBeUndefined(); + }); + + it("clamps maxSize to at least 1", () => { + const cache = new LRUCache(0); + cache.set("A", 1); + + expect(cache.get("A")).toBe(1); + }); }); diff --git a/src/lib/cache/lru.ts b/src/lib/cache/lru.ts index ccf8c30..eac965d 100644 --- a/src/lib/cache/lru.ts +++ b/src/lib/cache/lru.ts @@ -1,10 +1,10 @@ -export class LRUCache { +export class LRUCache { private cache: Map; private maxSize: number; constructor(maxSize: number) { this.cache = new Map(); - this.maxSize = maxSize; + this.maxSize = Math.max(1, maxSize); } isCacheFull(): boolean { @@ -36,4 +36,8 @@ export class LRUCache { has(key: K): boolean { return this.cache.has(key); } -} \ No newline at end of file + + delete(key: K): boolean { + return this.cache.delete(key); + } +} diff --git a/src/lib/zarr/ZarrChunkReader.test.ts b/src/lib/zarr/ZarrChunkReader.test.ts index 381f145..6ad68cc 100644 --- a/src/lib/zarr/ZarrChunkReader.test.ts +++ b/src/lib/zarr/ZarrChunkReader.test.ts @@ -59,15 +59,29 @@ function makeChunkData(shape: readonly [number, number, number, number]) { describe("ZarrChunkReader", () => { const mockGetChunk = vi.fn(); - beforeEach(() => { - mockGetChunk.mockReset(); - mockFetchPixelTimeSeries.mockReset(); - mockOpen.mockResolvedValue({ + function stubArray() { + return { shape: [4, 2, 40, 40], chunks: [2, 2, 40, 40], attrs: { units: "gC m-2 h-1" }, getChunk: mockGetChunk, - } as never); + }; + } + + beforeEach(() => { + mockGetChunk.mockReset(); + mockFetchPixelTimeSeries.mockReset(); + mockOpen.mockReset(); + mockOpen.mockImplementation(async () => stubArray() as never); + mockGetChunk.mockImplementation(async (coords: number[]) => { + const [timeChunkIdx] = coords; + const shape = [2, 2, 40, 40] as const; + const data = makeChunkData(shape); + for (let i = 0; i < data.length; i++) { + data[i] += timeChunkIdx * 10_000; + } + return { data, shape: [...shape] }; + }); }); it("uses the fast pixel fetch on cache miss and prefetches native chunks", async () => { @@ -115,7 +129,9 @@ describe("ZarrChunkReader", () => { const reader = new ZarrChunkReader(ds); await reader.getTimeSeries(makeGrid(50, 50)); - await new Promise((resolve) => setTimeout(resolve, 0)); + await vi.waitFor(() => { + expect(mockGetChunk).toHaveBeenCalledTimes(2); + }); mockFetchPixelTimeSeries.mockClear(); mockGetChunk.mockClear(); @@ -128,4 +144,36 @@ describe("ZarrChunkReader", () => { 121, 221, 1121, 1221, 10_121, 10_221, 11_121, 11_221, ]); }); + + it("opens each variable only once under concurrent requests", async () => { + mockFetchPixelTimeSeries.mockResolvedValue({ + values: new Float32Array([1, 2, 3, 4]), + variable: "NEE", + units: "gC m-2 h-1", + }); + + const reader = new ZarrChunkReader(ds); + await Promise.all([ + reader.getTimeSeries(makeGrid(50, 50)), + reader.getTimeSeries(makeGrid(80, 80)), + ]); + + expect(mockOpen).toHaveBeenCalledTimes(1); + }); + + it("dedupes concurrent prefetch for the same spatial block", async () => { + mockFetchPixelTimeSeries.mockResolvedValue({ + values: new Float32Array([1, 2, 3, 4]), + variable: "NEE", + units: "gC m-2 h-1", + }); + + const reader = new ZarrChunkReader(ds); + await Promise.all([ + reader.getTimeSeries(makeGrid(50, 50)), + reader.getTimeSeries(makeGrid(51, 51)), + ]); + + expect(mockGetChunk).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/lib/zarr/ZarrChunkReader.ts b/src/lib/zarr/ZarrChunkReader.ts index e2af4ca..c25c9b2 100644 --- a/src/lib/zarr/ZarrChunkReader.ts +++ b/src/lib/zarr/ZarrChunkReader.ts @@ -10,7 +10,11 @@ import { type LocalOffset, type PixelNativeChunkContext, } from "@/lib/zarr/chunks"; -import { fetchPixelTimeSeries, type ZarrStore } from "@/lib/zarr/store"; +import { + fetchPixelTimeSeries, + type ZarrArrayHandle, + type ZarrStore, +} from "@/lib/zarr/store"; import type { GridCell } from "@/types/map"; type CachedNativeChunk = { @@ -18,10 +22,7 @@ type CachedNativeChunk = { shape: readonly number[]; }; -type ZarrArray = { - shape: number[]; - chunks: number[]; - attrs: Record; +type ZarrArray = ZarrArrayHandle & { getChunk( chunkCoords: number[], ): Promise<{ data: Float32Array; shape: number[] }>; @@ -30,7 +31,8 @@ type ZarrArray = { export class ZarrChunkReader { private ds: ZarrStore; private cache: LRUCache; - private arraysByVariable = new Map(); + private arrayPromises = new Map>(); + private chunkLoadsInFlight = new Map>(); private prefetchInFlight = new Map>(); /** Default holds ~8 pixels of full history (6 native chunks per pixel). */ @@ -39,15 +41,20 @@ export class ZarrChunkReader { this.cache = new LRUCache(maxCacheSize); } - private async getArray(variable: string): Promise { - const cached = this.arraysByVariable.get(variable); - if (cached) return cached; + private getArray(variable: string): Promise { + const existing = this.arrayPromises.get(variable); + if (existing) return existing; + + const promise = zarr + .open(this.ds.root.resolve(variable), { kind: "array" }) + .then((array) => array as ZarrArray) + .catch((error) => { + this.arrayPromises.delete(variable); + throw error; + }); - const array = (await zarr.open(this.ds.root.resolve(variable), { - kind: "array", - })) as ZarrArray; - this.arraysByVariable.set(variable, array); - return array; + this.arrayPromises.set(variable, promise); + return promise; } private getChunkSizes(array: ZarrArray): ArrayChunkSizes { @@ -78,7 +85,7 @@ export class ZarrChunkReader { ); } - private async loadNativeChunk( + private loadNativeChunk( array: ZarrArray, variable: string, coords: { @@ -90,21 +97,36 @@ export class ZarrChunkReader { ): Promise { const key = nativeChunkKey(variable, coords); const cached = this.cache.get(key); - if (cached) return cached; - - const chunk = await array.getChunk([ - coords.timeChunkIdx, - coords.hourChunkIdx, - coords.latChunkIdx, - coords.lonChunkIdx, - ]); - - const entry: CachedNativeChunk = { - data: chunk.data as Float32Array, - shape: chunk.shape, - }; - this.cache.set(key, entry); - return entry; + if (cached) return Promise.resolve(cached); + + const inFlight = this.chunkLoadsInFlight.get(key); + if (inFlight) return inFlight; + + const promise = array + .getChunk([ + coords.timeChunkIdx, + coords.hourChunkIdx, + coords.latChunkIdx, + coords.lonChunkIdx, + ]) + .then((chunk: { data: Float32Array; shape: number[] }) => { + const entry: CachedNativeChunk = { + data: chunk.data as Float32Array, + shape: chunk.shape, + }; + this.cache.set(key, entry); + return entry; + }) + .catch((error: unknown) => { + this.chunkLoadsInFlight.delete(key); + throw error; + }) + .finally(() => { + this.chunkLoadsInFlight.delete(key); + }); + + this.chunkLoadsInFlight.set(key, promise); + return promise; } private buildFromNativeCache( @@ -149,13 +171,19 @@ export class ZarrChunkReader { (timeChunkIdx) => !this.cache.has( nativeChunkKey(variable, this.nativeCoords(context, timeChunkIdx)), + ) && !this.chunkLoadsInFlight.has( + nativeChunkKey(variable, this.nativeCoords(context, timeChunkIdx)), ), ); if (missing.length === 0) return; const promise = Promise.all( missing.map((timeChunkIdx) => - this.loadNativeChunk(array, variable, this.nativeCoords(context, timeChunkIdx)), + this.loadNativeChunk( + array, + variable, + this.nativeCoords(context, timeChunkIdx), + ), ), ) .then(() => undefined) @@ -186,7 +214,7 @@ export class ZarrChunkReader { return this.buildFromNativeCache(variable, context, units); } - const pixel = await fetchPixelTimeSeries(this.ds, grid, variable); + const pixel = await fetchPixelTimeSeries(array, grid, variable); this.prefetchNativeChunks(array, variable, context); return pixel; } diff --git a/src/lib/zarr/store.ts b/src/lib/zarr/store.ts index db9e3d2..036df4a 100644 --- a/src/lib/zarr/store.ts +++ b/src/lib/zarr/store.ts @@ -4,6 +4,12 @@ import type { GridCell } from "@/types/map"; export type ZarrStore = Awaited>; +export type ZarrArrayHandle = { + attrs: Record; + shape: number[]; + chunks: number[]; +}; + export async function openZarrStore(url = ZARR_STORE.url) { const raw = new zarr.FetchStore(url); const consolidated = await zarr.withConsolidatedMetadata(raw); @@ -16,12 +22,11 @@ export async function openZarrStore(url = ZARR_STORE.url) { /** Fast path: one pixel, full time × hour, via zarrita's built-in slice assembly. */ export async function fetchPixelTimeSeries( - ds: ZarrStore, + array: ZarrArrayHandle, grid: GridCell, variable = ZARR_STORE.defaultVariable, ): Promise<{ values: Float32Array; variable: string; units?: string }> { - const array = await zarr.open(ds.root.resolve(variable), { kind: "array" }); - const result = await zarr.get(array, [ + const result = await zarr.get(array as Parameters[0], [ null, null, grid.latIndex, From fc290ec35d2b6005feb8482d8f5a86ddf26c3c65 Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Sun, 21 Jun 2026 11:44:09 +0300 Subject: [PATCH 4/4] Default pixel fetches to the last year and add a history slider. Slice Zarr reads by the selected window for faster first loads, and harden background prefetch against unhandled rejections. Co-authored-by: Cursor --- src/app/globals.css | 41 ++++++++++++ src/components/map/EarthMap.tsx | 101 ++++++++++++++++++------------ src/components/map/MapReadout.tsx | 32 ++++++++++ src/lib/zarr/ZarrChunkReader.ts | 49 ++++++++++----- src/lib/zarr/chunks.ts | 55 +++++++++++++++- src/lib/zarr/store.ts | 11 +++- src/lib/zarr/timeRange.test.ts | 65 +++++++++++++++++++ src/lib/zarr/timeRange.ts | 31 +++++++++ 8 files changed, 327 insertions(+), 58 deletions(-) create mode 100644 src/lib/zarr/timeRange.test.ts create mode 100644 src/lib/zarr/timeRange.ts diff --git a/src/app/globals.css b/src/app/globals.css index 1ba6b00..5e205ea 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -727,6 +727,47 @@ button { line-height: 1.55; } +.map-readout-control { + display: grid; + gap: 10px; + margin-top: 16px; + padding-top: 14px; + border-top: 1px solid var(--border-strong); +} + +.map-readout-control-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; +} + +.map-readout-control-label { + font-size: 12px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-muted); + font-weight: 500; +} + +.map-readout-control-value { + font-size: 13px; + color: var(--accent); + font-weight: 600; +} + +.map-readout-slider { + width: 100%; + accent-color: var(--accent); + cursor: pointer; +} + +.map-readout-control-hint { + color: var(--text-dim); + font-size: 12px; + line-height: 1.45; +} + .map-readout-grid { display: grid; gap: 12px; diff --git a/src/components/map/EarthMap.tsx b/src/components/map/EarthMap.tsx index cc8959b..a8b5205 100644 --- a/src/components/map/EarthMap.tsx +++ b/src/components/map/EarthMap.tsx @@ -11,6 +11,7 @@ import { TEAL_ON_DARK_RGB } from "@/lib/constants/theme"; import { DEFAULT_MAP_VIEW, MAP_BASE_STYLES } from "@/lib/map/viewState"; import { openZarrStore } from "@/lib/zarr/store"; import { ZarrChunkReader } from "@/lib/zarr/ZarrChunkReader"; +import { DEFAULT_HISTORY_YEARS } from "@/lib/zarr/timeRange"; import type { MapSelection } from "@/types/map"; import { useTheme } from "@/providers/ThemeProvider"; import { MapReadout } from "@/components/map/MapReadout"; @@ -25,54 +26,70 @@ export function EarthMap({ className }: EarthMapProps) { const requestIdRef = useRef(0); const [selection, setSelection] = useState(null); + const [historyYears, setHistoryYears] = useState(DEFAULT_HISTORY_YEARS); const [loadingSeries, setLoadingSeries] = useState(false); const [seriesError, setSeriesError] = useState(null); const [seriesLength, setSeriesLength] = useState(null); const [seriesPreview, setSeriesPreview] = useState(null); const [seriesUnits, setSeriesUnits] = useState(null); - const loadTimeSeries = useCallback(async (nextSelection: MapSelection) => { - const requestId = ++requestIdRef.current; - setLoadingSeries(true); - setSeriesError(null); - setSeriesLength(null); - setSeriesPreview(null); - setSeriesUnits(null); - - try { - if (!readerPromiseRef.current) { - readerPromiseRef.current = openZarrStore() - .then((ds) => new ZarrChunkReader(ds)) - .catch((error) => { - readerPromiseRef.current = null; - throw error; - }); + const loadTimeSeries = useCallback( + async (nextSelection: MapSelection, years: number) => { + const requestId = ++requestIdRef.current; + setLoadingSeries(true); + setSeriesError(null); + setSeriesLength(null); + setSeriesPreview(null); + setSeriesUnits(null); + + try { + if (!readerPromiseRef.current) { + readerPromiseRef.current = openZarrStore() + .then((ds) => new ZarrChunkReader(ds)) + .catch((error) => { + readerPromiseRef.current = null; + throw error; + }); + } + + const reader = await readerPromiseRef.current; + + const { values, units } = await reader.getTimeSeries( + nextSelection.grid, + undefined, + years, + ); + + if (requestId !== requestIdRef.current) return; + + setSeriesLength(values.length); + setSeriesPreview(Array.from(values.subarray(0, 3))); + setSeriesUnits(units ?? null); + } catch (error) { + if (requestId !== requestIdRef.current) return; + setSeriesError( + error instanceof Error + ? error.message + : "Could not load the Zarr time series.", + ); + } finally { + if (requestId === requestIdRef.current) { + setLoadingSeries(false); + } } + }, + [], + ); - const reader = await readerPromiseRef.current; - - const { values, units } = await reader.getTimeSeries( - nextSelection.grid, - ); - - if (requestId !== requestIdRef.current) return; - - setSeriesLength(values.length); - setSeriesPreview(Array.from(values.subarray(0, 3))); - setSeriesUnits(units ?? null); - } catch (error) { - if (requestId !== requestIdRef.current) return; - setSeriesError( - error instanceof Error - ? error.message - : "Could not load the Zarr time series.", - ); - } finally { - if (requestId === requestIdRef.current) { - setLoadingSeries(false); + const handleHistoryYearsChange = useCallback( + (years: number) => { + setHistoryYears(years); + if (selection) { + void loadTimeSeries(selection, years); } - } - }, []); + }, + [selection, loadTimeSeries], + ); const handleClick = useCallback( (info: PickingInfo) => { @@ -85,9 +102,9 @@ export function EarthMap({ className }: EarthMapProps) { }; setSelection(nextSelection); - void loadTimeSeries(nextSelection); + void loadTimeSeries(nextSelection, historyYears); }, - [loadTimeSeries], + [historyYears, loadTimeSeries], ); const layers = useMemo(() => { @@ -130,6 +147,8 @@ export function EarthMap({ className }: EarthMapProps) { void; loadingSeries: boolean; seriesError: string | null; seriesLength: number | null; @@ -15,12 +18,17 @@ type MapReadoutProps = { export function MapReadout({ selection, + historyYears, + onHistoryYearsChange, loadingSeries, seriesError, seriesLength, seriesPreview, seriesUnits, }: MapReadoutProps) { + const historyLabel = + historyYears === 1 ? "Last 1 year" : `Last ${historyYears} years`; + return (