From aa40cc90dfcf41603720b93f2e9ab25e19bc1731 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 27 May 2026 14:58:38 -0400 Subject: [PATCH 01/24] docs: design spec for NLDAS icechunk example (#569) Co-Authored-By: Claude Opus 4.7 (1M context) --- ...026-05-27-nldas-icechunk-example-design.md | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 dev-docs/specs/2026-05-27-nldas-icechunk-example-design.md diff --git a/dev-docs/specs/2026-05-27-nldas-icechunk-example-design.md b/dev-docs/specs/2026-05-27-nldas-icechunk-example-design.md new file mode 100644 index 00000000..bb712a23 --- /dev/null +++ b/dev-docs/specs/2026-05-27-nldas-icechunk-example-design.md @@ -0,0 +1,154 @@ +# NLDAS icechunk example — Design + +- **Date:** 2026-05-27 +- **Issues:** [#569](https://github.com/developmentseed/deck.gl-raster/issues/569) +- **Status:** Proposed +- **Related:** [`2026-04-17-ecmwf-zarr-animation-design.md`](2026-04-17-ecmwf-zarr-animation-design.md) — the closest precedent example; this design deliberately strips its UI down to a static frame + +## Problem + +We want an example proving that an [icechunk](https://icechunk.io) repository can +be read in the browser and rendered with `deck.gl-raster`, using +[`icechunk-js`](https://github.com/EarthyScience/icechunk-js) as a +zarrita-compatible store. icechunk is increasingly used to publish analysis-ready +and *virtual* Zarr (chunks that reference byte ranges inside other cloud objects), +and we currently have no example demonstrating that path. + +The target dataset, suggested in the issue, is +[NLDAS-3](https://github.com/virtual-zarr/nldas-icechunk) — NASA's North American +Land Data Assimilation System v3 daily forcing data, virtualized into an icechunk +repo: + +- **Repo:** `https://nasa-waterinsight.s3.us-west-2.amazonaws.com/virtual-zarr-store/NLDAS-3-icechunk` +- **Access:** public / anonymous. +- **Virtual chunks:** reference the original NLDAS-3 files under + `s3://nasa-waterinsight/NLDAS3/forcing/daily/` — **the same bucket**. + +Two feasibility facts were verified during design: + +- The entire `nasa-waterinsight` bucket returns `Access-Control-Allow-Origin: *` + for `GET`/`HEAD`, so both the icechunk repo metadata and the virtual source + objects are reachable from a browser origin. +- `icechunk-js@0.4.0` declares `zarrita ^0.5 || ^0.6 || ^0.7` as a peer + dependency, matching this repo's `zarrita@0.7.3`. + +## Goals + +- A new `examples/nldas-icechunk` that renders a single Tair (air temperature) + timestep over North America with a temperature colormap. +- Exercise the real integration seam: `IcechunkStore` → `zarrita.open` → + `ZarrLayer` (the existing `@developmentseed/deck.gl-zarr` layer). +- Reuse this repo's idiomatic GPU colormap pipeline (rescale + colormap on the + GPU via `deck.gl-raster`'s gpu-modules), as in the ECMWF example. +- `pnpm typecheck` passes and `pnpm dev` shows the rendered frame. + +## Non-goals + +- **No animation and no UI controls.** This is a minimal "plumbing" demo — one + pinned timestep, a fixed colormap, and a fixed rescale range. A time slider and + colormap/rescale controls are obvious follow-ups but explicitly out of scope. +- **No GeoZarr support work.** The NLDAS virtual store is not GeoZarr-compliant; + we hard-code synthetic spatial attrs (the established ECMWF approach) rather + than teaching anything to parse NLDAS's native layout. +- **No icechunk version-control UI** (snapshots/tags/branches). The example + checks out the default `main` branch only. + +## Design + +### Directory layout + +Mirrors `examples/dynamical-zarr-ecmwf`, minus the control-panel UI: + +``` +examples/nldas-icechunk/ + index.html + package.json + tsconfig.json + vite.config.ts + README.md + src/ + main.tsx React entry + App.tsx open store -> open Tair -> build ZarrLayer -> map + overlay + nldas/ + metadata.ts REPO_URL, VARIABLE, TIME_INDEX, rescale range, + colormap choice, hard-coded NLDAS_GEOZARR_ATTRS + get-tile-data.ts zarr.get(arr, sliceSpec) -> Float32 tile {data,width,height,byteLength} + render-tile.ts GPU rescale + colormap pipeline (trimmed ECMWF render-tile) +``` + +No Chakra UI — there are no controls. Dependencies: `icechunk-js@^0.4.0`, +`zarrita`, the workspace deck.gl-raster / deck.gl-zarr packages, the deck.gl +peer packages, `maplibre-gl`, `react-map-gl`, and the shared +`deck.gl-raster-examples-shared` (`DeckGlOverlay`). + +### Data flow + +1. **Open (once, on mount).** + `IcechunkStore.open(REPO_URL, { withRangeCoalescing: true })` returns a + zarrita `AsyncReadable`. Then + `zarr.open(store.resolve("/Tair"), { kind: "array" })` (use `zarr.open.v3` + if auto-detection misfires — icechunk is always Zarr v3). Assert the dtype is + float; throw with a clear message otherwise (ECMWF precedent). +2. **Colormap.** Fetch the shipped `colormaps.png`, `decodeColormapSprite` to + `ImageData`, and `createColormapTexture` once the luma `Device` arrives via + the overlay's `onDeviceInitialized` callback. Identical to ECMWF. +3. **Layer.** Construct + `ZarrLayer({ node: arr, metadata: NLDAS_GEOZARR_ATTRS, selection: { : TIME_INDEX }, getTileData, renderTile, maxRequests })`. + The layer tiles the single-level array; `getTileData` pulls one chunk per tile + via `zarr.get(arr, options.sliceSpec)`; `renderTile` applies the fixed rescale + + colormap on the GPU. +4. **Map.** A `maplibre-gl` basemap centered on North America (≈ lon −98, lat 39, + zoom ≈ 3.5) with the shared `DeckGlOverlay` (interleaved). + +### Spatial metadata (the one non-obvious piece) + +NLDAS-3 virtual-zarr is not GeoZarr-compliant, so — exactly like ECMWF's +`ECMWF_GEOZARR_ATTRS` — we hard-code a synthetic attrs object and pass it as +`ZarrLayer`'s `metadata` prop: + +```ts +{ + "spatial:dimensions": [, ], // e.g. ["lat", "lon"] + "spatial:transform": [a, b, c, d, e, f], // @developmentseed/affine convention + "spatial:shape": [height, width], + "proj:code": "EPSG:4326", +} +``` + +The **exact grid values** (spatial dim names, the non-spatial time dim name, +shape, origin, pixel size, and crucially the latitude **row direction**) are not +guessed — they are read from the store once during implementation by logging +`arr.shape` and reading the 1-D lat/lon coordinate arrays, then frozen into +`metadata.ts` with a comment recording where they came from. Tair's units +(likely Kelvin) are confirmed the same way and drive a fixed rescale range plus a +temperature colormap choice. + +### Error handling + +- Async open effect uses a `cancelled` flag (ECMWF precedent) to avoid setting + state after unmount. +- Non-float dtype throws with a descriptive message. +- Layer is only constructed once both the opened array and the colormap texture + are ready. + +## Risks / smoke-test before building UI + +- **Virtual chunk resolution — the load-bearing risk.** Tair's chunks reference + `s3://nasa-waterinsight/NLDAS3/forcing/daily/...`; icechunk-js must translate + those `s3://` references to HTTPS and fetch them from the browser. It may need + explicit region/endpoint options on `open`. **First implementation step is a + throwaway script/console call** that opens the store and `zarr.get`s a single + chunk, confirming bytes return, *before* any layer/UI work. If it fails, revisit + the approach here rather than pressing on. +- **Zarr version.** icechunk is Zarr v3 with no consolidated metadata; the store + serves metadata directly. Prefer `zarr.open.v3` if plain `open` mis-detects. + +## Testing + +Examples in this repo are demos without unit tests; verification is: + +- `pnpm typecheck` in the example. +- Manual `pnpm dev` confirming Tair renders over North America with the colormap. + +A fresh worktree first needs submodule init + `pnpm install` + `pnpm build` so the +workspace packages resolve. From 454dcea107d7eec6a6320df1701a4d69702ee0b3 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 27 May 2026 15:10:54 -0400 Subject: [PATCH 02/24] docs: pin icechunk-js virtual chunk container config in NLDAS spec (#569) Co-Authored-By: Claude Opus 4.7 (1M context) --- ...026-05-27-nldas-icechunk-example-design.md | 74 +++++++++++++++---- 1 file changed, 60 insertions(+), 14 deletions(-) diff --git a/dev-docs/specs/2026-05-27-nldas-icechunk-example-design.md b/dev-docs/specs/2026-05-27-nldas-icechunk-example-design.md index bb712a23..c3933df4 100644 --- a/dev-docs/specs/2026-05-27-nldas-icechunk-example-design.md +++ b/dev-docs/specs/2026-05-27-nldas-icechunk-example-design.md @@ -24,13 +24,42 @@ repo: - **Virtual chunks:** reference the original NLDAS-3 files under `s3://nasa-waterinsight/NLDAS3/forcing/daily/` — **the same bucket**. -Two feasibility facts were verified during design: +Feasibility facts verified during design: - The entire `nasa-waterinsight` bucket returns `Access-Control-Allow-Origin: *` for `GET`/`HEAD`, so both the icechunk repo metadata and the virtual source objects are reachable from a browser origin. - `icechunk-js@0.4.0` declares `zarrita ^0.5 || ^0.6 || ^0.7` as a peer dependency, matching this repo's `zarrita@0.7.3`. +- The repo's `config.yaml` declares exactly one virtual chunk container: + ```yaml + virtual_chunk_containers: + s3://nasa-waterinsight/NLDAS3/forcing/daily/: + url_prefix: s3://nasa-waterinsight/NLDAS3/forcing/daily/ + store: !s3 { region: us-west-2, anonymous: false, ... } + ``` + The container's underlying objects are nonetheless publicly readable over + HTTPS (verified), so the browser can fetch them unsigned despite + `anonymous: false` in the stored config. + +The working Python recipe (provided by @kylebarron) confirms what the browser +code must replicate: + +```python +storage = icechunk.s3_storage(bucket='nasa-waterinsight', + prefix="virtual-zarr-store/NLDAS-3-icechunk", region="us-west-2", anonymous=True) +virtual_credentials = icechunk.containers_credentials({ + "s3://nasa-waterinsight/NLDAS3/forcing/daily/": icechunk.s3_anonymous_credentials()}) +repo = icechunk.Repository.open(storage=storage, + authorize_virtual_chunk_access=virtual_credentials) +session = repo.readonly_session('main') +ds = xr.open_zarr(session.store, consolidated=False, zarr_version=3, chunks={}) +``` + +Two requirements fall out of this: **region** `us-west-2` (in the browser this is +just encoded in the HTTPS host — `icechunk-js` has no region param), and +**explicit authorization of the virtual chunk container** before chunk reads +work. ## Goals @@ -83,12 +112,27 @@ peer packages, `maplibre-gl`, `react-map-gl`, and the shared ### Data flow -1. **Open (once, on mount).** - `IcechunkStore.open(REPO_URL, { withRangeCoalescing: true })` returns a - zarrita `AsyncReadable`. Then - `zarr.open(store.resolve("/Tair"), { kind: "array" })` (use `zarr.open.v3` - if auto-detection misfires — icechunk is always Zarr v3). Assert the dtype is - float; throw with a clear message otherwise (ECMWF precedent). +1. **Open (once, on mount).** Build the store with the virtual chunk container + authorized. The `virtualChunkContainers` option lives on `ReadSession.open` + (not on `IcechunkStore.open(url, …)`), so we construct the session explicitly + and wrap it: + ```ts + const storage = new HttpStorage(REPO_URL); // region encoded in the HTTPS host + // VCC name (from config.yaml) -> public HTTPS prefix for the source objects + const virtualChunkContainers = new Map([[ + "s3://nasa-waterinsight/NLDAS3/forcing/daily/", + "https://nasa-waterinsight.s3.us-west-2.amazonaws.com/NLDAS3/forcing/daily/", + ]]); + // exact entry point (Repository.checkoutBranch vs ReadSession.open with an + // explicit snapshot id) is pinned at the smoke-test step below + const session = await /* main-branch read session */; + const store = await IcechunkStore.open(session, { withRangeCoalescing: true }); + const arr = await zarr.open(store.resolve("/Tair"), { kind: "array" }); + ``` + Use `zarr.open.v3` if auto-detection misfires — icechunk is always Zarr v3. + Assert the dtype is float; throw with a clear message otherwise (ECMWF + precedent). No custom `FetchClient` is needed: the source objects are public, + so the default client's unsigned `fetch` succeeds. 2. **Colormap.** Fetch the shipped `colormaps.png`, `decodeColormapSprite` to `ImageData`, and `createColormapTexture` once the luma `Device` arrives via the overlay's `onDeviceInitialized` callback. Identical to ECMWF. @@ -133,13 +177,15 @@ temperature colormap choice. ## Risks / smoke-test before building UI -- **Virtual chunk resolution — the load-bearing risk.** Tair's chunks reference - `s3://nasa-waterinsight/NLDAS3/forcing/daily/...`; icechunk-js must translate - those `s3://` references to HTTPS and fetch them from the browser. It may need - explicit region/endpoint options on `open`. **First implementation step is a - throwaway script/console call** that opens the store and `zarr.get`s a single - chunk, confirming bytes return, *before* any layer/UI work. If it fails, revisit - the approach here rather than pressing on. +- **Virtual chunk resolution — the load-bearing risk.** Mechanism is understood + (the `virtualChunkContainers` map above), but two unknowns remain until run: the + exact session entry point that accepts `virtualChunkContainers` + (`Repository.checkoutBranch` vs `ReadSession.open` with an explicit snapshot id), + and whether the manifest stores chunk locations such that the container's + `url_prefix` matches and rewrites cleanly to the HTTPS prefix. **First + implementation step is a throwaway script/console call** that opens the store + and `zarr.get`s a single Tair chunk, confirming bytes return, *before* any + layer/UI work. If it fails, revisit the approach here rather than pressing on. - **Zarr version.** icechunk is Zarr v3 with no consolidated metadata; the store serves metadata directly. Prefer `zarr.open.v3` if plain `open` mis-detects. From 6964f55ca53bb19b0c29db70a5ca0ed5c26c0b8a Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 27 May 2026 15:24:21 -0400 Subject: [PATCH 03/24] docs: fix withRangeCoalescing usage in NLDAS spec (#569) Co-Authored-By: Claude Opus 4.7 (1M context) --- dev-docs/specs/2026-05-27-nldas-icechunk-example-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-docs/specs/2026-05-27-nldas-icechunk-example-design.md b/dev-docs/specs/2026-05-27-nldas-icechunk-example-design.md index c3933df4..3bba6773 100644 --- a/dev-docs/specs/2026-05-27-nldas-icechunk-example-design.md +++ b/dev-docs/specs/2026-05-27-nldas-icechunk-example-design.md @@ -126,7 +126,7 @@ peer packages, `maplibre-gl`, `react-map-gl`, and the shared // exact entry point (Repository.checkoutBranch vs ReadSession.open with an // explicit snapshot id) is pinned at the smoke-test step below const session = await /* main-branch read session */; - const store = await IcechunkStore.open(session, { withRangeCoalescing: true }); + const store = await IcechunkStore.open(session); // withRangeCoalescing is fn-typed; omit const arr = await zarr.open(store.resolve("/Tair"), { kind: "array" }); ``` Use `zarr.open.v3` if auto-detection misfires — icechunk is always Zarr v3. From 02c7ff83804f55ddb5abc5be7c5f4699a75db0b6 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 27 May 2026 15:30:18 -0400 Subject: [PATCH 04/24] feat(nldas-icechunk): scaffold example package shell Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/nldas-icechunk/index.html | 22 ++++++ examples/nldas-icechunk/package.json | 37 ++++++++++ examples/nldas-icechunk/src/App.tsx | 24 ++++++ examples/nldas-icechunk/src/main.tsx | 12 +++ examples/nldas-icechunk/src/vite-env.d.ts | 1 + examples/nldas-icechunk/tsconfig.json | 4 + examples/nldas-icechunk/vite.config.ts | 11 +++ pnpm-lock.yaml | 89 +++++++++++++++++++++++ 8 files changed, 200 insertions(+) create mode 100644 examples/nldas-icechunk/index.html create mode 100644 examples/nldas-icechunk/package.json create mode 100644 examples/nldas-icechunk/src/App.tsx create mode 100644 examples/nldas-icechunk/src/main.tsx create mode 100644 examples/nldas-icechunk/src/vite-env.d.ts create mode 100644 examples/nldas-icechunk/tsconfig.json create mode 100644 examples/nldas-icechunk/vite.config.ts diff --git a/examples/nldas-icechunk/index.html b/examples/nldas-icechunk/index.html new file mode 100644 index 00000000..91e90dfb --- /dev/null +++ b/examples/nldas-icechunk/index.html @@ -0,0 +1,22 @@ + + + + + + NLDAS icechunk Example + + + +
+ + + diff --git a/examples/nldas-icechunk/package.json b/examples/nldas-icechunk/package.json new file mode 100644 index 00000000..e709aafe --- /dev/null +++ b/examples/nldas-icechunk/package.json @@ -0,0 +1,37 @@ +{ + "name": "deck.gl-nldas-icechunk", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "publish": "pnpm build && gh-pages -d dist -b gh-pages -e examples/nldas-icechunk" + }, + "dependencies": { + "@deck.gl/core": "^9.3.2", + "@deck.gl/geo-layers": "^9.3.2", + "@deck.gl/layers": "^9.3.2", + "@deck.gl/mapbox": "^9.3.2", + "@developmentseed/deck.gl-raster": "workspace:^", + "@developmentseed/deck.gl-zarr": "workspace:^", + "@luma.gl/core": "^9.3.2", + "@luma.gl/shadertools": "^9.3.2", + "deck.gl-raster-examples-shared": "workspace:*", + "icechunk-js": "^0.4.0", + "maplibre-gl": "^5.24.0", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "react-map-gl": "^8.1.1", + "zarrita": "^0.7.3" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "gh-pages": "^6.3.0", + "tsx": "^4.20.0", + "vite": "^8.0.0" + } +} diff --git a/examples/nldas-icechunk/src/App.tsx b/examples/nldas-icechunk/src/App.tsx new file mode 100644 index 00000000..ba5a4186 --- /dev/null +++ b/examples/nldas-icechunk/src/App.tsx @@ -0,0 +1,24 @@ +import { DeckGlOverlay } from "deck.gl-raster-examples-shared"; +import "maplibre-gl/dist/maplibre-gl.css"; +import { useRef } from "react"; +import type { MapRef } from "react-map-gl/maplibre"; +import { Map as MaplibreMap } from "react-map-gl/maplibre"; + +// Keyless CARTO basemap; light background reads well under a data overlay. +const BASEMAP_STYLE = + "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"; + +export default function App() { + const mapRef = useRef(null); + return ( +
+ + + +
+ ); +} diff --git a/examples/nldas-icechunk/src/main.tsx b/examples/nldas-icechunk/src/main.tsx new file mode 100644 index 00000000..171b36eb --- /dev/null +++ b/examples/nldas-icechunk/src/main.tsx @@ -0,0 +1,12 @@ +import { ExampleProvider } from "deck.gl-raster-examples-shared"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App.js"; + +createRoot(document.getElementById("root")!).render( + + + + + , +); diff --git a/examples/nldas-icechunk/src/vite-env.d.ts b/examples/nldas-icechunk/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/examples/nldas-icechunk/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/nldas-icechunk/tsconfig.json b/examples/nldas-icechunk/tsconfig.json new file mode 100644 index 00000000..c4d666fe --- /dev/null +++ b/examples/nldas-icechunk/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.base.json", + "include": ["src", "scripts"] +} diff --git a/examples/nldas-icechunk/vite.config.ts b/examples/nldas-icechunk/vite.config.ts new file mode 100644 index 00000000..27711d63 --- /dev/null +++ b/examples/nldas-icechunk/vite.config.ts @@ -0,0 +1,11 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()], + base: "/deck.gl-raster/examples/nldas-icechunk/", + worker: { format: "es" }, + server: { + port: 3000, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69f9e273..0312bf33 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -544,6 +544,73 @@ importers: specifier: ^8.0.0 version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.2)(jiti@1.21.7)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3) + examples/nldas-icechunk: + dependencies: + '@deck.gl/core': + specifier: ^9.3.1 + version: 9.3.2 + '@deck.gl/geo-layers': + specifier: ^9.3.1 + version: 9.3.2(@deck.gl/core@9.3.2)(@deck.gl/extensions@9.3.2(@deck.gl/core@9.3.2)(@luma.gl/core@9.3.3)(@luma.gl/engine@9.3.3(@luma.gl/core@9.3.3)(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.3))))(@deck.gl/layers@9.3.2(@deck.gl/core@9.3.2)(@loaders.gl/core@4.4.1)(@luma.gl/core@9.3.3)(@luma.gl/engine@9.3.3(@luma.gl/core@9.3.3)(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.3))))(@deck.gl/mesh-layers@9.3.2(@deck.gl/core@9.3.2)(@loaders.gl/core@4.4.1)(@luma.gl/core@9.3.3)(@luma.gl/engine@9.3.3(@luma.gl/core@9.3.3)(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.3)))(@luma.gl/gltf@9.3.3(@luma.gl/core@9.3.3)(@luma.gl/engine@9.3.3(@luma.gl/core@9.3.3)(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.3)))(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.3)))(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.3)))(@loaders.gl/core@4.4.1)(@luma.gl/core@9.3.3)(@luma.gl/engine@9.3.3(@luma.gl/core@9.3.3)(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.3))) + '@deck.gl/layers': + specifier: ^9.3.1 + version: 9.3.2(@deck.gl/core@9.3.2)(@loaders.gl/core@4.4.1)(@luma.gl/core@9.3.3)(@luma.gl/engine@9.3.3(@luma.gl/core@9.3.3)(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.3))) + '@deck.gl/mapbox': + specifier: ^9.3.1 + version: 9.3.2(@deck.gl/core@9.3.2)(@luma.gl/core@9.3.3)(@math.gl/web-mercator@4.1.0) + '@developmentseed/deck.gl-raster': + specifier: workspace:^ + version: link:../../packages/deck.gl-raster + '@developmentseed/deck.gl-zarr': + specifier: workspace:^ + version: link:../../packages/deck.gl-zarr + '@luma.gl/core': + specifier: ^9.3.3 + version: 9.3.3 + '@luma.gl/shadertools': + specifier: ^9.3.3 + version: 9.3.3(@luma.gl/core@9.3.3) + deck.gl-raster-examples-shared: + specifier: workspace:* + version: link:../_shared + icechunk-js: + specifier: ^0.4.0 + version: 0.4.0(zarrita@0.7.3) + maplibre-gl: + specifier: ^5.24.0 + version: 5.24.0 + react: + specifier: ^19.2.5 + version: 19.2.5 + react-dom: + specifier: ^19.2.5 + version: 19.2.5(react@19.2.5) + react-map-gl: + specifier: ^8.1.1 + version: 8.1.1(maplibre-gl@5.24.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + zarrita: + specifier: ^0.7.3 + version: 0.7.3 + devDependencies: + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.1(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.2)(jiti@1.21.7)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3)) + gh-pages: + specifier: ^6.3.0 + version: 6.3.0 + tsx: + specifier: ^4.20.0 + version: 4.21.0 + vite: + specifier: ^8.0.0 + version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.2)(jiti@1.21.7)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3) + examples/sentinel-2: dependencies: '@chakra-ui/react': @@ -3274,6 +3341,10 @@ packages: '@module-federation/webpack-bundler-runtime@0.22.0': resolution: {integrity: sha512-aM8gCqXu+/4wBmJtVeMeeMN5guw3chf+2i6HajKtQv7SJfxV/f4IyNQJUeUQu9HfiAZHjqtMV5Lvq/Lvh8LdyA==} + '@msgpack/msgpack@3.1.3': + resolution: {integrity: sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==} + engines: {node: '>= 18'} + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -6268,6 +6339,14 @@ packages: resolution: {integrity: sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==} engines: {node: '>=10.18'} + icechunk-js@0.4.0: + resolution: {integrity: sha512-au8ALQqpd60LAVHgeQzf0c1V7xSfvH89JFPCyjdZoy5qcQ9Uutu3h1SQBKXIa5iccf6VPjfFOYxlavoFFS8Rfg==} + peerDependencies: + zarrita: ^0.5.0 || ^0.6.0 || ^0.7.0 + peerDependenciesMeta: + zarrita: + optional: true + iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -12747,6 +12826,8 @@ snapshots: '@module-federation/runtime': 0.22.0 '@module-federation/sdk': 0.22.0 + '@msgpack/msgpack@3.1.3': {} + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.10.0 @@ -16226,6 +16307,14 @@ snapshots: hyperdyperid@1.2.0: {} + icechunk-js@0.4.0(zarrita@0.7.3): + dependencies: + '@msgpack/msgpack': 3.1.3 + flatbuffers: 25.9.23 + fzstd: 0.1.1 + optionalDependencies: + zarrita: 0.7.3 + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 From 757f1f95d7b4768c90485b753a2fa02e44bcfa12 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 27 May 2026 15:32:57 -0400 Subject: [PATCH 05/24] chore(nldas-icechunk): add virtual-chunk smoke spike Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/nldas-icechunk/scripts/smoke.ts | 118 +++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 examples/nldas-icechunk/scripts/smoke.ts diff --git a/examples/nldas-icechunk/scripts/smoke.ts b/examples/nldas-icechunk/scripts/smoke.ts new file mode 100644 index 00000000..98b2065e --- /dev/null +++ b/examples/nldas-icechunk/scripts/smoke.ts @@ -0,0 +1,118 @@ +/** + * Spike: prove that NLDAS-3 Tair chunks (virtual references into another S3 + * prefix) can be read from Node via icechunk-js + zarrita, and print the + * constants needed for src/nldas/metadata.ts. + * + * Run: cd examples/nldas-icechunk && pnpm exec tsx scripts/smoke.ts + */ +import { + HttpStorage, + IcechunkStore, + ReadSession, + Repository, +} from "icechunk-js"; +import * as zarr from "zarrita"; + +const REPO_URL = + "https://nasa-waterinsight.s3.us-west-2.amazonaws.com/virtual-zarr-store/NLDAS-3-icechunk"; +const BRANCH = "main"; +// VCC name (from config.yaml) → public HTTPS prefix for the source objects. +const VIRTUAL_CHUNK_CONTAINERS = new Map([ + [ + "s3://nasa-waterinsight/NLDAS3/forcing/daily/", + "https://nasa-waterinsight.s3.us-west-2.amazonaws.com/NLDAS3/forcing/daily/", + ], +]); + +async function main() { + const storage = new HttpStorage(REPO_URL); + const repo = await Repository.open({ storage }); + + // Resolve the main-branch snapshot id, then reopen a session WITH the + // virtual chunk container map (checkoutBranch can't take it). + const branchSession = await repo.checkoutBranch(BRANCH); + const snapshotId = branchSession.getSnapshotId(); + console.log( + "snapshotId:", + snapshotId, + "type:", + snapshotId?.constructor?.name, + ); + + // ObjectId12 should be a Uint8Array; coerce defensively if not. + const snapshotBytes = + snapshotId instanceof Uint8Array + ? snapshotId + : new Uint8Array(snapshotId as ArrayLike); + const session = await ReadSession.open(storage, snapshotBytes, { + virtualChunkContainers: VIRTUAL_CHUNK_CONTAINERS, + }); + + // Discover node paths (find the Tair array's exact path + coordinate arrays). + console.log( + "nodes:", + session.listNodes().map((n) => n.path), + ); + + const store = await IcechunkStore.open(session); + + // Adjust the path if listNodes shows Tair nested under a group. + const tair = await zarr.open(store.resolve("/Tair"), { kind: "array" }); + console.log("Tair shape:", tair.shape, "dtype:", tair.dtype); + console.log("Tair attrs:", tair.attrs); + + // Read coordinate arrays to derive the affine. Names come from listNodes / + // Tair's dimension metadata; "/time", "/lat", "/lon" are the likely paths. + const lat = await zarr.open(store.resolve("/lat"), { kind: "array" }); + const lon = await zarr.open(store.resolve("/lon"), { kind: "array" }); + const latVals = (await zarr.get(lat)).data as Float32Array | Float64Array; + const lonVals = (await zarr.get(lon)).data as Float32Array | Float64Array; + const dLat = Number(latVals[1]) - Number(latVals[0]); + const dLon = Number(lonVals[1]) - Number(lonVals[0]); + console.log( + "lat[0..1]:", + latVals[0], + latVals[1], + "dLat:", + dLat, + "n:", + latVals.length, + ); + console.log( + "lon[0..1]:", + lonVals[0], + lonVals[1], + "dLon:", + dLon, + "n:", + lonVals.length, + ); + + // Pull one Tair chunk through the virtual container (the real test). + const probe = await zarr.get(tair, [0, zarr.slice(0, 8), zarr.slice(0, 8)]); + console.log( + "probe shape:", + probe.shape, + "first values:", + Array.from(probe.data as Float32Array).slice(0, 8), + ); + + // Print a ready-to-paste NLDAS_GEOZARR_ATTRS (origin = cell-center − half pixel). + const height = latVals.length; + const width = lonVals.length; + console.log("\n--- paste into metadata.ts ---"); + console.log(`"spatial:dimensions": [, ],`); + console.log( + `"spatial:transform": [${dLon}, 0, ${Number(lonVals[0]) - dLon / 2}, 0, ${dLat}, ${Number(latVals[0]) - dLat / 2}],`, + ); + console.log(`"spatial:shape": [${height}, ${width}],`); + console.log(`"proj:code": "EPSG:4326",`); + console.log( + `// units: ${tair.attrs.units} fill: ${tair.attrs._FillValue ?? tair.attrs.missing_value}`, + ); +} + +main().catch((err) => { + console.error("SMOKE FAILED:", err); + process.exit(1); +}); From 58976418c5cfce77a8f87b432ab0282612c62634 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 27 May 2026 15:33:50 -0400 Subject: [PATCH 06/24] feat(nldas-icechunk): hard-code grid metadata and store opener Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/nldas-icechunk/src/nldas/metadata.ts | 70 +++++++++++++++++++ examples/nldas-icechunk/src/nldas/store.ts | 45 ++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 examples/nldas-icechunk/src/nldas/metadata.ts create mode 100644 examples/nldas-icechunk/src/nldas/store.ts diff --git a/examples/nldas-icechunk/src/nldas/metadata.ts b/examples/nldas-icechunk/src/nldas/metadata.ts new file mode 100644 index 00000000..029f6427 --- /dev/null +++ b/examples/nldas-icechunk/src/nldas/metadata.ts @@ -0,0 +1,70 @@ +import { COLORMAP_INDEX } from "@developmentseed/deck.gl-raster/gpu-modules"; + +/** Public NLDAS-3 icechunk repo (anonymous, CORS-enabled). */ +export const REPO_URL = + "https://nasa-waterinsight.s3.us-west-2.amazonaws.com/virtual-zarr-store/NLDAS-3-icechunk"; + +/** Branch to read. */ +export const BRANCH = "main"; + +/** Path to the Tair array within the store. */ +export const VARIABLE = "/Tair"; + +/** + * Virtual chunk container map: the container name declared in the repo's + * `config.yaml` → the public HTTPS prefix the browser can fetch. Tair's chunks + * are virtual references into the original NLDAS-3 source objects in the same + * `nasa-waterinsight` bucket. + */ +export const VIRTUAL_CHUNK_CONTAINERS = new Map([ + [ + "s3://nasa-waterinsight/NLDAS3/forcing/daily/", + "https://nasa-waterinsight.s3.us-west-2.amazonaws.com/NLDAS3/forcing/daily/", + ], +]); + +/** Name of the non-spatial dimension (array dims are ["time", "lat", "lon"]). */ +export const TIME_DIM = "time"; + +/** + * Which timestep to render (single static frame). Time is "days since + * 2001-01-01" with one-day increments, so index 3482 ≈ 2010-07-13 — a summer + * day with good thermal contrast over North America. + */ +export const TIME_INDEX = 3482; + +/** + * Sentinel for fill pixels. The store's `missing_value` is -9999; getTileData + * also maps any non-finite (NaN/Inf) value to this so the render pipeline can + * discard them with a single comparison. + */ +export const NODATA_VALUE = -9999; + +/** Fixed rescale range in Kelvin (Tair `vmin`/`vmax` ≈ 228–304 K). */ +export const RESCALE_MIN = 228; +export const RESCALE_MAX = 305; + +/** Colormap sprite layer + orientation (thermal sequential, like ECMWF). */ +export const COLORMAP_INDEX_TAIR = COLORMAP_INDEX.thermal; +export const COLORMAP_REVERSED = false; + +/** + * Synthetic GeoZarr-compliant attrs (the virtual store is not GeoZarr). + * Mirrors the ECMWF example's approach. Values derived from the store by + * `scripts/smoke.ts`. + * + * Affine [a,b,c,d,e,f] (see `@developmentseed/affine`): + * x = a*col + b*row + c ; y = d*col + e*row + f + * + * NLDAS-3 latitude is ASCENDING (row 0 = south, lat 7.005°), so the row step + * `e` is positive and the origin is the bottom-left cell corner = first cell + * center − half a pixel. Grid: 0.01° over lon [-169, -52], lat [7, 72]. + */ +export const NLDAS_GEOZARR_ATTRS = { + "spatial:dimensions": ["lat", "lon"], + "spatial:transform": [ + 0.0099945068359375, 0, -168.99999237060547, 0, 0.010000228881835938, 7, + ], + "spatial:shape": [6500, 11700], + "proj:code": "EPSG:4326", +} as const; diff --git a/examples/nldas-icechunk/src/nldas/store.ts b/examples/nldas-icechunk/src/nldas/store.ts new file mode 100644 index 00000000..58c9f763 --- /dev/null +++ b/examples/nldas-icechunk/src/nldas/store.ts @@ -0,0 +1,45 @@ +import { + HttpStorage, + IcechunkStore, + ReadSession, + Repository, +} from "icechunk-js"; +import * as zarr from "zarrita"; +import { + BRANCH, + REPO_URL, + VARIABLE, + VIRTUAL_CHUNK_CONTAINERS, +} from "./metadata.js"; + +/** + * Open the NLDAS-3 Tair array from the public icechunk repo, with the virtual + * chunk container authorized so chunk reads resolve to public HTTPS objects. + * + * The container map is only accepted by `ReadSession.open`, so we resolve the + * branch snapshot id first, then open a session that carries it. + */ +export async function openNldasTair(): Promise< + zarr.Array<"float32", zarr.Readable> +> { + const storage = new HttpStorage(REPO_URL); + const repo = await Repository.open({ storage }); + + const branchSession = await repo.checkoutBranch(BRANCH); + const snapshotId = branchSession.getSnapshotId(); + const snapshotBytes = + snapshotId instanceof Uint8Array + ? snapshotId + : new Uint8Array(snapshotId as ArrayLike); + + const session = await ReadSession.open(storage, snapshotBytes, { + virtualChunkContainers: VIRTUAL_CHUNK_CONTAINERS, + }); + const store = await IcechunkStore.open(session); + + const node = await zarr.open(store.resolve(VARIABLE), { kind: "array" }); + if (!node.is("float32")) { + throw new Error(`Expected ${VARIABLE} to be float32, got ${node.dtype}`); + } + return node; +} From 6dd5c113ebd040ba9de6cc107e1835be7318b49a Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 27 May 2026 15:34:20 -0400 Subject: [PATCH 07/24] feat(nldas-icechunk): fetch tile data as r32float texture Co-Authored-By: Claude Opus 4.7 (1M context) --- .../nldas-icechunk/src/nldas/get-tile-data.ts | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 examples/nldas-icechunk/src/nldas/get-tile-data.ts diff --git a/examples/nldas-icechunk/src/nldas/get-tile-data.ts b/examples/nldas-icechunk/src/nldas/get-tile-data.ts new file mode 100644 index 00000000..7e1a5b75 --- /dev/null +++ b/examples/nldas-icechunk/src/nldas/get-tile-data.ts @@ -0,0 +1,64 @@ +import type { MinimalTileData } from "@developmentseed/deck.gl-raster"; +import type { GetTileDataOptions } from "@developmentseed/deck.gl-zarr"; +import type { Texture } from "@luma.gl/core"; +import * as zarr from "zarrita"; +import { NODATA_VALUE } from "./metadata.js"; + +/** Per-tile data: one spatial chunk uploaded as an r32float texture. */ +export type NldasTileData = MinimalTileData & { + /** r32float 2D texture holding the tile's Tair values. */ + texture: Texture; +}; + +/** + * Slice one spatial chunk of Tair (time pinned by the layer's selection) and + * upload it as a single-channel float texture. Non-finite values (NaN/Inf + * fills) are mapped to NODATA_VALUE so the render pipeline can discard them. + */ +export async function getTileData( + arr: zarr.Array<"float32", zarr.Readable>, + options: GetTileDataOptions, +): Promise { + const { device, sliceSpec, width, height, signal } = options; + + const chunk = await zarr.get(arr, sliceSpec, { signal }); + if (chunk.shape.length !== 2) { + throw new Error( + `Expected 2D sliced chunk (y, x), got shape [${chunk.shape.join(", ")}]`, + ); + } + if (chunk.shape[0] !== height || chunk.shape[1] !== width) { + throw new Error( + `Tile shape mismatch: expected [${height}, ${width}], got ` + + `[${chunk.shape.join(", ")}]`, + ); + } + + const data = chunk.data as Float32Array; + for (let i = 0; i < data.length; i++) { + if (!Number.isFinite(data[i]!)) { + data[i] = NODATA_VALUE; + } + } + + const texture = device.createTexture({ + format: "r32float", + width, + height, + mipLevels: 1, + data, + sampler: { + minFilter: "nearest", + magFilter: "nearest", + addressModeU: "clamp-to-edge", + addressModeV: "clamp-to-edge", + }, + }); + + return { + texture, + width, + height, + byteLength: data.byteLength, + }; +} From e901badf57646398852d58dc5bdf59ec1ac01b62 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 27 May 2026 15:34:49 -0400 Subject: [PATCH 08/24] feat(nldas-icechunk): GPU colormap render pipeline Co-Authored-By: Claude Opus 4.7 (1M context) --- .../nldas-icechunk/src/nldas/render-tile.ts | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 examples/nldas-icechunk/src/nldas/render-tile.ts diff --git a/examples/nldas-icechunk/src/nldas/render-tile.ts b/examples/nldas-icechunk/src/nldas/render-tile.ts new file mode 100644 index 00000000..036fffcd --- /dev/null +++ b/examples/nldas-icechunk/src/nldas/render-tile.ts @@ -0,0 +1,55 @@ +import type { RenderTileResult } from "@developmentseed/deck.gl-raster"; +import { + Colormap, + CreateTexture, + FilterNoDataVal, + LinearRescale, +} from "@developmentseed/deck.gl-raster/gpu-modules"; +import type { Texture } from "@luma.gl/core"; +import type { NldasTileData } from "./get-tile-data.js"; + +/** Arguments for {@link makeRenderTile}. */ +export type MakeRenderTileArgs = { + /** Colormap sprite texture (2d-array; shared across tiles). */ + colormapTexture: Texture; + /** Which layer of the sprite to sample. */ + colormapIndex: number; + /** Whether to reverse the colormap. */ + colormapReversed: boolean; + /** Sentinel value to discard (set by getTileData on fill/NaN pixels). */ + noDataValue: number; + /** Minimum value for rescale (variable units). */ + rescaleMin: number; + /** Maximum value for rescale. */ + rescaleMax: number; +}; + +/** + * Build a renderTile callback that samples the tile's float texture, discards + * nodata, rescales to [0, 1], and applies a colormap — all on the GPU. + */ +export function makeRenderTile(args: MakeRenderTileArgs) { + const { + colormapTexture, + colormapIndex, + colormapReversed, + noDataValue, + rescaleMin, + rescaleMax, + } = args; + return function renderTile(data: NldasTileData): RenderTileResult { + return { + renderPipeline: [ + // r32float sample → color = vec4(value, 0, 0, 1) + { module: CreateTexture, props: { textureName: data.texture } }, + // Discard fills on the raw value before rescale clamps it. + { module: FilterNoDataVal, props: { value: noDataValue } }, + { module: LinearRescale, props: { rescaleMin, rescaleMax } }, + { + module: Colormap, + props: { colormapTexture, colormapIndex, reversed: colormapReversed }, + }, + ], + }; + }; +} From b411c038591e492c6844272b5132666a7d7a3271 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 27 May 2026 15:38:26 -0400 Subject: [PATCH 09/24] feat(nldas-icechunk): render Tair via ZarrLayer over icechunk store Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/nldas-icechunk/src/App.tsx | 99 ++++++++++++++++++++++++++++- 1 file changed, 97 insertions(+), 2 deletions(-) diff --git a/examples/nldas-icechunk/src/App.tsx b/examples/nldas-icechunk/src/App.tsx index ba5a4186..90ec594b 100644 --- a/examples/nldas-icechunk/src/App.tsx +++ b/examples/nldas-icechunk/src/App.tsx @@ -1,8 +1,30 @@ +import { + createColormapTexture, + decodeColormapSprite, +} from "@developmentseed/deck.gl-raster/gpu-modules"; +import colormapsPngUrl from "@developmentseed/deck.gl-raster/gpu-modules/colormaps.png"; +import { ZarrLayer } from "@developmentseed/deck.gl-zarr"; +import type { Device, Texture } from "@luma.gl/core"; import { DeckGlOverlay } from "deck.gl-raster-examples-shared"; import "maplibre-gl/dist/maplibre-gl.css"; -import { useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import type { MapRef } from "react-map-gl/maplibre"; import { Map as MaplibreMap } from "react-map-gl/maplibre"; +import type * as zarr from "zarrita"; +import type { NldasTileData } from "./nldas/get-tile-data.js"; +import { getTileData } from "./nldas/get-tile-data.js"; +import { + COLORMAP_INDEX_TAIR, + COLORMAP_REVERSED, + NLDAS_GEOZARR_ATTRS, + NODATA_VALUE, + RESCALE_MAX, + RESCALE_MIN, + TIME_DIM, + TIME_INDEX, +} from "./nldas/metadata.js"; +import { makeRenderTile } from "./nldas/render-tile.js"; +import { openNldasTair } from "./nldas/store.js"; // Keyless CARTO basemap; light background reads well under a data overlay. const BASEMAP_STYLE = @@ -10,6 +32,75 @@ const BASEMAP_STYLE = export default function App() { const mapRef = useRef(null); + const [arr, setArr] = useState | null>( + null, + ); + const [device, setDevice] = useState(null); + const [colormapImage, setColormapImage] = useState(null); + const [colormapTexture, setColormapTexture] = useState(null); + + // Open the icechunk store + Tair array once. + useEffect(() => { + let cancelled = false; + (async () => { + const opened = await openNldasTair(); + if (!cancelled) { + setArr(opened); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + // Decode the shipped colormap sprite once (no GPU device needed). + useEffect(() => { + let cancelled = false; + (async () => { + const resp = await fetch(colormapsPngUrl); + const bytes = await resp.arrayBuffer(); + const image = await decodeColormapSprite(bytes); + if (!cancelled) { + setColormapImage(image); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + // Upload the colormap sprite once both the Device and the decoded image exist. + useEffect(() => { + if (!device || !colormapImage) { + return; + } + setColormapTexture(createColormapTexture(device, colormapImage)); + }, [device, colormapImage]); + + const layers = + arr && colormapTexture + ? [ + new ZarrLayer({ + id: "nldas-tair", + node: arr, + metadata: NLDAS_GEOZARR_ATTRS, + selection: { [TIME_DIM]: TIME_INDEX }, + getTileData, + renderTile: makeRenderTile({ + colormapTexture, + colormapIndex: COLORMAP_INDEX_TAIR, + colormapReversed: COLORMAP_REVERSED, + noDataValue: NODATA_VALUE, + rescaleMin: RESCALE_MIN, + rescaleMax: RESCALE_MAX, + }), + // source bucket supports HTTP/2 multiplexing + maxRequests: 20, + maxCacheSize: 20, + }), + ] + : []; + return (
- +
); From 02caad31ca8d4727d94d4c1f14141a99f0e1f14c Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 27 May 2026 15:39:06 -0400 Subject: [PATCH 10/24] docs(nldas-icechunk): add README Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/nldas-icechunk/README.md | 37 +++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 examples/nldas-icechunk/README.md diff --git a/examples/nldas-icechunk/README.md b/examples/nldas-icechunk/README.md new file mode 100644 index 00000000..d51dff75 --- /dev/null +++ b/examples/nldas-icechunk/README.md @@ -0,0 +1,37 @@ +# NLDAS-3 icechunk Example + +Renders a single timestep of NLDAS-3 air temperature (`Tair`) from a public +[icechunk](https://icechunk.io) repository, read in the browser via +[`icechunk-js`](https://github.com/EarthyScience/icechunk-js) + zarrita and +displayed with `@developmentseed/deck.gl-zarr`'s `ZarrLayer`. + +The store is a *virtual* Zarr: its chunks reference NLDAS-3 source objects in +the same public `nasa-waterinsight` S3 bucket, authorized through a +`virtualChunkContainers` map. + +## Setup + +1. Install dependencies from the repository root: + ```bash + pnpm install + ``` +2. Build the packages: + ```bash + pnpm build + ``` +3. Run the development server: + ```bash + cd examples/nldas-icechunk + pnpm dev + ``` +4. Open your browser to http://localhost:3000 + +## Re-deriving the grid + +`src/nldas/metadata.ts` hard-codes the grid (the virtual store is not +GeoZarr-compliant). To re-derive it, run the spike: + +```bash +cd examples/nldas-icechunk +pnpm exec tsx scripts/smoke.ts +``` From da3fcc848a7b70f2266fc87b7de67c9d606eecf1 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 27 May 2026 15:47:08 -0400 Subject: [PATCH 11/24] feat(nldas-icechunk): add top-left intro panel Adds a ControlPanel (intro-only, no controls) describing the example, matching the other examples' info box. Re-adds Chakra deps for Text. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/nldas-icechunk/package.json | 2 ++ examples/nldas-icechunk/src/App.tsx | 28 ++++++++++++++++++- examples/nldas-icechunk/src/nldas/metadata.ts | 2 +- pnpm-lock.yaml | 6 ++++ 4 files changed, 36 insertions(+), 2 deletions(-) diff --git a/examples/nldas-icechunk/package.json b/examples/nldas-icechunk/package.json index e709aafe..6c8b9dd8 100644 --- a/examples/nldas-icechunk/package.json +++ b/examples/nldas-icechunk/package.json @@ -10,12 +10,14 @@ "publish": "pnpm build && gh-pages -d dist -b gh-pages -e examples/nldas-icechunk" }, "dependencies": { + "@chakra-ui/react": "^3.34.0", "@deck.gl/core": "^9.3.2", "@deck.gl/geo-layers": "^9.3.2", "@deck.gl/layers": "^9.3.2", "@deck.gl/mapbox": "^9.3.2", "@developmentseed/deck.gl-raster": "workspace:^", "@developmentseed/deck.gl-zarr": "workspace:^", + "@emotion/react": "^11.14.0", "@luma.gl/core": "^9.3.2", "@luma.gl/shadertools": "^9.3.2", "deck.gl-raster-examples-shared": "workspace:*", diff --git a/examples/nldas-icechunk/src/App.tsx b/examples/nldas-icechunk/src/App.tsx index 90ec594b..13608fd2 100644 --- a/examples/nldas-icechunk/src/App.tsx +++ b/examples/nldas-icechunk/src/App.tsx @@ -1,3 +1,4 @@ +import { Text } from "@chakra-ui/react"; import { createColormapTexture, decodeColormapSprite, @@ -5,7 +6,11 @@ import { import colormapsPngUrl from "@developmentseed/deck.gl-raster/gpu-modules/colormaps.png"; import { ZarrLayer } from "@developmentseed/deck.gl-zarr"; import type { Device, Texture } from "@luma.gl/core"; -import { DeckGlOverlay } from "deck.gl-raster-examples-shared"; +import { + ControlPanel, + DeckGlOverlay, + ExternalLink, +} from "deck.gl-raster-examples-shared"; import "maplibre-gl/dist/maplibre-gl.css"; import { useEffect, useRef, useState } from "react"; import type { MapRef } from "react-map-gl/maplibre"; @@ -114,6 +119,27 @@ export default function App() { onDeviceInitialized={setDevice} /> + + + Reads NASA's NLDAS-3 daily 2-meter air temperature directly from a + public{" "} + icechunk{" "} + repository in the browser — no server in between. The store is a{" "} + virtual Zarr: its chunks reference NLDAS-3 source files in + the same S3 bucket, read with{" "} + + icechunk-js + {" "} + + zarrita and rendered by a ZarrLayer. + + + Showing a single day (2010-07-16), colorized on the GPU with the + thermal colormap. Ocean / no-data is left transparent. + + ); } diff --git a/examples/nldas-icechunk/src/nldas/metadata.ts b/examples/nldas-icechunk/src/nldas/metadata.ts index 029f6427..6661712d 100644 --- a/examples/nldas-icechunk/src/nldas/metadata.ts +++ b/examples/nldas-icechunk/src/nldas/metadata.ts @@ -28,7 +28,7 @@ export const TIME_DIM = "time"; /** * Which timestep to render (single static frame). Time is "days since - * 2001-01-01" with one-day increments, so index 3482 ≈ 2010-07-13 — a summer + * 2001-01-01" with one-day increments, so index 3482 = 2010-07-16 — a summer * day with good thermal contrast over North America. */ export const TIME_INDEX = 3482; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0312bf33..fb46ca6b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -546,6 +546,9 @@ importers: examples/nldas-icechunk: dependencies: + '@chakra-ui/react': + specifier: ^3.34.0 + version: 3.35.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@deck.gl/core': specifier: ^9.3.1 version: 9.3.2 @@ -564,6 +567,9 @@ importers: '@developmentseed/deck.gl-zarr': specifier: workspace:^ version: link:../../packages/deck.gl-zarr + '@emotion/react': + specifier: ^11.14.0 + version: 11.14.0(@types/react@19.2.14)(react@19.2.5) '@luma.gl/core': specifier: ^9.3.3 version: 9.3.3 From 013c65e485f51b6a6f49187e735a31a38d51737d Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 27 May 2026 15:52:03 -0400 Subject: [PATCH 12/24] feat(nldas-icechunk): constrain map to the NLDAS-3 data extent Set maxBounds to lon [-169, -52], lat [7, 72] so the map can't pan or zoom out beyond where data exists. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/nldas-icechunk/src/App.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/examples/nldas-icechunk/src/App.tsx b/examples/nldas-icechunk/src/App.tsx index 13608fd2..9cc440c6 100644 --- a/examples/nldas-icechunk/src/App.tsx +++ b/examples/nldas-icechunk/src/App.tsx @@ -35,6 +35,14 @@ import { openNldasTair } from "./nldas/store.js"; const BASEMAP_STYLE = "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"; +// Geographic extent of the NLDAS-3 grid (derived from NLDAS_GEOZARR_ATTRS: +// lon [-169, -52], lat [7, 72]). Used as maxBounds so the map can't pan or +// zoom out beyond where data exists. +const DATA_BOUNDS: [[number, number], [number, number]] = [ + [-169, 7], + [-52, 72], +]; + export default function App() { const mapRef = useRef(null); const [arr, setArr] = useState | null>( @@ -112,6 +120,7 @@ export default function App() { ref={mapRef} initialViewState={{ longitude: -98, latitude: 39, zoom: 3.5 }} mapStyle={BASEMAP_STYLE} + maxBounds={DATA_BOUNDS} > Date: Wed, 27 May 2026 15:55:04 -0400 Subject: [PATCH 13/24] fix map bounds --- examples/nldas-icechunk/src/App.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/examples/nldas-icechunk/src/App.tsx b/examples/nldas-icechunk/src/App.tsx index 9cc440c6..093935a1 100644 --- a/examples/nldas-icechunk/src/App.tsx +++ b/examples/nldas-icechunk/src/App.tsx @@ -35,12 +35,10 @@ import { openNldasTair } from "./nldas/store.js"; const BASEMAP_STYLE = "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"; -// Geographic extent of the NLDAS-3 grid (derived from NLDAS_GEOZARR_ATTRS: -// lon [-169, -52], lat [7, 72]). Used as maxBounds so the map can't pan or -// zoom out beyond where data exists. +// Min longitude, min latitude, max longitude, max latitude const DATA_BOUNDS: [[number, number], [number, number]] = [ - [-169, 7], - [-52, 72], + [-180, -20], + [-20, 80], ]; export default function App() { From 897ddc54e30d95670eb47b74cb79f11b096aee7e Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 27 May 2026 15:57:09 -0400 Subject: [PATCH 14/24] chore(nldas-icechunk): drop one-off smoke spike script The grid constants are baked into metadata.ts; the spike was scaffolding. Removes scripts/smoke.ts, its README section, the tsx devDep, and the scripts tsconfig include. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/nldas-icechunk/README.md | 11 +- examples/nldas-icechunk/package.json | 1 - examples/nldas-icechunk/scripts/smoke.ts | 118 ------------------ examples/nldas-icechunk/src/nldas/metadata.ts | 4 +- examples/nldas-icechunk/tsconfig.json | 2 +- pnpm-lock.yaml | 3 - 6 files changed, 5 insertions(+), 134 deletions(-) delete mode 100644 examples/nldas-icechunk/scripts/smoke.ts diff --git a/examples/nldas-icechunk/README.md b/examples/nldas-icechunk/README.md index d51dff75..3bac3b86 100644 --- a/examples/nldas-icechunk/README.md +++ b/examples/nldas-icechunk/README.md @@ -26,12 +26,5 @@ the same public `nasa-waterinsight` S3 bucket, authorized through a ``` 4. Open your browser to http://localhost:3000 -## Re-deriving the grid - -`src/nldas/metadata.ts` hard-codes the grid (the virtual store is not -GeoZarr-compliant). To re-derive it, run the spike: - -```bash -cd examples/nldas-icechunk -pnpm exec tsx scripts/smoke.ts -``` +`src/nldas/metadata.ts` hard-codes the grid (origin, pixel size, shape, units, +fill) because the virtual store is not GeoZarr-compliant. diff --git a/examples/nldas-icechunk/package.json b/examples/nldas-icechunk/package.json index 6c8b9dd8..9f5b2898 100644 --- a/examples/nldas-icechunk/package.json +++ b/examples/nldas-icechunk/package.json @@ -33,7 +33,6 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", "gh-pages": "^6.3.0", - "tsx": "^4.20.0", "vite": "^8.0.0" } } diff --git a/examples/nldas-icechunk/scripts/smoke.ts b/examples/nldas-icechunk/scripts/smoke.ts deleted file mode 100644 index 98b2065e..00000000 --- a/examples/nldas-icechunk/scripts/smoke.ts +++ /dev/null @@ -1,118 +0,0 @@ -/** - * Spike: prove that NLDAS-3 Tair chunks (virtual references into another S3 - * prefix) can be read from Node via icechunk-js + zarrita, and print the - * constants needed for src/nldas/metadata.ts. - * - * Run: cd examples/nldas-icechunk && pnpm exec tsx scripts/smoke.ts - */ -import { - HttpStorage, - IcechunkStore, - ReadSession, - Repository, -} from "icechunk-js"; -import * as zarr from "zarrita"; - -const REPO_URL = - "https://nasa-waterinsight.s3.us-west-2.amazonaws.com/virtual-zarr-store/NLDAS-3-icechunk"; -const BRANCH = "main"; -// VCC name (from config.yaml) → public HTTPS prefix for the source objects. -const VIRTUAL_CHUNK_CONTAINERS = new Map([ - [ - "s3://nasa-waterinsight/NLDAS3/forcing/daily/", - "https://nasa-waterinsight.s3.us-west-2.amazonaws.com/NLDAS3/forcing/daily/", - ], -]); - -async function main() { - const storage = new HttpStorage(REPO_URL); - const repo = await Repository.open({ storage }); - - // Resolve the main-branch snapshot id, then reopen a session WITH the - // virtual chunk container map (checkoutBranch can't take it). - const branchSession = await repo.checkoutBranch(BRANCH); - const snapshotId = branchSession.getSnapshotId(); - console.log( - "snapshotId:", - snapshotId, - "type:", - snapshotId?.constructor?.name, - ); - - // ObjectId12 should be a Uint8Array; coerce defensively if not. - const snapshotBytes = - snapshotId instanceof Uint8Array - ? snapshotId - : new Uint8Array(snapshotId as ArrayLike); - const session = await ReadSession.open(storage, snapshotBytes, { - virtualChunkContainers: VIRTUAL_CHUNK_CONTAINERS, - }); - - // Discover node paths (find the Tair array's exact path + coordinate arrays). - console.log( - "nodes:", - session.listNodes().map((n) => n.path), - ); - - const store = await IcechunkStore.open(session); - - // Adjust the path if listNodes shows Tair nested under a group. - const tair = await zarr.open(store.resolve("/Tair"), { kind: "array" }); - console.log("Tair shape:", tair.shape, "dtype:", tair.dtype); - console.log("Tair attrs:", tair.attrs); - - // Read coordinate arrays to derive the affine. Names come from listNodes / - // Tair's dimension metadata; "/time", "/lat", "/lon" are the likely paths. - const lat = await zarr.open(store.resolve("/lat"), { kind: "array" }); - const lon = await zarr.open(store.resolve("/lon"), { kind: "array" }); - const latVals = (await zarr.get(lat)).data as Float32Array | Float64Array; - const lonVals = (await zarr.get(lon)).data as Float32Array | Float64Array; - const dLat = Number(latVals[1]) - Number(latVals[0]); - const dLon = Number(lonVals[1]) - Number(lonVals[0]); - console.log( - "lat[0..1]:", - latVals[0], - latVals[1], - "dLat:", - dLat, - "n:", - latVals.length, - ); - console.log( - "lon[0..1]:", - lonVals[0], - lonVals[1], - "dLon:", - dLon, - "n:", - lonVals.length, - ); - - // Pull one Tair chunk through the virtual container (the real test). - const probe = await zarr.get(tair, [0, zarr.slice(0, 8), zarr.slice(0, 8)]); - console.log( - "probe shape:", - probe.shape, - "first values:", - Array.from(probe.data as Float32Array).slice(0, 8), - ); - - // Print a ready-to-paste NLDAS_GEOZARR_ATTRS (origin = cell-center − half pixel). - const height = latVals.length; - const width = lonVals.length; - console.log("\n--- paste into metadata.ts ---"); - console.log(`"spatial:dimensions": [, ],`); - console.log( - `"spatial:transform": [${dLon}, 0, ${Number(lonVals[0]) - dLon / 2}, 0, ${dLat}, ${Number(latVals[0]) - dLat / 2}],`, - ); - console.log(`"spatial:shape": [${height}, ${width}],`); - console.log(`"proj:code": "EPSG:4326",`); - console.log( - `// units: ${tair.attrs.units} fill: ${tair.attrs._FillValue ?? tair.attrs.missing_value}`, - ); -} - -main().catch((err) => { - console.error("SMOKE FAILED:", err); - process.exit(1); -}); diff --git a/examples/nldas-icechunk/src/nldas/metadata.ts b/examples/nldas-icechunk/src/nldas/metadata.ts index 6661712d..068f3352 100644 --- a/examples/nldas-icechunk/src/nldas/metadata.ts +++ b/examples/nldas-icechunk/src/nldas/metadata.ts @@ -50,8 +50,8 @@ export const COLORMAP_REVERSED = false; /** * Synthetic GeoZarr-compliant attrs (the virtual store is not GeoZarr). - * Mirrors the ECMWF example's approach. Values derived from the store by - * `scripts/smoke.ts`. + * Mirrors the ECMWF example's approach. Values read once from the store's + * shape + lat/lon coordinate arrays and frozen here. * * Affine [a,b,c,d,e,f] (see `@developmentseed/affine`): * x = a*col + b*row + c ; y = d*col + e*row + f diff --git a/examples/nldas-icechunk/tsconfig.json b/examples/nldas-icechunk/tsconfig.json index c4d666fe..4bd6962d 100644 --- a/examples/nldas-icechunk/tsconfig.json +++ b/examples/nldas-icechunk/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "../tsconfig.base.json", - "include": ["src", "scripts"] + "include": ["src"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb46ca6b..f55447e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -610,9 +610,6 @@ importers: gh-pages: specifier: ^6.3.0 version: 6.3.0 - tsx: - specifier: ^4.20.0 - version: 4.21.0 vite: specifier: ^8.0.0 version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.2)(jiti@1.21.7)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3) From 84f27dfdb1ca8a2d1ce6d91e66230d6ee7f63814 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 27 May 2026 16:07:29 -0400 Subject: [PATCH 15/24] refactor(nldas-icechunk): address review feedback - Drop the CPU nodata-replacement loop: the source uses a finite -9999 sentinel (no NaN), already discarded on the GPU by FilterNoDataVal. - Remove redundant mipLevels: 1 (luma's default is already 1). - Rename Tair -> descriptive surface-temperature naming in identifiers and docstrings; keep the literal "/Tair" store path (the array's real name). Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/nldas-icechunk/README.md | 2 +- examples/nldas-icechunk/src/App.tsx | 18 ++++++------- .../nldas-icechunk/src/nldas/get-tile-data.ts | 16 ++++-------- examples/nldas-icechunk/src/nldas/metadata.ts | 26 ++++++++++++------- examples/nldas-icechunk/src/nldas/store.ts | 17 +++++++----- 5 files changed, 42 insertions(+), 37 deletions(-) diff --git a/examples/nldas-icechunk/README.md b/examples/nldas-icechunk/README.md index 3bac3b86..1acbbc04 100644 --- a/examples/nldas-icechunk/README.md +++ b/examples/nldas-icechunk/README.md @@ -1,6 +1,6 @@ # NLDAS-3 icechunk Example -Renders a single timestep of NLDAS-3 air temperature (`Tair`) from a public +Renders a single timestep of NLDAS-3 near-surface air temperature from a public [icechunk](https://icechunk.io) repository, read in the browser via [`icechunk-js`](https://github.com/EarthyScience/icechunk-js) + zarrita and displayed with `@developmentseed/deck.gl-zarr`'s `ZarrLayer`. diff --git a/examples/nldas-icechunk/src/App.tsx b/examples/nldas-icechunk/src/App.tsx index 093935a1..17e28af3 100644 --- a/examples/nldas-icechunk/src/App.tsx +++ b/examples/nldas-icechunk/src/App.tsx @@ -19,23 +19,23 @@ import type * as zarr from "zarrita"; import type { NldasTileData } from "./nldas/get-tile-data.js"; import { getTileData } from "./nldas/get-tile-data.js"; import { - COLORMAP_INDEX_TAIR, COLORMAP_REVERSED, NLDAS_GEOZARR_ATTRS, NODATA_VALUE, RESCALE_MAX, RESCALE_MIN, + SURFACE_TEMP_COLORMAP_INDEX, TIME_DIM, TIME_INDEX, } from "./nldas/metadata.js"; import { makeRenderTile } from "./nldas/render-tile.js"; -import { openNldasTair } from "./nldas/store.js"; +import { openSurfaceTemp } from "./nldas/store.js"; // Keyless CARTO basemap; light background reads well under a data overlay. const BASEMAP_STYLE = "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"; -// Min longitude, min latitude, max longitude, max latitude +// [[Min longitude, min latitude], [max longitude, max latitude]] const DATA_BOUNDS: [[number, number], [number, number]] = [ [-180, -20], [-20, 80], @@ -50,11 +50,11 @@ export default function App() { const [colormapImage, setColormapImage] = useState(null); const [colormapTexture, setColormapTexture] = useState(null); - // Open the icechunk store + Tair array once. + // Open the icechunk store + surface temperature array once. useEffect(() => { let cancelled = false; (async () => { - const opened = await openNldasTair(); + const opened = await openSurfaceTemp(); if (!cancelled) { setArr(opened); } @@ -92,14 +92,14 @@ export default function App() { arr && colormapTexture ? [ new ZarrLayer({ - id: "nldas-tair", + id: "nldas-surface-temp", node: arr, metadata: NLDAS_GEOZARR_ATTRS, selection: { [TIME_DIM]: TIME_INDEX }, getTileData, renderTile: makeRenderTile({ colormapTexture, - colormapIndex: COLORMAP_INDEX_TAIR, + colormapIndex: SURFACE_TEMP_COLORMAP_INDEX, colormapReversed: COLORMAP_REVERSED, noDataValue: NODATA_VALUE, rescaleMin: RESCALE_MIN, @@ -131,8 +131,8 @@ export default function App() { sourcePath="examples/nldas-icechunk" > - Reads NASA's NLDAS-3 daily 2-meter air temperature directly from a - public{" "} + Reads NASA's NLDAS-3 daily near-surface air temperature directly from + a public{" "} icechunk{" "} repository in the browser — no server in between. The store is a{" "} virtual Zarr: its chunks reference NLDAS-3 source files in diff --git a/examples/nldas-icechunk/src/nldas/get-tile-data.ts b/examples/nldas-icechunk/src/nldas/get-tile-data.ts index 7e1a5b75..bef9367c 100644 --- a/examples/nldas-icechunk/src/nldas/get-tile-data.ts +++ b/examples/nldas-icechunk/src/nldas/get-tile-data.ts @@ -2,18 +2,18 @@ import type { MinimalTileData } from "@developmentseed/deck.gl-raster"; import type { GetTileDataOptions } from "@developmentseed/deck.gl-zarr"; import type { Texture } from "@luma.gl/core"; import * as zarr from "zarrita"; -import { NODATA_VALUE } from "./metadata.js"; /** Per-tile data: one spatial chunk uploaded as an r32float texture. */ export type NldasTileData = MinimalTileData & { - /** r32float 2D texture holding the tile's Tair values. */ + /** r32float 2D texture holding the tile's temperature values. */ texture: Texture; }; /** - * Slice one spatial chunk of Tair (time pinned by the layer's selection) and - * upload it as a single-channel float texture. Non-finite values (NaN/Inf - * fills) are mapped to NODATA_VALUE so the render pipeline can discard them. + * Slice one spatial chunk of the near-surface air temperature array (time + * pinned by the layer's selection) and upload it as a single-channel float + * texture. Fill pixels keep their sentinel value (the store's `-9999`) and are + * discarded on the GPU by `FilterNoDataVal`. */ export async function getTileData( arr: zarr.Array<"float32", zarr.Readable>, @@ -35,17 +35,11 @@ export async function getTileData( } const data = chunk.data as Float32Array; - for (let i = 0; i < data.length; i++) { - if (!Number.isFinite(data[i]!)) { - data[i] = NODATA_VALUE; - } - } const texture = device.createTexture({ format: "r32float", width, height, - mipLevels: 1, data, sampler: { minFilter: "nearest", diff --git a/examples/nldas-icechunk/src/nldas/metadata.ts b/examples/nldas-icechunk/src/nldas/metadata.ts index 068f3352..fe66056c 100644 --- a/examples/nldas-icechunk/src/nldas/metadata.ts +++ b/examples/nldas-icechunk/src/nldas/metadata.ts @@ -7,14 +7,17 @@ export const REPO_URL = /** Branch to read. */ export const BRANCH = "main"; -/** Path to the Tair array within the store. */ -export const VARIABLE = "/Tair"; +/** + * Path to the near-surface air temperature array within the store. `Tair` is + * the array's name in the NLDAS-3 store, so the literal path can't change. + */ +export const SURFACE_TEMP_PATH = "/Tair"; /** * Virtual chunk container map: the container name declared in the repo's - * `config.yaml` → the public HTTPS prefix the browser can fetch. Tair's chunks - * are virtual references into the original NLDAS-3 source objects in the same - * `nasa-waterinsight` bucket. + * `config.yaml` → the public HTTPS prefix the browser can fetch. The + * temperature chunks are virtual references into the original NLDAS-3 source + * objects in the same `nasa-waterinsight` bucket. */ export const VIRTUAL_CHUNK_CONTAINERS = new Map([ [ @@ -34,18 +37,21 @@ export const TIME_DIM = "time"; export const TIME_INDEX = 3482; /** - * Sentinel for fill pixels. The store's `missing_value` is -9999; getTileData - * also maps any non-finite (NaN/Inf) value to this so the render pipeline can - * discard them with a single comparison. + * Sentinel for fill pixels. The store's `missing_value` is -9999, which + * `FilterNoDataVal` discards on the GPU. The source data uses this finite + * sentinel (not NaN), so no per-pixel CPU pass is needed. */ export const NODATA_VALUE = -9999; -/** Fixed rescale range in Kelvin (Tair `vmin`/`vmax` ≈ 228–304 K). */ +/** + * Fixed rescale range in Kelvin (near-surface air temperature `vmin`/`vmax` + * ≈ 228–304 K). + */ export const RESCALE_MIN = 228; export const RESCALE_MAX = 305; /** Colormap sprite layer + orientation (thermal sequential, like ECMWF). */ -export const COLORMAP_INDEX_TAIR = COLORMAP_INDEX.thermal; +export const SURFACE_TEMP_COLORMAP_INDEX = COLORMAP_INDEX.thermal; export const COLORMAP_REVERSED = false; /** diff --git a/examples/nldas-icechunk/src/nldas/store.ts b/examples/nldas-icechunk/src/nldas/store.ts index 58c9f763..8084d560 100644 --- a/examples/nldas-icechunk/src/nldas/store.ts +++ b/examples/nldas-icechunk/src/nldas/store.ts @@ -8,18 +8,19 @@ import * as zarr from "zarrita"; import { BRANCH, REPO_URL, - VARIABLE, + SURFACE_TEMP_PATH, VIRTUAL_CHUNK_CONTAINERS, } from "./metadata.js"; /** - * Open the NLDAS-3 Tair array from the public icechunk repo, with the virtual - * chunk container authorized so chunk reads resolve to public HTTPS objects. + * Open the NLDAS-3 near-surface air temperature array from the public icechunk + * repo, with the virtual chunk container authorized so chunk reads resolve to + * public HTTPS objects. * * The container map is only accepted by `ReadSession.open`, so we resolve the * branch snapshot id first, then open a session that carries it. */ -export async function openNldasTair(): Promise< +export async function openSurfaceTemp(): Promise< zarr.Array<"float32", zarr.Readable> > { const storage = new HttpStorage(REPO_URL); @@ -37,9 +38,13 @@ export async function openNldasTair(): Promise< }); const store = await IcechunkStore.open(session); - const node = await zarr.open(store.resolve(VARIABLE), { kind: "array" }); + const node = await zarr.open(store.resolve(SURFACE_TEMP_PATH), { + kind: "array", + }); if (!node.is("float32")) { - throw new Error(`Expected ${VARIABLE} to be float32, got ${node.dtype}`); + throw new Error( + `Expected ${SURFACE_TEMP_PATH} to be float32, got ${node.dtype}`, + ); } return node; } From abf8cc208bafbaec53eb985ccfd1c76536c212d0 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 27 May 2026 16:18:26 -0400 Subject: [PATCH 16/24] feat(nldas-icechunk): add colormap picker and rescale slider Adds runtime controls to the intro panel (reusing the shared Field, NativeSelect, ColormapPreview, and RangeSlider), matching the other examples. A new colormap-choices list drives the picker; rescale min/max become state with updateTriggers so cached tiles re-render on change. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/nldas-icechunk/src/App.tsx | 97 ++++++++++++++++--- .../src/nldas/colormap-choices.ts | 59 +++++++++++ examples/nldas-icechunk/src/nldas/metadata.ts | 13 ++- 3 files changed, 149 insertions(+), 20 deletions(-) create mode 100644 examples/nldas-icechunk/src/nldas/colormap-choices.ts diff --git a/examples/nldas-icechunk/src/App.tsx b/examples/nldas-icechunk/src/App.tsx index 17e28af3..3f325bed 100644 --- a/examples/nldas-icechunk/src/App.tsx +++ b/examples/nldas-icechunk/src/App.tsx @@ -1,5 +1,6 @@ -import { Text } from "@chakra-ui/react"; +import { NativeSelect, Text } from "@chakra-ui/react"; import { + COLORMAP_INDEX, createColormapTexture, decodeColormapSprite, } from "@developmentseed/deck.gl-raster/gpu-modules"; @@ -7,24 +8,33 @@ import colormapsPngUrl from "@developmentseed/deck.gl-raster/gpu-modules/colorma import { ZarrLayer } from "@developmentseed/deck.gl-zarr"; import type { Device, Texture } from "@luma.gl/core"; import { + ColormapPreview, ControlPanel, DeckGlOverlay, ExternalLink, + Field, + RangeSlider, } from "deck.gl-raster-examples-shared"; import "maplibre-gl/dist/maplibre-gl.css"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { MapRef } from "react-map-gl/maplibre"; import { Map as MaplibreMap } from "react-map-gl/maplibre"; import type * as zarr from "zarrita"; +import type { ColormapId } from "./nldas/colormap-choices.js"; +import { + COLORMAP_CHOICES, + DEFAULT_COLORMAP_ID, +} from "./nldas/colormap-choices.js"; import type { NldasTileData } from "./nldas/get-tile-data.js"; import { getTileData } from "./nldas/get-tile-data.js"; import { - COLORMAP_REVERSED, NLDAS_GEOZARR_ATTRS, NODATA_VALUE, RESCALE_MAX, RESCALE_MIN, - SURFACE_TEMP_COLORMAP_INDEX, + RESCALE_SLIDER_MAX, + RESCALE_SLIDER_MIN, + RESCALE_SLIDER_STEP, TIME_DIM, TIME_INDEX, } from "./nldas/metadata.js"; @@ -41,6 +51,9 @@ const DATA_BOUNDS: [[number, number], [number, number]] = [ [-20, 80], ]; +/** Total number of rows in the shipped colormap sprite. */ +const COLORMAP_ROW_COUNT = Object.keys(COLORMAP_INDEX).length; + export default function App() { const mapRef = useRef(null); const [arr, setArr] = useState | null>( @@ -49,6 +62,15 @@ export default function App() { const [device, setDevice] = useState(null); const [colormapImage, setColormapImage] = useState(null); const [colormapTexture, setColormapTexture] = useState(null); + const [colormapId, setColormapId] = useState(DEFAULT_COLORMAP_ID); + const [rescaleMin, setRescaleMin] = useState(RESCALE_MIN); + const [rescaleMax, setRescaleMax] = useState(RESCALE_MAX); + + const colormapChoice = useMemo( + () => + COLORMAP_CHOICES.find((c) => c.id === colormapId) ?? COLORMAP_CHOICES[0], + [colormapId], + ); // Open the icechunk store + surface temperature array once. useEffect(() => { @@ -99,12 +121,17 @@ export default function App() { getTileData, renderTile: makeRenderTile({ colormapTexture, - colormapIndex: SURFACE_TEMP_COLORMAP_INDEX, - colormapReversed: COLORMAP_REVERSED, + colormapIndex: colormapChoice.colormapIndex, + colormapReversed: colormapChoice.reversed, noDataValue: NODATA_VALUE, - rescaleMin: RESCALE_MIN, - rescaleMax: RESCALE_MAX, + rescaleMin, + rescaleMax, }), + // Re-run renderTile on cached tiles when the colormap or rescale + // range changes. + updateTriggers: { + renderTile: [colormapId, rescaleMin, rescaleMax], + }, // source bucket supports HTTP/2 multiplexing maxRequests: 20, maxCacheSize: 20, @@ -140,12 +167,56 @@ export default function App() { icechunk-js {" "} - + zarrita and rendered by a ZarrLayer. - - - Showing a single day (2010-07-16), colorized on the GPU with the - thermal colormap. Ocean / no-data is left transparent. + + zarrita and rendered by a ZarrLayer. Showing a single + day (2010-07-16); ocean / no-data is left transparent. + + + + setColormapId(e.target.value as ColormapId)} + > + {COLORMAP_CHOICES.map((c) => ( + + ))} + + + + + + + + Rescale range: {rescaleMin} – {rescaleMax} K + + } + > + { + if (nextMin !== rescaleMin) { + setRescaleMin(nextMin); + } + if (nextMax !== rescaleMax) { + setRescaleMax(nextMax); + } + }} + thumbLabels={["Rescale min (K)", "Rescale max (K)"]} + /> + ); diff --git a/examples/nldas-icechunk/src/nldas/colormap-choices.ts b/examples/nldas-icechunk/src/nldas/colormap-choices.ts new file mode 100644 index 00000000..d72b42ae --- /dev/null +++ b/examples/nldas-icechunk/src/nldas/colormap-choices.ts @@ -0,0 +1,59 @@ +import { COLORMAP_INDEX } from "@developmentseed/deck.gl-raster/gpu-modules"; + +/** Shape constraint for a single colormap choice. */ +type ColormapChoiceShape = { + /** Stable identifier used as the select-option value. */ + id: string; + /** Human-readable label shown in the dropdown. */ + label: string; + /** Layer index into the colormap sprite. */ + colormapIndex: number; + /** Whether to sample the colormap in reverse. */ + reversed: boolean; +}; + +/** + * Shortlist of colormap options appropriate for near-surface air temperature. + * Order drives the dropdown order in the control panel. + */ +export const COLORMAP_CHOICES = [ + { + id: "thermal", + label: "thermal (cmocean sequential)", + colormapIndex: COLORMAP_INDEX.thermal, + reversed: false, + }, + { + id: "coolwarm", + label: "coolwarm (diverging)", + colormapIndex: COLORMAP_INDEX.coolwarm, + reversed: false, + }, + { + id: "rdbu_r", + label: "RdBu reversed (blue→red)", + colormapIndex: COLORMAP_INDEX.rdbu, + reversed: true, + }, + { + id: "balance", + label: "balance (cmocean diverging)", + colormapIndex: COLORMAP_INDEX.balance, + reversed: false, + }, + { + id: "turbo", + label: "turbo", + colormapIndex: COLORMAP_INDEX.turbo, + reversed: false, + }, +] as const satisfies readonly ColormapChoiceShape[]; + +/** Union of valid colormap choice ids (e.g. `"coolwarm" | "rdbu_r" | ...`). */ +export type ColormapId = (typeof COLORMAP_CHOICES)[number]["id"]; + +/** An entry from {@link COLORMAP_CHOICES}. */ +export type ColormapChoice = (typeof COLORMAP_CHOICES)[number]; + +/** Default colormap on first load. */ +export const DEFAULT_COLORMAP_ID: ColormapId = COLORMAP_CHOICES[0].id; diff --git a/examples/nldas-icechunk/src/nldas/metadata.ts b/examples/nldas-icechunk/src/nldas/metadata.ts index fe66056c..aa916185 100644 --- a/examples/nldas-icechunk/src/nldas/metadata.ts +++ b/examples/nldas-icechunk/src/nldas/metadata.ts @@ -1,5 +1,3 @@ -import { COLORMAP_INDEX } from "@developmentseed/deck.gl-raster/gpu-modules"; - /** Public NLDAS-3 icechunk repo (anonymous, CORS-enabled). */ export const REPO_URL = "https://nasa-waterinsight.s3.us-west-2.amazonaws.com/virtual-zarr-store/NLDAS-3-icechunk"; @@ -44,15 +42,16 @@ export const TIME_INDEX = 3482; export const NODATA_VALUE = -9999; /** - * Fixed rescale range in Kelvin (near-surface air temperature `vmin`/`vmax` - * ≈ 228–304 K). + * Initial rescale range in Kelvin (near-surface air temperature `vmin`/`vmax` + * ≈ 228–304 K). Adjustable at runtime via the rescale slider. */ export const RESCALE_MIN = 228; export const RESCALE_MAX = 305; -/** Colormap sprite layer + orientation (thermal sequential, like ECMWF). */ -export const SURFACE_TEMP_COLORMAP_INDEX = COLORMAP_INDEX.thermal; -export const COLORMAP_REVERSED = false; +/** Bounds + step for the rescale slider (Kelvin). */ +export const RESCALE_SLIDER_MIN = 220; +export const RESCALE_SLIDER_MAX = 320; +export const RESCALE_SLIDER_STEP = 1; /** * Synthetic GeoZarr-compliant attrs (the virtual store is not GeoZarr). From 5c2047c7894c94bf9321e9aebfb96c32677f2372 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 27 May 2026 16:20:29 -0400 Subject: [PATCH 17/24] =?UTF-8?q?feat(nldas-icechunk):=20label=20rescale?= =?UTF-8?q?=20range=20in=20=C2=B0C=20(with=20K=20in=20parens)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slider still operates in Kelvin (the shader's unit); only the label converts, e.g. "10°C (283 K) – 40°C (313 K)". Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/nldas-icechunk/src/App.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/examples/nldas-icechunk/src/App.tsx b/examples/nldas-icechunk/src/App.tsx index 3f325bed..7e941cdb 100644 --- a/examples/nldas-icechunk/src/App.tsx +++ b/examples/nldas-icechunk/src/App.tsx @@ -54,6 +54,13 @@ const DATA_BOUNDS: [[number, number], [number, number]] = [ /** Total number of rows in the shipped colormap sprite. */ const COLORMAP_ROW_COUNT = Object.keys(COLORMAP_INDEX).length; +/** + * Convert a Kelvin value to an integer °C for display. The slider operates in + * Kelvin (the data's native unit, which the rescale shader expects); only the + * label is shown in the friendlier °C, with Kelvin in parentheses. + */ +const kelvinToCelsius = (k: number) => Math.round(k - 273.15); + export default function App() { const mapRef = useRef(null); const [arr, setArr] = useState | null>( @@ -197,7 +204,8 @@ export default function App() { - Rescale range: {rescaleMin} – {rescaleMax} K + Rescale range: {kelvinToCelsius(rescaleMin)}°C ({rescaleMin} K) –{" "} + {kelvinToCelsius(rescaleMax)}°C ({rescaleMax} K) } > From 43f247e44cd7c294d2fdc78c5374ca83de3e3a5d Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 27 May 2026 16:25:25 -0400 Subject: [PATCH 18/24] =?UTF-8?q?fix(nldas-icechunk):=20use=20exact=200.01?= =?UTF-8?q?=C2=B0=20grid=20transform?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The store's lat/lon coords are float32; deriving the affine by subtracting them injected precision noise (dLon ~0.00999451), drifting the east edge to -52.064 (~7 km). Use the exact intended grid: origin (-169, 7), step 0.01°. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/nldas-icechunk/src/nldas/metadata.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/examples/nldas-icechunk/src/nldas/metadata.ts b/examples/nldas-icechunk/src/nldas/metadata.ts index aa916185..76806182 100644 --- a/examples/nldas-icechunk/src/nldas/metadata.ts +++ b/examples/nldas-icechunk/src/nldas/metadata.ts @@ -55,21 +55,23 @@ export const RESCALE_SLIDER_STEP = 1; /** * Synthetic GeoZarr-compliant attrs (the virtual store is not GeoZarr). - * Mirrors the ECMWF example's approach. Values read once from the store's - * shape + lat/lon coordinate arrays and frozen here. + * Mirrors the ECMWF example's approach. * * Affine [a,b,c,d,e,f] (see `@developmentseed/affine`): * x = a*col + b*row + c ; y = d*col + e*row + f * - * NLDAS-3 latitude is ASCENDING (row 0 = south, lat 7.005°), so the row step - * `e` is positive and the origin is the bottom-left cell corner = first cell - * center − half a pixel. Grid: 0.01° over lon [-169, -52], lat [7, 72]. + * NLDAS-3 latitude is ASCENDING (row 0 = south, first cell center lat 7.005°), + * so the row step `e` is positive and the origin is the bottom-left cell + * corner = first cell center − half a pixel. + * + * The grid is an exact 0.01° lon [-169, -52] × lat [7, 72]. Use these exact + * values rather than ones derived from the store's coordinate arrays: those are + * float32, so subtracting them yields precision-noised spacing (dLon ≈ + * 0.00999451 → east edge ≈ -52.064, a ~7 km eastward drift by the far edge). */ export const NLDAS_GEOZARR_ATTRS = { "spatial:dimensions": ["lat", "lon"], - "spatial:transform": [ - 0.0099945068359375, 0, -168.99999237060547, 0, 0.010000228881835938, 7, - ], + "spatial:transform": [0.01, 0, -169, 0, 0.01, 7], "spatial:shape": [6500, 11700], "proj:code": "EPSG:4326", } as const; From 747ea54f70d1c39511405f135292d96c7fe7860a Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 27 May 2026 16:28:11 -0400 Subject: [PATCH 19/24] source is http/1.1, not http/2 --- examples/nldas-icechunk/src/App.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/examples/nldas-icechunk/src/App.tsx b/examples/nldas-icechunk/src/App.tsx index 7e941cdb..125950e3 100644 --- a/examples/nldas-icechunk/src/App.tsx +++ b/examples/nldas-icechunk/src/App.tsx @@ -139,9 +139,6 @@ export default function App() { updateTriggers: { renderTile: [colormapId, rescaleMin, rescaleMax], }, - // source bucket supports HTTP/2 multiplexing - maxRequests: 20, - maxCacheSize: 20, }), ] : []; From 34b80892c4860cc20b6ed349cfd4e67d843613a3 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 27 May 2026 16:31:12 -0400 Subject: [PATCH 20/24] fix(nldas-icechunk): skip v2 /repo probe (declare v1 format) NLDAS-3 is a v1 icechunk repo. Without an explicit formatVersion, Repository.open auto-detects by fetching the v2 "/repo" marker, which 404s (then falls back to v1). Passing formatVersion: "v1" skips that probe. Verified: /repo requests drop 1 -> 0 per open (2 -> 0 with StrictMode's dev double-mount) while the array still opens. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/nldas-icechunk/src/nldas/store.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/nldas-icechunk/src/nldas/store.ts b/examples/nldas-icechunk/src/nldas/store.ts index 8084d560..f80f28e3 100644 --- a/examples/nldas-icechunk/src/nldas/store.ts +++ b/examples/nldas-icechunk/src/nldas/store.ts @@ -24,7 +24,9 @@ export async function openSurfaceTemp(): Promise< zarr.Array<"float32", zarr.Readable> > { const storage = new HttpStorage(REPO_URL); - const repo = await Repository.open({ storage }); + // NLDAS-3 is a v1 icechunk repo; declaring the format skips icechunk-js's + // auto-detection probe for the v2 `/repo` file (an avoidable 404). + const repo = await Repository.open({ storage, formatVersion: "v1" }); const branchSession = await repo.checkoutBranch(BRANCH); const snapshotId = branchSession.getSnapshotId(); From aa922373b4a07ff8152e90316f3aeceb7e6d0f49 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 27 May 2026 16:38:39 -0400 Subject: [PATCH 21/24] concise --- examples/nldas-icechunk/src/nldas/metadata.ts | 13 ------------- examples/nldas-icechunk/src/nldas/store.ts | 9 ++------- 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/examples/nldas-icechunk/src/nldas/metadata.ts b/examples/nldas-icechunk/src/nldas/metadata.ts index 76806182..60a258e2 100644 --- a/examples/nldas-icechunk/src/nldas/metadata.ts +++ b/examples/nldas-icechunk/src/nldas/metadata.ts @@ -55,19 +55,6 @@ export const RESCALE_SLIDER_STEP = 1; /** * Synthetic GeoZarr-compliant attrs (the virtual store is not GeoZarr). - * Mirrors the ECMWF example's approach. - * - * Affine [a,b,c,d,e,f] (see `@developmentseed/affine`): - * x = a*col + b*row + c ; y = d*col + e*row + f - * - * NLDAS-3 latitude is ASCENDING (row 0 = south, first cell center lat 7.005°), - * so the row step `e` is positive and the origin is the bottom-left cell - * corner = first cell center − half a pixel. - * - * The grid is an exact 0.01° lon [-169, -52] × lat [7, 72]. Use these exact - * values rather than ones derived from the store's coordinate arrays: those are - * float32, so subtracting them yields precision-noised spacing (dLon ≈ - * 0.00999451 → east edge ≈ -52.064, a ~7 km eastward drift by the far edge). */ export const NLDAS_GEOZARR_ATTRS = { "spatial:dimensions": ["lat", "lon"], diff --git a/examples/nldas-icechunk/src/nldas/store.ts b/examples/nldas-icechunk/src/nldas/store.ts index f80f28e3..47d30f81 100644 --- a/examples/nldas-icechunk/src/nldas/store.ts +++ b/examples/nldas-icechunk/src/nldas/store.ts @@ -24,18 +24,13 @@ export async function openSurfaceTemp(): Promise< zarr.Array<"float32", zarr.Readable> > { const storage = new HttpStorage(REPO_URL); - // NLDAS-3 is a v1 icechunk repo; declaring the format skips icechunk-js's - // auto-detection probe for the v2 `/repo` file (an avoidable 404). + // NLDAS-3 is a v1 icechunk repo const repo = await Repository.open({ storage, formatVersion: "v1" }); const branchSession = await repo.checkoutBranch(BRANCH); const snapshotId = branchSession.getSnapshotId(); - const snapshotBytes = - snapshotId instanceof Uint8Array - ? snapshotId - : new Uint8Array(snapshotId as ArrayLike); - const session = await ReadSession.open(storage, snapshotBytes, { + const session = await ReadSession.open(storage, snapshotId, { virtualChunkContainers: VIRTUAL_CHUNK_CONTAINERS, }); const store = await IcechunkStore.open(session); From e5cf3fe8f845d4d54f8f5fca8f05d9f97b80844d Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 27 May 2026 16:43:37 -0400 Subject: [PATCH 22/24] refactor(nldas-icechunk): read fill value from the array's missing_value attr Instead of hard-coding NODATA_VALUE = -9999, openSurfaceTemp now reads (and validates) the array's `missing_value` attribute and returns it alongside the array. The render pipeline gets the fill sentinel from the store rather than a constant. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/nldas-icechunk/src/App.tsx | 14 +++++------ .../nldas-icechunk/src/nldas/get-tile-data.ts | 4 ++-- examples/nldas-icechunk/src/nldas/metadata.ts | 7 ------ examples/nldas-icechunk/src/nldas/store.ts | 24 +++++++++++++++---- 4 files changed, 28 insertions(+), 21 deletions(-) diff --git a/examples/nldas-icechunk/src/App.tsx b/examples/nldas-icechunk/src/App.tsx index 125950e3..2b57080f 100644 --- a/examples/nldas-icechunk/src/App.tsx +++ b/examples/nldas-icechunk/src/App.tsx @@ -29,7 +29,6 @@ import type { NldasTileData } from "./nldas/get-tile-data.js"; import { getTileData } from "./nldas/get-tile-data.js"; import { NLDAS_GEOZARR_ATTRS, - NODATA_VALUE, RESCALE_MAX, RESCALE_MIN, RESCALE_SLIDER_MAX, @@ -39,6 +38,7 @@ import { TIME_INDEX, } from "./nldas/metadata.js"; import { makeRenderTile } from "./nldas/render-tile.js"; +import type { SurfaceTempSource } from "./nldas/store.js"; import { openSurfaceTemp } from "./nldas/store.js"; // Keyless CARTO basemap; light background reads well under a data overlay. @@ -63,9 +63,7 @@ const kelvinToCelsius = (k: number) => Math.round(k - 273.15); export default function App() { const mapRef = useRef(null); - const [arr, setArr] = useState | null>( - null, - ); + const [source, setSource] = useState(null); const [device, setDevice] = useState(null); const [colormapImage, setColormapImage] = useState(null); const [colormapTexture, setColormapTexture] = useState(null); @@ -85,7 +83,7 @@ export default function App() { (async () => { const opened = await openSurfaceTemp(); if (!cancelled) { - setArr(opened); + setSource(opened); } })(); return () => { @@ -118,11 +116,11 @@ export default function App() { }, [device, colormapImage]); const layers = - arr && colormapTexture + source && colormapTexture ? [ new ZarrLayer({ id: "nldas-surface-temp", - node: arr, + node: source.array, metadata: NLDAS_GEOZARR_ATTRS, selection: { [TIME_DIM]: TIME_INDEX }, getTileData, @@ -130,7 +128,7 @@ export default function App() { colormapTexture, colormapIndex: colormapChoice.colormapIndex, colormapReversed: colormapChoice.reversed, - noDataValue: NODATA_VALUE, + noDataValue: source.noDataValue, rescaleMin, rescaleMax, }), diff --git a/examples/nldas-icechunk/src/nldas/get-tile-data.ts b/examples/nldas-icechunk/src/nldas/get-tile-data.ts index bef9367c..1313c4a3 100644 --- a/examples/nldas-icechunk/src/nldas/get-tile-data.ts +++ b/examples/nldas-icechunk/src/nldas/get-tile-data.ts @@ -12,8 +12,8 @@ export type NldasTileData = MinimalTileData & { /** * Slice one spatial chunk of the near-surface air temperature array (time * pinned by the layer's selection) and upload it as a single-channel float - * texture. Fill pixels keep their sentinel value (the store's `-9999`) and are - * discarded on the GPU by `FilterNoDataVal`. + * texture. Fill pixels keep their sentinel value (the store's `missing_value`) + * and are discarded on the GPU by `FilterNoDataVal`. */ export async function getTileData( arr: zarr.Array<"float32", zarr.Readable>, diff --git a/examples/nldas-icechunk/src/nldas/metadata.ts b/examples/nldas-icechunk/src/nldas/metadata.ts index 60a258e2..1ce51786 100644 --- a/examples/nldas-icechunk/src/nldas/metadata.ts +++ b/examples/nldas-icechunk/src/nldas/metadata.ts @@ -34,13 +34,6 @@ export const TIME_DIM = "time"; */ export const TIME_INDEX = 3482; -/** - * Sentinel for fill pixels. The store's `missing_value` is -9999, which - * `FilterNoDataVal` discards on the GPU. The source data uses this finite - * sentinel (not NaN), so no per-pixel CPU pass is needed. - */ -export const NODATA_VALUE = -9999; - /** * Initial rescale range in Kelvin (near-surface air temperature `vmin`/`vmax` * ≈ 228–304 K). Adjustable at runtime via the rescale slider. diff --git a/examples/nldas-icechunk/src/nldas/store.ts b/examples/nldas-icechunk/src/nldas/store.ts index 47d30f81..351440e0 100644 --- a/examples/nldas-icechunk/src/nldas/store.ts +++ b/examples/nldas-icechunk/src/nldas/store.ts @@ -12,6 +12,14 @@ import { VIRTUAL_CHUNK_CONTAINERS, } from "./metadata.js"; +/** The opened temperature array plus the fill sentinel read from its attrs. */ +export interface SurfaceTempSource { + /** The near-surface air temperature array. */ + array: zarr.Array<"float32", zarr.Readable>; + /** Fill value, read from the array's `missing_value` attribute. */ + noDataValue: number; +} + /** * Open the NLDAS-3 near-surface air temperature array from the public icechunk * repo, with the virtual chunk container authorized so chunk reads resolve to @@ -20,9 +28,7 @@ import { * The container map is only accepted by `ReadSession.open`, so we resolve the * branch snapshot id first, then open a session that carries it. */ -export async function openSurfaceTemp(): Promise< - zarr.Array<"float32", zarr.Readable> -> { +export async function openSurfaceTemp(): Promise { const storage = new HttpStorage(REPO_URL); // NLDAS-3 is a v1 icechunk repo const repo = await Repository.open({ storage, formatVersion: "v1" }); @@ -43,5 +49,15 @@ export async function openSurfaceTemp(): Promise< `Expected ${SURFACE_TEMP_PATH} to be float32, got ${node.dtype}`, ); } - return node; + + // Read the fill sentinel from the array's attrs rather than hard-coding it. + const missingValue = node.attrs.missing_value; + if (typeof missingValue !== "number") { + throw new Error( + `Expected ${SURFACE_TEMP_PATH} to have a numeric "missing_value" attr, ` + + `got ${typeof missingValue}`, + ); + } + + return { array: node, noDataValue: missingValue }; } From 6df92bf7991b2b8255c6f18915ebf3d67709c150 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 27 May 2026 16:56:01 -0400 Subject: [PATCH 23/24] Update info box --- examples/nldas-icechunk/src/App.tsx | 36 +++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/examples/nldas-icechunk/src/App.tsx b/examples/nldas-icechunk/src/App.tsx index 2b57080f..b6994f0f 100644 --- a/examples/nldas-icechunk/src/App.tsx +++ b/examples/nldas-icechunk/src/App.tsx @@ -156,21 +156,37 @@ export default function App() { /> - Reads NASA's NLDAS-3 daily near-surface air temperature directly from - a public{" "} - icechunk{" "} - repository in the browser — no server in between. The store is a{" "} - virtual Zarr: its chunks reference NLDAS-3 source files in - the same S3 bucket, read with{" "} + Reads{" "} + + NLDAS-3 + {" "} + temperature data directly from a public{" "} + Icechunk{" "} + repository. + + + Zarr data is rendered by a{" "} + + ZarrLayer + + , using - icechunk-js + icechunk-js + {" "} + and{" "} + + zarrita {" "} - + zarrita and rendered by a ZarrLayer. Showing a single - day (2010-07-16); ocean / no-data is left transparent. + to fetch data directly in the browser, without a server in between. + The store is{" "} + + virtualized + + : the underlying raw data is technically read from NetCDF files. From 829016513e27344f0a283ab47bee77bf56d528fd Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 27 May 2026 16:57:24 -0400 Subject: [PATCH 24/24] narrower rescale range --- examples/nldas-icechunk/src/nldas/metadata.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/nldas-icechunk/src/nldas/metadata.ts b/examples/nldas-icechunk/src/nldas/metadata.ts index 1ce51786..46f61bbe 100644 --- a/examples/nldas-icechunk/src/nldas/metadata.ts +++ b/examples/nldas-icechunk/src/nldas/metadata.ts @@ -38,11 +38,11 @@ export const TIME_INDEX = 3482; * Initial rescale range in Kelvin (near-surface air temperature `vmin`/`vmax` * ≈ 228–304 K). Adjustable at runtime via the rescale slider. */ -export const RESCALE_MIN = 228; +export const RESCALE_MIN = 260; export const RESCALE_MAX = 305; /** Bounds + step for the rescale slider (Kelvin). */ -export const RESCALE_SLIDER_MIN = 220; +export const RESCALE_SLIDER_MIN = 240; export const RESCALE_SLIDER_MAX = 320; export const RESCALE_SLIDER_STEP = 1;