diff --git a/package-lock.json b/package-lock.json index 7a89790..2b94ad5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,10 +19,12 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "react-map-gl": "^8.0.2", + "react-router": "^7.5.3", "zarrita": "^0.5.1" }, "devDependencies": { "@eslint/js": "^9.21.0", + "@netlify/vite-plugin-react-router": "^1.0.1", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "@vitejs/plugin-react": "^4.3.4", @@ -2097,6 +2099,29 @@ "@math.gl/core": "4.1.0" } }, + "node_modules/@mjackson/node-fetch-server": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@mjackson/node-fetch-server/-/node-fetch-server-0.2.0.tgz", + "integrity": "sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng==", + "dev": true + }, + "node_modules/@netlify/vite-plugin-react-router": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@netlify/vite-plugin-react-router/-/vite-plugin-react-router-1.0.1.tgz", + "integrity": "sha512-JE32RJ6PamX6Yf2yyys7NLaKK4ppcDwuL6mk7NDZ7rt4EWUm+5bz/YjMcLz9uXyo1jDPlWjaUcqRC2MUZKt2DA==", + "dev": true, + "dependencies": { + "@react-router/node": "^7.0.1", + "isbot": "^5.0.0", + "react-router": "^7.0.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "vite": ">=5.0.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2179,6 +2204,30 @@ "resolved": "https://registry.npmjs.org/@probe.gl/stats/-/stats-4.1.0.tgz", "integrity": "sha512-EI413MkWKBDVNIfLdqbeNSJTs7ToBz/KVGkwi3D+dQrSIkRI2IYbWGAU3xX+D6+CI4ls8ehxMhNpUVMaZggDvQ==" }, + "node_modules/@react-router/node": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@react-router/node/-/node-7.6.0.tgz", + "integrity": "sha512-agjDPUzisLdGJ7Q2lx/Z3OfdS2t1k6qv/nTvA45iahGsQJCMDvMqVoIi7iIULKQJwrn4HWjM9jqEp75+WsMOXg==", + "dev": true, + "dependencies": { + "@mjackson/node-fetch-server": "^0.2.0", + "source-map-support": "^0.5.21", + "stream-slice": "^0.1.2", + "undici": "^6.19.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react-router": "7.6.0", + "typescript": "^5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.34.9", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.9.tgz", @@ -4453,6 +4502,12 @@ "node": ">=0.10.0" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, "node_modules/bytewise": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/bytewise/-/bytewise-1.1.0.tgz", @@ -4669,6 +4724,14 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "engines": { + "node": ">=18" + } + }, "node_modules/core-assert": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/core-assert/-/core-assert-0.2.1.tgz", @@ -5783,6 +5846,15 @@ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" }, + "node_modules/isbot": { + "version": "5.1.28", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.28.tgz", + "integrity": "sha512-qrOp4g3xj8YNse4biorv6O5ZShwsJM0trsoda4y7j/Su7ZtTTfVXFzbKkpgcSoDrHS8FcTuUwcU04YimZlZOxw==", + "dev": true, + "engines": { + "node": ">=18" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -6672,6 +6744,27 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.0.tgz", + "integrity": "sha512-GGufuHIVCJDbnIAXP3P9Sxzq3UUsddG3rrI3ut1q6m0FI6vxVBF3JoPQ38+W/blslLH4a5Yutp8drkEpXoddGQ==", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", @@ -6831,6 +6924,11 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==" + }, "node_modules/set-value": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", @@ -6937,6 +7035,25 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/splaytree-ts": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/splaytree-ts/-/splaytree-ts-1.0.2.tgz", @@ -6993,6 +7110,12 @@ "integrity": "sha512-vj5lIj3Mwf9D79hBkltk5qmkFI+biIKWS2IBxEyEU3AX1tUf7AoL8nSazCOiiqQsGKIq01SClsKEzweu34uwvA==", "dev": true }, + "node_modules/stream-slice": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/stream-slice/-/stream-slice-0.1.2.tgz", + "integrity": "sha512-QzQxpoacatkreL6jsxnVb7X5R/pGw9OUv2qWTYWnmLpg4NdN31snPy/f3TdQE1ZUXaThRvj1Zw4/OGg0ZkaLMA==", + "dev": true + }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -7124,6 +7247,48 @@ "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", "dev": true }, + "node_modules/tinyglobby": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", + "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", + "dev": true, + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", + "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "dev": true, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/tinypool": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz", @@ -7257,6 +7422,15 @@ "resolved": "https://registry.npmjs.org/typewise-core/-/typewise-core-1.2.0.tgz", "integrity": "sha512-2SCC/WLzj2SbUwzFOzqMCkz5amXLlxtJqDKTICqg30x+2DZxcfZN2MvQZmGfXWKNWaKK9pBPsvkcwv8bF/gxKg==" }, + "node_modules/undici": { + "version": "6.21.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", + "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", + "dev": true, + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", @@ -7342,14 +7516,17 @@ "integrity": "sha512-AMqwWZaknLM77G+VPYNZLEruMGWGzyigPK3/Whg99B3S6vGHuqsyl5ZrOv1UUF3paGK1U6PM0cnayioaryg/fA==" }, "node_modules/vite": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.0.tgz", - "integrity": "sha512-7dPxoo+WsT/64rDcwoOjk76XHj+TqNTIvHKcuMQ1k4/SeHDaQt5GFAeLYzrimZrMpn/O6DtdI03WUjdxuPM0oQ==", + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", + "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "dev": true, "dependencies": { "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", "postcss": "^8.5.3", - "rollup": "^4.30.1" + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" }, "bin": { "vite": "bin/vite.js" @@ -7434,6 +7611,32 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/vite/node_modules/fdir": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", + "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "dev": true, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/vitest": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.9.tgz", diff --git a/package.json b/package.json index 9aedc3c..db5e459 100644 --- a/package.json +++ b/package.json @@ -21,10 +21,12 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "react-map-gl": "^8.0.2", + "react-router": "^7.5.3", "zarrita": "^0.5.1" }, "devDependencies": { "@eslint/js": "^9.21.0", + "@netlify/vite-plugin-react-router": "^1.0.1", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "@vitejs/plugin-react": "^4.3.4", diff --git a/public/_redirects b/public/_redirects new file mode 100644 index 0000000..50a4633 --- /dev/null +++ b/public/_redirects @@ -0,0 +1 @@ +/* /index.html 200 \ No newline at end of file diff --git a/src/Animation.tsx b/src/Animation.tsx new file mode 100644 index 0000000..03ff522 --- /dev/null +++ b/src/Animation.tsx @@ -0,0 +1,308 @@ +import { useState, useEffect } from "react"; +import { + Map as GLMap, + NavigationControl, + useControl, +} from "react-map-gl/maplibre"; +import { TileLayer } from "@deck.gl/geo-layers"; +import type { _TileLoadProps } from "@deck.gl/geo-layers"; + +import { MapboxOverlay as DeckOverlay } from "@deck.gl/mapbox"; + +import ZarrReader from "./zarr"; +import NumericDataAnimationLayer from "@/layers/NumericDataAnimationLayer"; +import Panel from "@/components/Panel"; +import Description from "@/components/Description"; +import Dropdown from "@/components/ui/Dropdown"; +import RangeSlider from "@/components/ui/RangeSlider"; +import SingleSlider from "@/components/ui/Slider"; +import PlayButton from "@/components/ui/PlayButton"; + +import { usePausableAnimation } from "@/components/ui/utils"; + +import { INITIAL_VIEW_STATE } from "./App"; + +import "maplibre-gl/dist/maplibre-gl.css"; +import "./App.css"; + +const MAP_STYLE = + "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"; + +const BASE_URL = import.meta.env.VITE_ZARR_BASE_URL ?? window.location.origin; + +const ZARR_STORE_NAME = + "20020601090000-JPL-L4_GHRSST-SSTfnd-MUR-GLOB-v02.0-fv04.1_multiscales.zarr"; + +const VAR_NAME = "analysed_sst"; + +const zarrReader = await ZarrReader.initialize({ + zarrUrl: `${BASE_URL}/${ZARR_STORE_NAME}`, + varName: VAR_NAME, +}); + +export type TileIndex = { x: number; y: number; z: number }; + +const TIME_UNIT = 1; +const MAX_TIMESTAMP = 4; +const SPEED = 0.01; + +const quickCache = new Map(); + +//@ts-expect-error ignoring for now +function DeckGLOverlay(props) { + const overlay = useControl(() => new DeckOverlay(props)); + overlay.setProps(props); + return null; +} + +async function fetchOneTimeStamp({ + timestamp, + index, +}: { + timestamp: number; + index: TileIndex; +}) { + const { x, y, z } = index; + const keyName = `tile${timestamp}${x}${y}${z}`; + + if (quickCache.get(keyName)) { + return quickCache.get(keyName); + } + const chunkData = await zarrReader.getTileData({ + ...index, + timestamp, + }); + quickCache.set(keyName, chunkData); + return chunkData; +} + +// Helper function to pre-fetch the next timestamp for all visible tiles +async function prefetchNextTimestamp( + currentTimestamp: number, + visibleTiles: TileIndex[] +) { + const nextTimestamp = + (Math.floor(currentTimestamp + TIME_UNIT) % MAX_TIMESTAMP) + 1; + + // Pre-fetch data for all visible tiles + for (const tile of visibleTiles) { + await fetchOneTimeStamp({ + timestamp: nextTimestamp, + index: tile, + }); + } +} + +// Evict cache entries for a specific tile (when tiles are unloaded) +function evictTileFromCache(index: TileIndex) { + const { x, y, z } = index; + + for (let t = 0; t < MAX_TIMESTAMP; t++) { + const tileKey = `tile${t}${x}${y}${z}`; + if (quickCache.has(tileKey)) { + quickCache.delete(tileKey); + } + } +} + +function App() { + const [selectedColormap, setSelectedColormap] = useState("viridis"); + const [minMax, setMinMax] = useState<{ min: number; max: number }>( + zarrReader.scale + ); + const [timestamp, setTimestamp] = useState(0.0); + const [visibleTiles, setVisibleTiles] = useState([]); + const [lastPrefetchedTimestamp, setLastPrefetchedTimestamp] = + useState(-1); + + const timestampStart = Math.floor(timestamp); + const timestampEnd = Math.min( + Math.floor(timestamp + TIME_UNIT), + MAX_TIMESTAMP + ); + const step = timestamp - timestampStart; + + // When step crosses 0.5, fetch one more timestamp + + useEffect(() => { + const nextTimestamp = + (Math.floor(timestamp + TIME_UNIT) + 1) % MAX_TIMESTAMP; + + // Only pre-fetch if we have visible tiles and step has crossed 0.5 + // and we haven't pre-fetched this timestamp yet + if ( + step > 0.5 && + visibleTiles.length > 0 && + lastPrefetchedTimestamp !== nextTimestamp + ) { + prefetchNextTimestamp(timestamp, visibleTiles); + setLastPrefetchedTimestamp(nextTimestamp); + } + }, [timestamp, step, visibleTiles, lastPrefetchedTimestamp]); + + const { isRunning, toggleAnimation } = usePausableAnimation(() => { + setTimestamp((prev) => (prev + SPEED) % MAX_TIMESTAMP); + }); + + async function getTileData({ index, signal }: _TileLoadProps) { + if (signal?.aborted) { + console.error("Signal aborted: ", signal); + return null; + } + const scale = zarrReader.scale; + + const { min, max } = scale; + const { x, y, z } = index; + + // Add this tile to visible tiles if not already there + setVisibleTiles((prevTiles) => { + if ( + !prevTiles.some((tile) => tile.x === x && tile.y === y && tile.z === z) + ) { + return [...prevTiles, { x, y, z }]; + } + return prevTiles; + }); + + const timestampKeyStart = `tile${timestampStart}${x}${y}${z}`; + const timestampKeyEnd = `tile${timestampEnd}${x}${y}${z}`; + // Make it synchronous when there are values cached + if (quickCache.get(timestampKeyStart) && quickCache.get(timestampKeyEnd)) { + return { + imageDataFrom: quickCache.get(timestampKeyStart), + imageDataTo: quickCache.get(timestampKeyEnd), + min, + max, + }; + } + + const chunkDataStart = await fetchOneTimeStamp({ + index, + timestamp: timestampStart, + }); + + const chunkDataEnd = await fetchOneTimeStamp({ + index, + timestamp: timestampEnd, + }); + + if (chunkDataStart && chunkDataEnd) { + return { + imageDataFrom: chunkDataStart, + imageDataTo: chunkDataEnd, + min, + max, + }; + } else { + throw Error("No tile data available"); + } + } + + const layers = [ + new TileLayer({ + id: "TileLayer", + // data: 'https://c.tile.openstreetmap.org/{z}/{x}/{y}.png', + + /* props from TileLayer class */ + // TilesetClass: null, + // debounceTime: 0, + // extent: null, + getTileData, + // maxCacheByteSize: null, + // maxCacheSize: null, + // maxRequests: 6, + maxZoom: zarrReader.maxZoom, + minZoom: zarrReader.minZoom, + // onTileError: null, + // onTileLoad: null, + onTileUnload: (tile) => { + // Remove unloaded tiles from the visibleTiles array + const { x, y, z } = tile.index; + // Also get rid of them from cache + evictTileFromCache({ x, y, z }); + setVisibleTiles((prevTiles) => + prevTiles.filter((t) => !(t.x === x && t.y === y && t.z === z)) + ); + }, + // onViewportLoad: null, + // refinementStrategy: 'best-available', + updateTriggers: { + getTileData: [timestampStart], + renderSubLayers: [selectedColormap, minMax, timestamp], + }, + renderSubLayers: (props) => { + const { imageDataFrom, imageDataTo } = props.data; + const { boundingBox } = props.tile; + return new NumericDataAnimationLayer(props, { + data: undefined, + colormap_image: `/colormaps/${selectedColormap}.png`, + min: minMax.min, + max: minMax.max, + imageDataFrom, + imageDataTo, + step, + tileSize: zarrReader.tileSize, + bounds: [ + boundingBox[0][0], + boundingBox[0][1], + boundingBox[1][0], + boundingBox[1][1], + ], + pickable: true, + }); + }, + tileSize: zarrReader.tileSize, + // zRange: null, + // zoomOffset: 0, + + /* props inherited from Layer class */ + + // autoHighlight: false, + // coordinateOrigin: [0, 0, 0], + // coordinateSystem: COORDINATE_SYSTEM.LNGLAT, + // highlightColor: [0, 0, 128, 128], + // modelMatrix: null, + // opacity: 1, + + // visible: true, + // wrapLongitude: false, + }), + ]; + + const deckProps = { + layers, + }; + + return ( + <> + + + + + + + + + + + + + + ); +} + +export default App; diff --git a/src/App.tsx b/src/App.tsx index 96c474c..992ec36 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,11 +18,11 @@ import CheckBox from "@/components/ui/Checkbox"; import "maplibre-gl/dist/maplibre-gl.css"; import "./App.css"; -const INITIAL_VIEW_STATE = { +export const INITIAL_VIEW_STATE = { latitude: 51.47, longitude: 0.45, zoom: 0, - maxZoom: 1, + maxZoom: 20, }; const MAP_STYLE = @@ -88,8 +88,8 @@ function App() { // maxCacheByteSize: null, // maxCacheSize: null, // maxRequests: 6, - // maxZoom: 19, - // minZoom: 0, + maxZoom: zarrReader.maxZoom, + minZoom: zarrReader.minZoom, // onTileError: null, // onTileLoad: null, // onTileUnload: null, @@ -167,6 +167,7 @@ function App() { void; +}; + +export default function PlayButton({ onPlay, onClick }: PlayButtonProps) { + return ; +} diff --git a/src/components/ui/RangeSlider.tsx b/src/components/ui/RangeSlider.tsx index 9242d5b..b00f57f 100644 --- a/src/components/ui/RangeSlider.tsx +++ b/src/components/ui/RangeSlider.tsx @@ -1,7 +1,10 @@ import { Slider } from "@chakra-ui/react"; import type { SliderUIProps } from "./Slider"; -type RangeSliderUIProps = SliderUIProps & { +type RangeSliderUIProps = Omit< + SliderUIProps, + "onValueChange" | "currentValue" +> & { onValueChange: (param: { min: number; max: number }) => void; }; diff --git a/src/components/ui/Slider.tsx b/src/components/ui/Slider.tsx index 7455887..3fe8527 100644 --- a/src/components/ui/Slider.tsx +++ b/src/components/ui/Slider.tsx @@ -4,6 +4,7 @@ export type SliderUIProps = { minMax: [number, number]; step?: number; label?: string; + currentValue: number; onValueChange: (param: number) => void; }; @@ -11,6 +12,7 @@ export default function SingleSlider({ minMax, step, label, + currentValue, onValueChange, }: SliderUIProps) { const handleChange = (detail: Slider.ValueChangeDetails) => { @@ -21,6 +23,7 @@ export default function SingleSlider({ defaultValue={[minMax[0]]} min={minMax[0]} max={minMax[1]} + value={[currentValue]} maxW="100%" width="100%" step={step} @@ -32,6 +35,11 @@ export default function SingleSlider({ + idx)} + /> ); diff --git a/src/components/ui/utils.ts b/src/components/ui/utils.ts new file mode 100644 index 0000000..755b874 --- /dev/null +++ b/src/components/ui/utils.ts @@ -0,0 +1,53 @@ +import { useState, useRef, useEffect } from "react"; + +// Define the callback function type +type AnimationFrameCallback = (deltaTime: number) => void; + +// Define the return type for our hook +interface PausableAnimationControls { + isRunning: boolean; + startAnimation: () => void; + stopAnimation: () => void; + toggleAnimation: () => void; +} + +/** + * A custom hook that provides requestAnimationFrame with pause/resume functionality + * @param callback Function to call on each animation frame with deltaTime parameter + * @returns Object with animation control functions and state + */ +export function usePausableAnimation( + callback: AnimationFrameCallback +): PausableAnimationControls { + // Use useRef for mutable variables that we want to persist + // without triggering a re-render on their change + const requestRef = useRef(null); + const previousTimeRef = useRef(undefined); + // Add a state to control whether the animation is running + const [isRunning, setIsRunning] = useState(false); + + const animate = (time: number): void => { + if (previousTimeRef.current !== undefined && isRunning) { + const deltaTime = time - previousTimeRef.current; + callback(deltaTime); + } + previousTimeRef.current = time; + requestRef.current = requestAnimationFrame(animate); + }; + + useEffect(() => { + requestRef.current = requestAnimationFrame(animate); + return () => { + if (requestRef.current !== null) { + cancelAnimationFrame(requestRef.current); + } + }; + }, [isRunning]); // Re-run when isRunning changes + + // Return functions to start and stop the animation + const startAnimation = (): void => setIsRunning(true); + const stopAnimation = (): void => setIsRunning(false); + const toggleAnimation = (): void => setIsRunning((prev) => !prev); + + return { isRunning, startAnimation, stopAnimation, toggleAnimation }; +} diff --git a/src/layers/NumericDataAnimationLayer/index.tsx b/src/layers/NumericDataAnimationLayer/index.tsx new file mode 100644 index 0000000..a43bfbf --- /dev/null +++ b/src/layers/NumericDataAnimationLayer/index.tsx @@ -0,0 +1,68 @@ +import { CompositeLayer, Layer, LayersList } from "deck.gl"; +import type { CompositeLayerProps, UpdateParameters } from "deck.gl"; +import type { Texture } from "@luma.gl/core"; + +import { NumericDataAnimationPaintLayer } from "../NumericDataAnimationPaintLayer"; +import type { NumericDataAnimationLayerProps } from "./types"; + +const textureDefaultOption = { + format: "r32float" as const, + dimension: "2d" as const, +}; + +const textureSamplerDefaultOption = { + minFilter: "linear" as const, + magFilter: "linear" as const, + mipmapFilter: "linear" as const, + addressModeU: "clamp-to-edge" as const, + addressModeV: "clamp-to-edge" as const, +}; + +export default class NumericDataAnimationLayer extends CompositeLayer { + static layerName: string = "numeric-data-animation-layer"; + updateState( + params: UpdateParameters< + Layer> + > + ): void { + const { props, oldProps, context } = params; + const { imageDataFrom, imageDataTo } = props; + const { imageDataFrom: oldImageDataFrom, imageDataTo: oldImageDataTo } = + oldProps; + if (imageDataFrom !== oldImageDataFrom && imageDataTo !== oldImageDataTo) { + const { tileSize, textureParameters } = props; + const dataTextureFrom = context.device.createTexture({ + data: this.props.imageDataFrom, + width: tileSize, + height: tileSize, + ...textureDefaultOption, + sampler: { + ...textureSamplerDefaultOption, + ...textureParameters, + }, + }); + const dataTextureTo = context.device.createTexture({ + data: this.props.imageDataTo, + width: tileSize, + height: tileSize, + ...textureDefaultOption, + sampler: { + ...textureSamplerDefaultOption, + ...textureParameters, + }, + }); + this.setState({ + dataTextureFrom, + dataTextureTo, + }); + } + } + + renderLayers(): Layer | null | LayersList { + return new NumericDataAnimationPaintLayer(this.props, { + id: `${this.props.id}-data`, + image: this.state.dataTextureFrom as Texture, + imageTo: this.state.dataTextureTo as Texture, + }); + } +} diff --git a/src/layers/NumericDataAnimationLayer/types.d.ts b/src/layers/NumericDataAnimationLayer/types.d.ts new file mode 100644 index 0000000..2c795c2 --- /dev/null +++ b/src/layers/NumericDataAnimationLayer/types.d.ts @@ -0,0 +1,8 @@ +import type { NumericDataAnimationPaintLayerProps } from "../NumericDataAnimationPaintLayer/types"; +import type { TypedArray, NumberDataType } from "zarrita"; + +export interface NumericDataAnimationLayerProps + extends NumericDataAnimationPaintLayerProps { + imageDataFrom: TypedArray; + imageDataTo: TypedArray; +} diff --git a/src/layers/NumericDataAnimationPaintLayer/index.tsx b/src/layers/NumericDataAnimationPaintLayer/index.tsx new file mode 100644 index 0000000..4deb93b --- /dev/null +++ b/src/layers/NumericDataAnimationPaintLayer/index.tsx @@ -0,0 +1,100 @@ +import { BitmapLayer } from "deck.gl"; + +import type { Texture } from "@luma.gl/core"; +import type { ShaderModule } from "@luma.gl/shadertools"; + +import type { NumericDataAnimationPaintLayerProps } from "./types"; + +const uniformBlock = `\ + uniform ndUniforms { + float min; + float max; + float step; + } nd; +`; + +export type NDProps = { + min: number; + max: number; + step: number; + colormap_texture: Texture; + image_to: Texture; +}; + +const numericDataAnimationUniforms = { + name: "nd", + vs: uniformBlock, + fs: uniformBlock, + // @?: not float data? + uniformTypes: { + min: "f32", + max: "f32", + step: "f32", + }, +} as const satisfies ShaderModule; + +const defaultProps = { + ...BitmapLayer.defaultProps, + min: 0, + max: 0, + tileSize: 256, + step: 0.0, + imageData: [], + imageTo: [], + colormap_image: { + type: "image", + value: null, + async: true, + }, +}; + +export class NumericDataAnimationPaintLayer extends BitmapLayer { + static layerName = "numeric-paint-animation-layer"; + static defaultProps = defaultProps; + + getShaders() { + return { + ...super.getShaders(), + inject: { + "fs:#decl": ` + uniform sampler2D colormap_texture; // texture is not included in ubo + uniform sampler2D image_to; + `, + "fs:DECKGL_FILTER_COLOR": ` + float start_value = color.r; + vec4 end_image = texture(image_to, geometry.uv); + float end_value = end_image.r; + float value = mix(start_value, end_value, nd.step); + if (isnan(value)) { + discard; + } else { + float normalized = (value - nd.min)/(nd.max - nd.min); + vec4 color_val = texture(colormap_texture, vec2(normalized, 0.)); + color = color_val; + } + `, + }, + modules: [...super.getShaders().modules, numericDataAnimationUniforms], + }; + } + + // @ts-expect-error no opts type available + draw(opts) { + const { colormap_image, imageTo, step, min, max } = this.props; + + const sModels = super.getModels(); + if (colormap_image) + for (const m of sModels) { + m.shaderInputs.setProps({ + nd: { + colormap_texture: colormap_image, + image_to: imageTo, + step, + min, + max, + }, + }); + } + super.draw({ ...opts }); + } +} diff --git a/src/layers/NumericDataAnimationPaintLayer/types.d.ts b/src/layers/NumericDataAnimationPaintLayer/types.d.ts new file mode 100644 index 0000000..5effc53 --- /dev/null +++ b/src/layers/NumericDataAnimationPaintLayer/types.d.ts @@ -0,0 +1,10 @@ +import type { BitmapLayerProps } from "deck.gl"; + +export interface NumericDataAnimationPaintLayerProps extends BitmapLayerProps { + colormap_image: string | Texture; + min: number; + max: number; + step: number; + tileSize: number; + imageTo: Texture; +} diff --git a/src/main.tsx b/src/main.tsx index 8f027f3..703441e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,11 +1,19 @@ import { createRoot } from "react-dom/client"; import { Provider } from "@/components/ui/Provider"; import { defaultSystem } from "@chakra-ui/react"; +import { BrowserRouter, Routes, Route } from "react-router"; + import "./index.css"; import App from "./App.tsx"; +import Animation from "./Animation.tsx"; createRoot(document.getElementById("root")!).render( - + + + } /> + } /> + + ); diff --git a/src/zarr/index.ts b/src/zarr/index.ts index c096b79..a4c4492 100644 --- a/src/zarr/index.ts +++ b/src/zarr/index.ts @@ -7,16 +7,22 @@ import type { NumberDataType, } from "zarrita"; import { findMinMax } from "./utils"; -interface ZarrReaderProps { +type ZarrReaderProps = { zarrUrl: string; varName: string; -} +}; -interface TileIndex { +type TileIndex = { x: number; y: number; z: number; -} +}; + +type Multiscale = { + tile_matrix_set: "WebMercatorQuad"; + resampling_method: string; + tile_matrix_limits: Record; +}; export default class ZarrReader { private root!: Location; @@ -28,6 +34,7 @@ export default class ZarrReader { private _tileSize: number = 256; // @TODO: hard coding for now private _t: number = 0; + private _zooms!: { min: number; max: number }; get scale() { return this._scale; @@ -41,6 +48,12 @@ export default class ZarrReader { get metadata() { return this._metadata; } + get minZoom() { + return this._zooms.min; + } + get maxZoom() { + return this._zooms.max; + } private constructor() {} @@ -74,6 +87,22 @@ export default class ZarrReader { min: minMax.min, }; } + + const zarrMetadata = await zarr.open.v3(this.root.resolve(`${varName}`), { + kind: "array", + }); + const multiscale = zarrMetadata.attrs.multiscales as Multiscale; + + if (multiscale?.tile_matrix_limits) { + this._zooms = { + min: Math.min( + ...Object.keys(multiscale.tile_matrix_limits).map((e) => Number(e)) + ), + max: Math.max( + ...Object.keys(multiscale.tile_matrix_limits).map((e) => Number(e)) + ), + }; + } } async getTileData({ @@ -89,12 +118,13 @@ export default class ZarrReader { }); if (arr.is("number")) { - const { data } = await arr.getChunk([timestamp, y, x]); + const { data } = await arr.getChunk([this._t, y, x]); // @TODO : remove once the data has actual timestamps - if (timestamp == 2) { - return new Float32Array(this.tileSize * this.tileSize); + if (timestamp % 2 == 1) { + const tempArray = new Float32Array(this.tileSize * this.tileSize); + tempArray.fill(this.scale.min); + return tempArray; } - return data; } else { return undefined; diff --git a/vite.config.ts b/vite.config.ts index 357971a..fb0fe1c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,10 +1,11 @@ import { fileURLToPath, URL } from "url"; import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; +import netlifyPlugin from "@netlify/vite-plugin-react-router"; // https://vite.dev/config/ export default defineConfig({ - plugins: [react()], + plugins: [react(), netlifyPlugin()], build: { target: "ES2022", },