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 (
+
+
+
+ {historyLabel}
+
+
+ onHistoryYearsChange(Number(event.currentTarget.value))
+ }
+ />
+
+ Fetch only the most recent window to keep pixel loads fast.
+
+
+
{selection ? (
diff --git a/src/lib/zarr/ZarrChunkReader.ts b/src/lib/zarr/ZarrChunkReader.ts
index c25c9b2..fc48afa 100644
--- a/src/lib/zarr/ZarrChunkReader.ts
+++ b/src/lib/zarr/ZarrChunkReader.ts
@@ -5,11 +5,17 @@ import {
extractPixelFromNativeChunk,
nativeChunkKey,
pixelToNativeChunkContext,
- stitchTimeSeries,
+ stitchTimeSeriesForRange,
type ArrayChunkSizes,
+ type AxisSlice,
type LocalOffset,
type PixelNativeChunkContext,
} from "@/lib/zarr/chunks";
+import {
+ chunkIndexToStartDay,
+ DEFAULT_HISTORY_YEARS,
+ yearsToDayRange,
+} from "@/lib/zarr/timeRange";
import {
fetchPixelTimeSeries,
type ZarrArrayHandle,
@@ -117,10 +123,6 @@ export class ZarrChunkReader {
this.cache.set(key, entry);
return entry;
})
- .catch((error: unknown) => {
- this.chunkLoadsInFlight.delete(key);
- throw error;
- })
.finally(() => {
this.chunkLoadsInFlight.delete(key);
});
@@ -132,6 +134,9 @@ export class ZarrChunkReader {
private buildFromNativeCache(
variable: string,
context: PixelNativeChunkContext,
+ timeRange: AxisSlice,
+ hourCount: number,
+ chunkTime: number,
units?: string,
): { values: Float32Array; variable: string; units?: string } {
const localOffset: LocalOffset = {
@@ -145,15 +150,18 @@ export class ZarrChunkReader {
this.nativeCoords(context, timeChunkIdx),
);
const chunk = this.cache.get(key)!;
- return extractPixelFromNativeChunk(
- chunk.data,
- chunk.shape,
- localOffset,
- );
+ return {
+ values: extractPixelFromNativeChunk(
+ chunk.data,
+ chunk.shape,
+ localOffset,
+ ),
+ chunkStartDay: chunkIndexToStartDay(timeChunkIdx, chunkTime),
+ };
});
return {
- values: stitchTimeSeries(segments),
+ values: stitchTimeSeriesForRange(segments, timeRange, hourCount),
variable,
units,
};
@@ -187,6 +195,9 @@ export class ZarrChunkReader {
),
)
.then(() => undefined)
+ .catch(() => {
+ // Prefetch is best-effort; explicit requests retry failed chunks.
+ })
.finally(() => {
this.prefetchInFlight.delete(prefetchKey);
});
@@ -197,24 +208,34 @@ export class ZarrChunkReader {
async getTimeSeries(
grid: GridCell,
variable = ZARR_STORE.defaultVariable,
+ historyYears?: number,
): Promise<{ values: Float32Array; variable: string; units?: string }> {
const array = await this.getArray(variable);
const chunkSizes = this.getChunkSizes(array);
- const [timeCount] = array.shape;
+ const [timeCount, hourCount] = array.shape;
+ const timeRange = yearsToDayRange(historyYears ?? DEFAULT_HISTORY_YEARS, timeCount);
const context = pixelToNativeChunkContext(
grid.latIndex,
grid.lonIndex,
timeCount,
chunkSizes,
+ timeRange,
);
const units =
typeof array.attrs.units === "string" ? array.attrs.units : undefined;
if (this.hasAllNativeChunks(variable, context)) {
- return this.buildFromNativeCache(variable, context, units);
+ return this.buildFromNativeCache(
+ variable,
+ context,
+ timeRange,
+ hourCount,
+ chunkSizes.time,
+ units,
+ );
}
- const pixel = await fetchPixelTimeSeries(array, grid, variable);
+ const pixel = await fetchPixelTimeSeries(array, grid, variable, timeRange);
this.prefetchNativeChunks(array, variable, context);
return pixel;
}
diff --git a/src/lib/zarr/chunks.ts b/src/lib/zarr/chunks.ts
index d39e28a..046d1e9 100644
--- a/src/lib/zarr/chunks.ts
+++ b/src/lib/zarr/chunks.ts
@@ -98,6 +98,7 @@ export function pixelToNativeChunkContext(
lonIndex: number,
timeCount: number,
chunkSizes: ArrayChunkSizes,
+ timeRange?: AxisSlice,
): PixelNativeChunkContext {
const { chunkLatIdx, chunkLonIdx } = pixelToChunkIndices(
latIndex,
@@ -106,15 +107,38 @@ export function pixelToNativeChunkContext(
chunkSizes.lon,
);
+ const timeChunkIndices = timeRange
+ ? listTimeChunkIndicesForRange(timeRange, chunkSizes.time, timeCount)
+ : listTimeChunkIndices(timeCount, chunkSizes.time);
+
return {
chunkLatIdx,
chunkLonIdx,
localLat: latIndex - chunkLatIdx * chunkSizes.lat,
localLon: lonIndex - chunkLonIdx * chunkSizes.lon,
- timeChunkIndices: listTimeChunkIndices(timeCount, chunkSizes.time),
+ timeChunkIndices,
};
}
+/** List native time-chunk indices that overlap a day slice. */
+export function listTimeChunkIndicesForRange(
+ timeRange: AxisSlice,
+ chunkTime: number,
+ totalDays: number,
+): number[] {
+ const [start, stop] = timeRange;
+ const clampedStart = Math.max(0, start);
+ const clampedStop = Math.min(stop, totalDays);
+ if (clampedStart >= clampedStop) return [];
+
+ const firstChunk = Math.floor(clampedStart / chunkTime);
+ const lastChunk = Math.floor((clampedStop - 1) / chunkTime);
+ return Array.from(
+ { length: lastChunk - firstChunk + 1 },
+ (_, index) => firstChunk + index,
+ );
+}
+
/** Pick one pixel's series out of a single native on-disk chunk. */
export function extractPixelFromNativeChunk(
data: Float32Array,
@@ -150,3 +174,32 @@ export function stitchTimeSeries(segments: Float32Array[]): Float32Array {
return series;
}
+
+type TimeChunkSegment = {
+ values: Float32Array;
+ chunkStartDay: number;
+};
+
+/** Trim stitched native-chunk segments to a day slice. */
+export function stitchTimeSeriesForRange(
+ segments: TimeChunkSegment[],
+ timeRange: AxisSlice,
+ hourCount: number,
+): Float32Array {
+ const [rangeStart, rangeStop] = timeRange;
+ const parts: Float32Array[] = [];
+
+ for (const { values, chunkStartDay } of segments) {
+ const chunkDayCount = values.length / hourCount;
+ const chunkStopDay = chunkStartDay + chunkDayCount;
+ const overlapStart = Math.max(rangeStart, chunkStartDay);
+ const overlapStop = Math.min(rangeStop, chunkStopDay);
+ if (overlapStart >= overlapStop) continue;
+
+ const localStart = overlapStart - chunkStartDay;
+ const localStop = overlapStop - chunkStartDay;
+ parts.push(values.subarray(localStart * hourCount, localStop * hourCount));
+ }
+
+ return stitchTimeSeries(parts);
+}
diff --git a/src/lib/zarr/store.ts b/src/lib/zarr/store.ts
index 036df4a..5be3c4f 100644
--- a/src/lib/zarr/store.ts
+++ b/src/lib/zarr/store.ts
@@ -1,5 +1,6 @@
import * as zarr from "zarrita";
import { ZARR_STORE } from "@/lib/constants/store";
+import type { AxisSlice } from "@/lib/zarr/chunks";
import type { GridCell } from "@/types/map";
export type ZarrStore = Awaited>;
@@ -20,14 +21,20 @@ export async function openZarrStore(url = ZARR_STORE.url) {
};
}
-/** Fast path: one pixel, full time × hour, via zarrita's built-in slice assembly. */
+/** One pixel, time × hour slice, via zarrita's built-in slice assembly. */
export async function fetchPixelTimeSeries(
array: ZarrArrayHandle,
grid: GridCell,
variable = ZARR_STORE.defaultVariable,
+ timeRange?: AxisSlice,
): Promise<{ values: Float32Array; variable: string; units?: string }> {
+ const timeSelection =
+ timeRange === undefined
+ ? null
+ : zarr.slice(timeRange[0], timeRange[1]);
+
const result = await zarr.get(array as Parameters[0], [
- null,
+ timeSelection,
null,
grid.latIndex,
grid.lonIndex,
diff --git a/src/lib/zarr/timeRange.test.ts b/src/lib/zarr/timeRange.test.ts
new file mode 100644
index 0000000..424b8e6
--- /dev/null
+++ b/src/lib/zarr/timeRange.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, it } from "vitest";
+import {
+ listTimeChunkIndicesForRange,
+ pixelToNativeChunkContext,
+ stitchTimeSeriesForRange,
+} from "@/lib/zarr/chunks";
+import { yearsToDayRange } from "@/lib/zarr/timeRange";
+
+describe("yearsToDayRange", () => {
+ it("defaults to the most recent year of data", () => {
+ expect(yearsToDayRange(1, 7670)).toEqual([7305, 7670]);
+ });
+
+ it("clamps to the full archive when requested", () => {
+ expect(yearsToDayRange(21, 7670)).toEqual([0, 7670]);
+ });
+});
+
+describe("listTimeChunkIndicesForRange", () => {
+ it("returns only chunks overlapping the last year", () => {
+ expect(listTimeChunkIndicesForRange([7305, 7670], 1461, 7670)).toEqual([5]);
+ });
+
+ it("returns all chunks for the full archive", () => {
+ expect(listTimeChunkIndicesForRange([0, 7670], 1461, 7670)).toEqual([
+ 0, 1, 2, 3, 4, 5,
+ ]);
+ });
+});
+
+describe("pixelToNativeChunkContext with range", () => {
+ it("limits native chunk indices to the requested window", () => {
+ expect(
+ pixelToNativeChunkContext(50, 50, 7670, {
+ time: 1461,
+ hour: 24,
+ lat: 40,
+ lon: 40,
+ }, [7305, 7670]),
+ ).toEqual({
+ chunkLatIdx: 1,
+ chunkLonIdx: 1,
+ localLat: 10,
+ localLon: 10,
+ timeChunkIndices: [5],
+ });
+ });
+});
+
+describe("stitchTimeSeriesForRange", () => {
+ it("trims partial overlap at chunk boundaries", () => {
+ const stitched = stitchTimeSeriesForRange(
+ [
+ {
+ chunkStartDay: 7305,
+ values: new Float32Array([1, 2, 3, 4, 5, 6]),
+ },
+ ],
+ [7306, 7307],
+ 2,
+ );
+
+ expect(Array.from(stitched)).toEqual([3, 4]);
+ });
+});
diff --git a/src/lib/zarr/timeRange.ts b/src/lib/zarr/timeRange.ts
new file mode 100644
index 0000000..0673048
--- /dev/null
+++ b/src/lib/zarr/timeRange.ts
@@ -0,0 +1,31 @@
+import { ZARR_STORE } from "@/lib/constants/store";
+import type { AxisSlice } from "@/lib/zarr/chunks";
+
+export const DEFAULT_HISTORY_YEARS = 1;
+
+export const ZARR_TIME = {
+ totalDays: ZARR_STORE.dimensions.time,
+ hoursPerDay: ZARR_STORE.dimensions.hour,
+ defaultHistoryYears: DEFAULT_HISTORY_YEARS,
+ /** Whole years covered by the archive (~21 for FLUXCOM-X NEE). */
+ maxHistoryYears: Math.ceil(ZARR_STORE.dimensions.time / 365.25),
+} as const;
+
+/** Map a "last N years" control value to a half-open day slice `[start, stop)`. */
+export function yearsToDayRange(
+ years: number,
+ totalDays?: number,
+): AxisSlice {
+ const stop = totalDays ?? ZARR_TIME.totalDays;
+
+ if (years >= ZARR_TIME.maxHistoryYears) {
+ return [0, stop];
+ }
+
+ const start = Math.max(0, stop - Math.round(years * 365));
+ return [start, stop];
+}
+
+export function chunkIndexToStartDay(chunkIdx: number, chunkTime: number): number {
+ return chunkIdx * chunkTime;
+}