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..cc8959b 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"; @@ -20,14 +21,13 @@ type EarthMapProps = { export function EarthMap({ className }: EarthMapProps) { const { isLight } = useTheme(); - const dsRef = useRef> | null>( - null, - ); + const readerPromiseRef = useRef | null>(null); const requestIdRef = useRef(0); 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); @@ -35,22 +35,30 @@ export function EarthMap({ className }: EarthMapProps) { const requestId = ++requestIdRef.current; setLoadingSeries(true); setSeriesError(null); + setSeriesLength(null); setSeriesPreview(null); setSeriesUnits(null); try { - if (!dsRef.current) { - dsRef.current = await openZarrStore(); + if (!readerPromiseRef.current) { + readerPromiseRef.current = openZarrStore() + .then((ds) => new ZarrChunkReader(ds)) + .catch((error) => { + readerPromiseRef.current = null; + throw error; + }); } - const { values, units } = await fetchZarrTimeSeries( - dsRef.current, + const reader = await readerPromiseRef.current; + + const { values, units } = await reader.getTimeSeries( nextSelection.grid, ); 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; @@ -124,6 +132,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 new file mode 100644 index 0000000..8758a24 --- /dev/null +++ b/src/lib/cache/lru.test.ts @@ -0,0 +1,59 @@ +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); + }); + + 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(); + }); + + 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 new file mode 100644 index 0000000..eac965d --- /dev/null +++ b/src/lib/cache/lru.ts @@ -0,0 +1,43 @@ +export class LRUCache { + private cache: Map; + private maxSize: number; + + constructor(maxSize: number) { + this.cache = new Map(); + this.maxSize = Math.max(1, 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); + } + + has(key: K): boolean { + return this.cache.has(key); + } + + 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 new file mode 100644 index 0000000..6ad68cc --- /dev/null +++ b/src/lib/zarr/ZarrChunkReader.test.ts @@ -0,0 +1,179 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as zarr from "zarrita"; +import { ZarrChunkReader } from "@/lib/zarr/ZarrChunkReader"; +import { fetchPixelTimeSeries, type ZarrStore } from "@/lib/zarr/store"; +import type { GridCell } from "@/types/map"; + +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, + fetchPixelTimeSeries: vi.fn(), + }; +}); + +const mockOpen = vi.mocked(zarr.open); +const mockFetchPixelTimeSeries = vi.mocked(fetchPixelTimeSeries); + +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", () => { + const mockGetChunk = vi.fn(); + + function stubArray() { + return { + shape: [4, 2, 40, 40], + chunks: [2, 2, 40, 40], + attrs: { units: "gC m-2 h-1" }, + getChunk: mockGetChunk, + }; + } + + 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 () => { + 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 vi.waitFor(() => { + expect(mockGetChunk).toHaveBeenCalledTimes(2); + }); + + mockFetchPixelTimeSeries.mockClear(); + mockGetChunk.mockClear(); + + const second = await reader.getTimeSeries(makeGrid(51, 51)); + + 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, + ]); + }); + + 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 new file mode 100644 index 0000000..c25c9b2 --- /dev/null +++ b/src/lib/zarr/ZarrChunkReader.ts @@ -0,0 +1,221 @@ +import * as zarr from "zarrita"; +import { LRUCache } from "@/lib/cache/lru"; +import { ZARR_STORE } from "@/lib/constants/store"; +import { + extractPixelFromNativeChunk, + nativeChunkKey, + pixelToNativeChunkContext, + stitchTimeSeries, + type ArrayChunkSizes, + type LocalOffset, + type PixelNativeChunkContext, +} from "@/lib/zarr/chunks"; +import { + fetchPixelTimeSeries, + type ZarrArrayHandle, + type ZarrStore, +} from "@/lib/zarr/store"; +import type { GridCell } from "@/types/map"; + +type CachedNativeChunk = { + data: Float32Array; + shape: readonly number[]; +}; + +type ZarrArray = ZarrArrayHandle & { + getChunk( + chunkCoords: number[], + ): Promise<{ data: Float32Array; shape: number[] }>; +}; + +export class ZarrChunkReader { + private ds: ZarrStore; + private cache: LRUCache; + private arrayPromises = new Map>(); + private chunkLoadsInFlight = new Map>(); + private prefetchInFlight = new Map>(); + + /** 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 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; + }); + + this.arrayPromises.set(variable, promise); + return promise; + } + + 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 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 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( + 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, + ); + }); + + return { + values: stitchTimeSeries(segments), + variable, + units, + }; + } + + 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)), + ) && !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), + ), + ), + ) + .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 array = await this.getArray(variable); + const chunkSizes = this.getChunkSizes(array); + const [timeCount] = array.shape; + const context = pixelToNativeChunkContext( + grid.latIndex, + grid.lonIndex, + timeCount, + chunkSizes, + ); + const units = + typeof array.attrs.units === "string" ? array.attrs.units : undefined; + + if (this.hasAllNativeChunks(variable, context)) { + return this.buildFromNativeCache(variable, context, units); + } + + const pixel = await fetchPixelTimeSeries(array, 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 new file mode 100644 index 0000000..7b18685 --- /dev/null +++ b/src/lib/zarr/chunks.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { + chunkIndexToSlice, + extractPixelFromNativeChunk, + nativeChunkKey, + pixelToLocalOffset, + pixelToNativeChunkContext, + stitchTimeSeries, +} from "@/lib/zarr/chunks"; + +describe("chunk helpers", () => { + 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", () => { + 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("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("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); + + 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 = extractPixelFromNativeChunk(data, shape, { + localLat: 1, + localLon: 2, + }); + + 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 new file mode 100644 index 0000000..d39e28a --- /dev/null +++ b/src/lib/zarr/chunks.ts @@ -0,0 +1,152 @@ +/** 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 ArrayChunkSizes = { + time: number; + hour: number; + lat: number; + lon: number; +}; + +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); +} + +/** 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), + }; +} + +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, + }; +} + +/** 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, + timeCount: number, + chunkSizes: ArrayChunkSizes, +): PixelNativeChunkContext { + const { chunkLatIdx, chunkLonIdx } = pixelToChunkIndices( + latIndex, + lonIndex, + chunkSizes.lat, + chunkSizes.lon, + ); + + return { + chunkLatIdx, + chunkLonIdx, + localLat: latIndex - chunkLatIdx * chunkSizes.lat, + localLon: lonIndex - chunkLonIdx * chunkSizes.lon, + timeChunkIndices: listTimeChunkIndices(timeCount, chunkSizes.time), + }; +} + +/** Pick one pixel's series out of a single native on-disk chunk. */ +export function extractPixelFromNativeChunk( + 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; +} + +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 7968058..036df4a 100644 --- a/src/lib/zarr/store.ts +++ b/src/lib/zarr/store.ts @@ -4,22 +4,34 @@ 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 store = await zarr.withConsolidatedMetadata(raw); + const consolidated = await zarr.withConsolidatedMetadata(raw); + const store = zarr.withByteCaching(consolidated); return { store, root: zarr.root(store), }; } -export async function fetchZarrTimeSeries( - ds: ZarrStore, +/** Fast path: one pixel, full time × hour, via zarrita's built-in slice assembly. */ +export async function fetchPixelTimeSeries( + 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, [null, null, grid.latIndex, grid.lonIndex]); /* days, hours, lat, lon */ + const result = await zarr.get(array as Parameters[0], [ + null, + null, + grid.latIndex, + grid.lonIndex, + ]); const units = typeof array.attrs.units === "string" ? array.attrs.units : undefined; 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"), + }, + }, +});