diff --git a/examples/zarr-sentinel2-tci/README.md b/examples/zarr-sentinel2-tci/README.md
new file mode 100644
index 00000000..43134b13
--- /dev/null
+++ b/examples/zarr-sentinel2-tci/README.md
@@ -0,0 +1,21 @@
+# GeoZarr Sentinel-2 True Color Image
+
+## 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/cog-basic
+ pnpm dev
+ ```
+
+4. Open your browser to http://localhost:3000
diff --git a/examples/zarr-sentinel2-tci/index.html b/examples/zarr-sentinel2-tci/index.html
new file mode 100644
index 00000000..d9119b05
--- /dev/null
+++ b/examples/zarr-sentinel2-tci/index.html
@@ -0,0 +1,22 @@
+
+
+
+
+
+ COGLayer Example
+
+
+
+
+
+
+
diff --git a/examples/zarr-sentinel2-tci/package.json b/examples/zarr-sentinel2-tci/package.json
new file mode 100644
index 00000000..e2d80906
--- /dev/null
+++ b/examples/zarr-sentinel2-tci/package.json
@@ -0,0 +1,32 @@
+{
+ "name": "deck.gl-zarr-sentinel2-tci",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview",
+ "publish": "pnpm build && gh-pages -d dist -b gh-pages -e examples/zarr-sentinel2-tci"
+ },
+ "dependencies": {
+ "@deck.gl/core": "^9.2.10",
+ "@deck.gl/geo-layers": "^9.2.10",
+ "@deck.gl/layers": "^9.2.10",
+ "@deck.gl/mapbox": "^9.2.10",
+ "@deck.gl/mesh-layers": "^9.2.10",
+ "@developmentseed/deck.gl-zarr": "workspace:^",
+ "@luma.gl/core": "9.2.6",
+ "maplibre-gl": "^5.19.0",
+ "react": "^19.2.4",
+ "react-dom": "^19.2.4",
+ "react-map-gl": "^8.1.0",
+ "zarrita": "^0.6.1"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^5.1.4",
+ "gh-pages": "^6.3.0",
+ "vite": "^7.3.1"
+ }
+}
diff --git a/examples/zarr-sentinel2-tci/src/App.tsx b/examples/zarr-sentinel2-tci/src/App.tsx
new file mode 100644
index 00000000..b03ac598
--- /dev/null
+++ b/examples/zarr-sentinel2-tci/src/App.tsx
@@ -0,0 +1,132 @@
+import type { MapboxOverlayProps } from "@deck.gl/mapbox";
+import { MapboxOverlay } from "@deck.gl/mapbox";
+import { ZarrLayer } from "@developmentseed/deck.gl-zarr";
+import "maplibre-gl/dist/maplibre-gl.css";
+import { useRef, useState } from "react";
+import type { MapRef } from "react-map-gl/maplibre";
+import { Map as MaplibreMap, useControl } from "react-map-gl/maplibre";
+
+function DeckGLOverlay(props: MapboxOverlayProps) {
+ const overlay = useControl(() => new MapboxOverlay(props));
+ overlay.setProps(props);
+ return null;
+}
+
+// Currently generated locally from
+// https://github.com/developmentseed/geozarr-examples/pull/36
+const ZARR_URL = "http://localhost:8080/TCI.zarr";
+
+export default function App() {
+ const mapRef = useRef(null);
+ const [debug, setDebug] = useState(false);
+ const [debugOpacity, setDebugOpacity] = useState(0.25);
+
+ const zarrLayer = new ZarrLayer({
+ id: "zarr-layer",
+ source: ZARR_URL,
+ debug,
+ debugOpacity,
+ });
+
+ return (
+
+
+
+
+
+
+
+
+ ZarrLayer — Sentinel-2 TCI
+
+
+ GeoZarr multiscale, EPSG:32612
+
+
+
+
+
+
+ );
+}
diff --git a/examples/zarr-sentinel2-tci/src/main.tsx b/examples/zarr-sentinel2-tci/src/main.tsx
new file mode 100644
index 00000000..f8fc6f51
--- /dev/null
+++ b/examples/zarr-sentinel2-tci/src/main.tsx
@@ -0,0 +1,9 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import App from "./App";
+
+createRoot(document.getElementById("root")!).render(
+
+
+ ,
+);
diff --git a/examples/zarr-sentinel2-tci/tsconfig.json b/examples/zarr-sentinel2-tci/tsconfig.json
new file mode 100644
index 00000000..f0a23505
--- /dev/null
+++ b/examples/zarr-sentinel2-tci/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"]
+}
diff --git a/examples/zarr-sentinel2-tci/vite.config.ts b/examples/zarr-sentinel2-tci/vite.config.ts
new file mode 100644
index 00000000..7632979b
--- /dev/null
+++ b/examples/zarr-sentinel2-tci/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/zarr-sentinel2-tci/",
+ worker: { format: "es" },
+ server: {
+ port: 3000,
+ },
+});
diff --git a/packages/deck.gl-geotiff/src/cog-layer.ts b/packages/deck.gl-geotiff/src/cog-layer.ts
index b2049fb1..dfecc88c 100644
--- a/packages/deck.gl-geotiff/src/cog-layer.ts
+++ b/packages/deck.gl-geotiff/src/cog-layer.ts
@@ -13,7 +13,6 @@ import type {
_Tileset2DProps as Tileset2DProps,
} from "@deck.gl/geo-layers";
import { TileLayer } from "@deck.gl/geo-layers";
-import { PathLayer, TextLayer } from "@deck.gl/layers";
import type {
RenderTileResult,
TileMetadata,
@@ -21,6 +20,7 @@ import type {
import {
RasterLayer,
RasterTileset2D,
+ _renderDebugTileOutline as renderDebugTileOutline,
TileMatrixSetAdaptor,
} from "@developmentseed/deck.gl-raster";
import type { DecoderPool, GeoTIFF, Overview } from "@developmentseed/geotiff";
@@ -421,108 +421,102 @@ export class COGLayer<
const tile = props.tile as Tile2DHeader> &
TileMetadata;
+ const layers: Layer[] = [];
+ if (debug) {
+ layers.push(
+ ...renderDebugTileOutline(
+ `${this.id}-${tile.id}-bounds`,
+ tile,
+ forwardTo4326,
+ ),
+ );
+ }
+
if (!props.data) {
- return null;
+ return layers;
}
const { data, forwardTransform, inverseTransform } = props.data;
- const layers: Layer[] = [];
+ const { height, width } = data;
- if (data) {
- const { height, width } = data;
-
- let tileResult: RenderTileResult;
- if (this.props.getTileData) {
- // In the case that the user passed in a custom `getTileData`, TS knows
- // that `data` can be passed in to `renderTile`.
- tileResult = this.props.renderTile(data);
- } else {
- // In the default case, `data` is `DefaultDataT` — cast required because
- // TS can't prove that `DataT` (which defaults to `DefaultDataT`) is
- // `DefaultDataT` at this point.
- tileResult = this.state.defaultRenderTile!(
- data as unknown as DefaultDataT,
- );
- }
- const { image, renderPipeline } = tileResult;
-
- // viewport.resolution is defined for GlobeView, undefined for WebMercatorViewport.
- // For WebMercator we project the mesh to EPSG:3857 and use a model matrix
- // to map from 3857 meters to deck.gl world space, matching the approach
- // used by the MVTLayer. This avoids per-vertex WGS84→WebMercator linear
- // interpolation errors that become visible at high latitudes.
- const isGlobe = this.context.viewport.resolution !== undefined;
- let reprojectionFns: ReprojectionFns;
- let deckProjectionProps: Partial;
-
- if (isGlobe) {
- reprojectionFns = {
- forwardTransform,
- inverseTransform,
- forwardReproject: forwardTo4326,
- inverseReproject: inverseFrom4326,
- };
- deckProjectionProps = {};
- } else {
- reprojectionFns = {
- forwardTransform,
- inverseTransform,
- forwardReproject: forwardTo3857,
- inverseReproject: inverseFrom3857,
- };
- // Scale 3857 meters → deck.gl world units (512×512).
- //
- // coordinateOrigin shifts the world-space origin to (256, 256) so that
- // easting=0 / northing=0 maps to world center. Then the modelMatrix
- //
- // No Y-flip needed: CARTESIAN Y increases upward = northing.
- deckProjectionProps = {
- coordinateSystem: COORDINATE_SYSTEM.CARTESIAN,
- coordinateOrigin: [TILE_SIZE / 2, TILE_SIZE / 2, 0],
- // biome-ignore format: array
- modelMatrix: [
+ let tileResult: RenderTileResult;
+ if (this.props.getTileData) {
+ // In the case that the user passed in a custom `getTileData`, TS knows
+ // that `data` can be passed in to `renderTile`.
+ tileResult = this.props.renderTile(data);
+ } else {
+ // In the default case, `data` is `DefaultDataT` — cast required because
+ // TS can't prove that `DataT` (which defaults to `DefaultDataT`) is
+ // `DefaultDataT` at this point.
+ tileResult = this.state.defaultRenderTile!(
+ data as unknown as DefaultDataT,
+ );
+ }
+ const { image, renderPipeline } = tileResult;
+
+ // viewport.resolution is defined for GlobeView, undefined for WebMercatorViewport.
+ // For WebMercator we project the mesh to EPSG:3857 and use a model matrix
+ // to map from 3857 meters to deck.gl world space, matching the approach
+ // used by the MVTLayer. This avoids per-vertex WGS84→WebMercator linear
+ // interpolation errors that become visible at high latitudes.
+ const isGlobe = this.context.viewport.resolution !== undefined;
+ let reprojectionFns: ReprojectionFns;
+ let deckProjectionProps: Partial;
+
+ if (isGlobe) {
+ reprojectionFns = {
+ forwardTransform,
+ inverseTransform,
+ forwardReproject: forwardTo4326,
+ inverseReproject: inverseFrom4326,
+ };
+ deckProjectionProps = {};
+ } else {
+ reprojectionFns = {
+ forwardTransform,
+ inverseTransform,
+ forwardReproject: forwardTo3857,
+ inverseReproject: inverseFrom3857,
+ };
+ // Scale 3857 meters → deck.gl world units (512×512).
+ //
+ // coordinateOrigin shifts the world-space origin to (256, 256) so that
+ // easting=0 / northing=0 maps to world center. Then the modelMatrix
+ //
+ // No Y-flip needed: CARTESIAN Y increases upward = northing.
+ deckProjectionProps = {
+ coordinateSystem: COORDINATE_SYSTEM.CARTESIAN,
+ coordinateOrigin: [TILE_SIZE / 2, TILE_SIZE / 2, 0],
+ // biome-ignore format: array
+ modelMatrix: [
WEB_MERCATOR_TO_WORLD_SCALE, 0, 0, 0,
0, WEB_MERCATOR_TO_WORLD_SCALE, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
],
- };
- }
-
- layers.push(
- new RasterLayer(
- this.getSubLayerProps({
- id: `${props.id}-raster`,
- width,
- height,
- // Only pass image if defined — passing `undefined` explicitly overrides
- // the default null and causes isAsyncPropLoading to return true briefly,
- // which hides the parent tile placeholder and causes a black flash.
- // https://github.com/developmentseed/deck.gl-raster/issues/376
- ...(image !== undefined && { image }),
- renderPipeline,
- maxError,
- reprojectionFns,
- debug,
- debugOpacity,
- ...deckProjectionProps,
- }),
- ),
- );
+ };
}
- if (debug) {
- layers.push(
- ...this.renderDebugTileOutline(
- `${this.id}-${tile.id}-bounds`,
- tile,
- forwardTo4326,
- ),
- );
- }
-
- return layers;
+ const rasterLayer = new RasterLayer(
+ this.getSubLayerProps({
+ id: `${props.id}-raster`,
+ width,
+ height,
+ // Only pass image if defined — passing `undefined` explicitly overrides
+ // the default null and causes isAsyncPropLoading to return true briefly,
+ // which hides the parent tile placeholder and causes a black flash.
+ // https://github.com/developmentseed/deck.gl-raster/issues/376
+ ...(image !== undefined && { image }),
+ renderPipeline,
+ maxError,
+ reprojectionFns,
+ debug,
+ debugOpacity,
+ ...deckProjectionProps,
+ }),
+ );
+ return [rasterLayer, ...layers];
}
/** Define the underlying deck.gl TileLayer. */
@@ -608,68 +602,4 @@ export class COGLayer<
geotiff,
);
}
-
- renderDebugTileOutline(
- id: string,
- tile: Tile2DHeader> & TileMetadata,
- forwardTo4326: ReprojectionFns["forwardReproject"],
- ) {
- const { projectedCorners } = tile;
-
- // Create a closed path in WGS84 projection around the tile bounds
- //
- // The tile has a `bbox` field which is already the bounding box in WGS84,
- // but that uses `transformBounds` and densifies edges. So the corners of
- // the bounding boxes don't line up with each other.
- //
- // In this case in the debug mode, it looks better if we ignore the actual
- // non-linearities of the edges and just draw a box connecting the
- // reprojected corners. In any case, the _image itself_ will be densified
- // on the edges as a feature of the mesh generation.
- const { topLeft, topRight, bottomRight, bottomLeft } = projectedCorners;
- const topLeftWgs84 = forwardTo4326(topLeft[0], topLeft[1]);
- const topRightWgs84 = forwardTo4326(topRight[0], topRight[1]);
- const bottomRightWgs84 = forwardTo4326(bottomRight[0], bottomRight[1]);
- const bottomLeftWgs84 = forwardTo4326(bottomLeft[0], bottomLeft[1]);
-
- const path = [
- topLeftWgs84,
- topRightWgs84,
- bottomRightWgs84,
- bottomLeftWgs84,
- topLeftWgs84,
- ];
-
- const center = [
- (topLeftWgs84[0] + bottomRightWgs84[0]) / 2,
- (topLeftWgs84[1] + bottomRightWgs84[1]) / 2,
- ];
- const labelLayer = new TextLayer({
- id: `${id}-label`,
- data: [
- {
- position: center,
- text: `x=${tile.index.x} y=${tile.index.y} z=${tile.index.z}`,
- },
- ],
- getColor: [255, 255, 255, 255],
- getSize: 24,
- sizeUnits: "pixels",
- outlineWidth: 3,
- outlineColor: [0, 0, 0, 255],
- fontSettings: { sdf: true },
- });
-
- const outlineLayer = new PathLayer({
- id,
- data: [path],
- getPath: (d) => d,
- getColor: [255, 0, 0, 255], // Red
- getWidth: 2,
- widthUnits: "pixels",
- pickable: false,
- });
-
- return [outlineLayer, labelLayer];
- }
}
diff --git a/packages/deck.gl-raster/src/index.ts b/packages/deck.gl-raster/src/index.ts
index 227338c8..ee4f8eb8 100644
--- a/packages/deck.gl-raster/src/index.ts
+++ b/packages/deck.gl-raster/src/index.ts
@@ -1,4 +1,6 @@
export type { RasterModule } from "./gpu-modules/types.js";
+// Not a public API; exported for use in COGLayer and ZarrLayer
+export { renderDebugTileOutline as _renderDebugTileOutline } from "./layer-utils.js";
export type { RasterLayerProps, RenderTileResult } from "./raster-layer.js";
export { RasterLayer } from "./raster-layer.js";
export type {
diff --git a/packages/deck.gl-raster/src/layer-utils.ts b/packages/deck.gl-raster/src/layer-utils.ts
new file mode 100644
index 00000000..d022a5bb
--- /dev/null
+++ b/packages/deck.gl-raster/src/layer-utils.ts
@@ -0,0 +1,68 @@
+import type { _Tile2DHeader as Tile2DHeader } from "@deck.gl/geo-layers";
+import { PathLayer, TextLayer } from "@deck.gl/layers";
+import type { ReprojectionFns } from "@developmentseed/raster-reproject";
+import type { TileMetadata } from "./raster-tileset";
+
+export function renderDebugTileOutline(
+ id: string,
+ tile: Tile2DHeader & TileMetadata,
+ forwardTo4326: ReprojectionFns["forwardReproject"],
+) {
+ const { projectedCorners } = tile;
+
+ // Create a closed path in WGS84 projection around the tile bounds
+ //
+ // The tile has a `bbox` field which is already the bounding box in WGS84,
+ // but that uses `transformBounds` and densifies edges. So the corners of
+ // the bounding boxes don't line up with each other.
+ //
+ // In this case in the debug mode, it looks better if we ignore the actual
+ // non-linearities of the edges and just draw a box connecting the
+ // reprojected corners. In any case, the _image itself_ will be densified
+ // on the edges as a feature of the mesh generation.
+ const { topLeft, topRight, bottomRight, bottomLeft } = projectedCorners;
+ const topLeftWgs84 = forwardTo4326(topLeft[0], topLeft[1]);
+ const topRightWgs84 = forwardTo4326(topRight[0], topRight[1]);
+ const bottomRightWgs84 = forwardTo4326(bottomRight[0], bottomRight[1]);
+ const bottomLeftWgs84 = forwardTo4326(bottomLeft[0], bottomLeft[1]);
+
+ const path = [
+ topLeftWgs84,
+ topRightWgs84,
+ bottomRightWgs84,
+ bottomLeftWgs84,
+ topLeftWgs84,
+ ];
+
+ const center = [
+ (topLeftWgs84[0] + bottomRightWgs84[0]) / 2,
+ (topLeftWgs84[1] + bottomRightWgs84[1]) / 2,
+ ];
+ const labelLayer = new TextLayer({
+ id: `${id}-label`,
+ data: [
+ {
+ position: center,
+ text: `x=${tile.index.x} y=${tile.index.y} z=${tile.index.z}`,
+ },
+ ],
+ getColor: [255, 255, 255, 255],
+ getSize: 24,
+ sizeUnits: "pixels",
+ outlineWidth: 3,
+ outlineColor: [0, 0, 0, 255],
+ fontSettings: { sdf: true },
+ });
+
+ const outlineLayer = new PathLayer({
+ id,
+ data: [path],
+ getPath: (d) => d,
+ getColor: [255, 0, 0, 255], // Red
+ getWidth: 2,
+ widthUnits: "pixels",
+ pickable: false,
+ });
+
+ return [outlineLayer, labelLayer];
+}
diff --git a/packages/deck.gl-zarr/src/index.ts b/packages/deck.gl-zarr/src/index.ts
index f01de927..0b2a8c1e 100644
--- a/packages/deck.gl-zarr/src/index.ts
+++ b/packages/deck.gl-zarr/src/index.ts
@@ -1,2 +1,2 @@
-// Zarr visualization placeholder
-export {};
+export type { ZarrLayerProps } from "./zarr-layer.js";
+export { ZarrLayer } from "./zarr-layer.js";
diff --git a/packages/deck.gl-zarr/src/zarr-layer.ts b/packages/deck.gl-zarr/src/zarr-layer.ts
new file mode 100644
index 00000000..2ebb12b8
--- /dev/null
+++ b/packages/deck.gl-zarr/src/zarr-layer.ts
@@ -0,0 +1,566 @@
+import type {
+ CompositeLayerProps,
+ Layer,
+ LayerProps,
+ LayersList,
+ UpdateParameters,
+} from "@deck.gl/core";
+import { COORDINATE_SYSTEM, CompositeLayer } from "@deck.gl/core";
+import type {
+ _Tile2DHeader as Tile2DHeader,
+ TileLayerProps,
+ _TileLoadProps as TileLoadProps,
+ _Tileset2DProps as Tileset2DProps,
+} from "@deck.gl/geo-layers";
+import { TileLayer } from "@deck.gl/geo-layers";
+import * as affine from "@developmentseed/affine";
+import type { TileMetadata } from "@developmentseed/deck.gl-raster";
+import {
+ RasterLayer,
+ RasterTileset2D,
+ _renderDebugTileOutline as renderDebugTileOutline,
+} from "@developmentseed/deck.gl-raster";
+import type { GeoZarrMetadata } from "@developmentseed/geozarr";
+import { parseGeoZarrMetadata } from "@developmentseed/geozarr";
+import type {
+ EpsgResolver,
+ ProjectionDefinition,
+ ProjJson,
+} from "@developmentseed/proj";
+import {
+ epsgResolver,
+ makeClampedForwardTo3857,
+ metersPerUnit,
+ parseWkt,
+} from "@developmentseed/proj";
+import type { ReprojectionFns } from "@developmentseed/raster-reproject";
+import proj4 from "proj4";
+import * as zarr from "zarrita";
+import { geoZarrToDescriptor } from "./zarr-tileset.js";
+
+/** Size of deck.gl's common coordinate space in world units. */
+const TILE_SIZE = 512;
+
+/** Size of the globe in web mercator meters. */
+const WEB_MERCATOR_METER_CIRCUMFERENCE = 40075016.686;
+
+/** Scale factor for converting EPSG:3857 meters into deck.gl world units. */
+const WEB_MERCATOR_TO_WORLD_SCALE =
+ TILE_SIZE / WEB_MERCATOR_METER_CIRCUMFERENCE;
+
+/**
+ * Props for the {@link ZarrLayer}.
+ */
+export type ZarrLayerProps<
+ Store extends zarr.Readable = zarr.Readable,
+ Dtype extends zarr.DataType = zarr.DataType,
+> = CompositeLayerProps &
+ Pick<
+ TileLayerProps,
+ | "debounceTime"
+ | "maxCacheSize"
+ | "maxCacheByteSize"
+ | "maxRequests"
+ | "refinementStrategy"
+ > & {
+ /** URL to the Zarr v3 store root. */
+ source: string | URL | zarr.Array | zarr.Group;
+
+ /**
+ * Optional path within the store to the variable group.
+ * If omitted, the root group is used.
+ */
+ variable?: string;
+
+ /**
+ * Index to use for non-spatial dimensions (e.g. `{ time: 0, band: 2 }`).
+ * Defaults to 0 for any unspecified dimension.
+ */
+ dimensionIndices?: Record;
+
+ /**
+ * Resolver for authority:code CRS strings (e.g. "EPSG:4326").
+ * Defaults to fetching from epsg.io.
+ */
+ epsgResolver?: EpsgResolver;
+
+ /** Maximum reprojection error in pixels for mesh refinement. @default 0.125 */
+ maxError?: number;
+
+ /** Enable debug tile outline visualization. @default false */
+ debug?: boolean;
+
+ /** Opacity of the debug mesh overlay (0-1). @default 0.5 */
+ debugOpacity?: number;
+
+ /** Called when Zarr metadata has been loaded and parsed. */
+ // TODO: restore onZarrLoad once we understand what metadata we should pass
+ // through it.
+ // onZarrLoad?: (meta: GeoZarrMetadata) => void;
+
+ /** User-provided AbortSignal to cancel loading. */
+ signal?: AbortSignal;
+ };
+
+const defaultProps: Partial = {
+ ...TileLayer.defaultProps,
+ epsgResolver,
+ debug: false,
+ debugOpacity: 0.5,
+};
+
+type TileData = {
+ image: ImageData;
+ forwardTransform: ReprojectionFns["forwardTransform"];
+ inverseTransform: ReprojectionFns["inverseTransform"];
+ width: number;
+ height: number;
+};
+
+/**
+ * ZarrLayer renders a GeoZarr dataset using a tiled approach with reprojection.
+ */
+export class ZarrLayer extends CompositeLayer {
+ static override layerName = "ZarrLayer";
+ static override defaultProps = defaultProps;
+
+ declare state: {
+ meta?: GeoZarrMetadata;
+
+ // TODO: arrays should be a named record, since the GeoZarr levels array
+ // isn't ordered. And we might need to support multiple separate arrays
+ // (different variables) in a single render
+ /** One opened array per level, finest-first (matches meta.levels order). */
+ arrays?: zarr.Array[];
+ forwardTo4326?: ReprojectionFns["forwardReproject"];
+ inverseFrom4326?: ReprojectionFns["inverseReproject"];
+ forwardTo3857?: ReprojectionFns["forwardReproject"];
+ inverseFrom3857?: ReprojectionFns["inverseReproject"];
+ mpu?: number;
+ };
+
+ override initializeState(): void {
+ this.setState({});
+ }
+
+ override updateState(params: UpdateParameters) {
+ super.updateState(params);
+
+ const { props, oldProps, changeFlags } = params;
+
+ const needsUpdate =
+ Boolean(changeFlags.dataChanged) ||
+ props.source !== oldProps.source ||
+ props.variable !== oldProps.variable;
+
+ if (needsUpdate) {
+ // Clear stale state so renderLayers returns null until the new GeoTIFF is
+ // ready
+ this._clearState();
+ void this._parseZarr();
+ }
+ }
+
+ _clearState() {
+ this.setState({
+ meta: undefined,
+ arrays: undefined,
+ forwardTo4326: undefined,
+ inverseFrom4326: undefined,
+ forwardTo3857: undefined,
+ inverseFrom3857: undefined,
+ mpu: undefined,
+ });
+ }
+
+ async _parseZarr(): Promise {
+ const { source, variable } = this.props;
+
+ const store = new zarr.FetchStore(source.toString());
+ // @ts-expect-error - for debugging
+ window.store = store;
+
+ const root = await zarr.open(store);
+ // @ts-expect-error - for debugging
+ window.root = root;
+
+ const group = variable
+ ? await zarr.open(root.resolve(variable), { kind: "group" })
+ : root;
+ // @ts-expect-error - for debugging
+ window.group = group;
+
+ const meta = parseGeoZarrMetadata(group.attrs);
+ // @ts-expect-error - for debugging
+ window.meta = meta;
+
+ // Open each level's array once and keep the references in state.
+ // This avoids re-fetching array metadata on every tile request.
+ const arrays = await Promise.all(
+ meta.levels.map((level) =>
+ zarr.open(group.resolve(level.path), { kind: "array" }),
+ ),
+ );
+
+ const sourceProjection = await parseCrs(meta.crs, this.props.epsgResolver!);
+
+ // Build proj4 converters
+ // @ts-expect-error - proj4 typings don't cover wkt-parser output
+ const converter4326 = proj4(sourceProjection, "EPSG:4326");
+ const forwardTo4326 = (x: number, y: number) =>
+ converter4326.forward<[number, number]>([x, y], false);
+ const inverseFrom4326 = (x: number, y: number) =>
+ converter4326.inverse<[number, number]>([x, y], false);
+
+ // @ts-expect-error - proj4 typings don't cover wkt-parser output
+ const converter3857 = proj4(sourceProjection, "EPSG:3857");
+ const forwardTo3857 = makeClampedForwardTo3857(
+ (x: number, y: number) =>
+ converter3857.forward<[number, number]>([x, y], false),
+ forwardTo4326,
+ );
+ const inverseFrom3857 = (x: number, y: number) =>
+ converter3857.inverse<[number, number]>([x, y], false);
+
+ // Compute meters-per-CRS-unit from the resolved projection
+ const units = sourceProjection.units;
+ if (!units) {
+ throw new Error(
+ "Source projection is missing 'units' property, cannot compute meters per unit",
+ );
+ }
+ const semiMajorAxis: number | undefined =
+ sourceProjection.datum?.a ?? sourceProjection.a;
+ const mpu = metersPerUnit(units as Parameters[0], {
+ semiMajorAxis,
+ });
+
+ this.setState({
+ meta,
+ arrays,
+ forwardTo4326,
+ inverseFrom4326,
+ forwardTo3857,
+ inverseFrom3857,
+ mpu,
+ });
+ }
+
+ // TODO: I need to go through _getTileData again and understand how slicing is
+ // fetched.
+ async _getTileData(
+ tile: TileLoadProps,
+ meta: GeoZarrMetadata,
+ arrays: zarr.Array[],
+ ): Promise {
+ const { x, y, z } = tile.index;
+ const { dimensionIndices = {} } = this.props;
+
+ // descriptor z=0 is coarsest; meta.levels is finest-first
+ // so descriptor level z maps to meta.levels[numLevels - 1 - z]
+ const zarrLevelIdx = meta.levels.length - 1 - z;
+ const level = meta.levels[zarrLevelIdx]!;
+ const arr = arrays[zarrLevelIdx]!;
+
+ // TODO: don't hard-code y/x as the last two dims; look at spatial
+ // convention metadata
+ // chunks is [...otherDims, chunkHeight, chunkWidth]
+ const tileWidth = arr.chunks[arr.chunks.length - 1]!;
+ const tileHeight = arr.chunks[arr.chunks.length - 2]!;
+
+ // Build slice spec for all dimensions
+ // The last two dims are y (height) and x (width); others use dimensionIndices
+ const rowStart = y * tileHeight;
+ const rowEnd = Math.min((y + 1) * tileHeight, level.arrayHeight);
+ const colStart = x * tileWidth;
+ const colEnd = Math.min((x + 1) * tileWidth, level.arrayWidth);
+
+ const actualHeight = rowEnd - rowStart;
+ const actualWidth = colEnd - colStart;
+
+ // Build slice for each dimension
+ const slices: (zarr.Slice | number)[] = arr.shape.map((_, dimIdx) => {
+ const numDims = arr.shape.length;
+ // TODO: don't hard-code y/x as the last two dims; look at spatial
+ // convention metadata
+ if (dimIdx === numDims - 2) {
+ // y dimension
+ return zarr.slice(rowStart, rowEnd);
+ }
+ if (dimIdx === numDims - 1) {
+ // x dimension
+ return zarr.slice(colStart, colEnd);
+ }
+ // Other dimensions: use dimensionIndices or 0
+ const dimName = meta.axes[dimIdx] ?? String(dimIdx);
+ return dimensionIndices[dimName] ?? 0;
+ });
+
+ const result = await zarr.get(arr, slices);
+
+ // Compute per-tile affine: compose level affine with pixel offset of this tile
+ const tileOffset = affine.translation(colStart, rowStart);
+ const tileAffine = affine.compose(level.affine, tileOffset);
+ const invTileAffine = affine.invert(tileAffine);
+
+ const forwardTransform = (px: number, py: number) =>
+ affine.apply(tileAffine, px, py);
+ const inverseTransform = (cx: number, cy: number) =>
+ affine.apply(invTileAffine, cx, cy);
+
+ const image = toImageData(result, actualWidth, actualHeight);
+
+ return {
+ image,
+ forwardTransform,
+ inverseTransform,
+ width: actualWidth,
+ height: actualHeight,
+ };
+ }
+
+ _renderSubLayers(
+ props: TileLayerProps & {
+ id: string;
+ data?: TileData;
+ _offset: number;
+ tile: Tile2DHeader;
+ },
+ forwardTo4326: ReprojectionFns["forwardReproject"],
+ inverseFrom4326: ReprojectionFns["inverseReproject"],
+ forwardTo3857: ReprojectionFns["forwardReproject"],
+ inverseFrom3857: ReprojectionFns["inverseReproject"],
+ ): Layer | LayersList | null {
+ const { maxError, debug, debugOpacity } = this.props;
+
+ // Cast to include TileMetadata from raster-tileset's `getTileMetadata`
+ // method.
+ // TODO: implement generic handling of tile metadata upstream in TileLayer
+ const tile = props.tile as Tile2DHeader & TileMetadata;
+
+ const layers: Layer[] = [];
+ if (debug) {
+ layers.push(
+ ...renderDebugTileOutline(
+ `${this.id}-${tile.id}-bounds`,
+ tile,
+ forwardTo4326,
+ ),
+ );
+ }
+
+ if (!props.data) {
+ return layers;
+ }
+
+ const { image, forwardTransform, inverseTransform, width, height } =
+ props.data;
+
+ const isGlobe = this.context.viewport.resolution !== undefined;
+ let reprojectionFns: ReprojectionFns;
+ let deckProjectionProps: Partial;
+
+ if (isGlobe) {
+ reprojectionFns = {
+ forwardTransform,
+ inverseTransform,
+ forwardReproject: forwardTo4326,
+ inverseReproject: inverseFrom4326,
+ };
+ deckProjectionProps = {};
+ } else {
+ reprojectionFns = {
+ forwardTransform,
+ inverseTransform,
+ forwardReproject: forwardTo3857,
+ inverseReproject: inverseFrom3857,
+ };
+ deckProjectionProps = {
+ coordinateSystem: COORDINATE_SYSTEM.CARTESIAN,
+ coordinateOrigin: [TILE_SIZE / 2, TILE_SIZE / 2, 0],
+ // biome-ignore format: array
+ modelMatrix: [
+ WEB_MERCATOR_TO_WORLD_SCALE, 0, 0, 0,
+ 0, WEB_MERCATOR_TO_WORLD_SCALE, 0, 0,
+ 0, 0, 1, 0,
+ 0, 0, 0, 1
+ ],
+ };
+ }
+
+ const rasterLayer = new RasterLayer(
+ this.getSubLayerProps({
+ id: `${props.id}-raster`,
+ image,
+ width,
+ height,
+ maxError,
+ reprojectionFns,
+ debug,
+ debugOpacity,
+ ...deckProjectionProps,
+ }),
+ );
+ return [rasterLayer, ...layers];
+ }
+
+ renderTileLayer(
+ meta: GeoZarrMetadata,
+ arrays: zarr.Array[],
+ mpu: number,
+ forwardTo4326: ReprojectionFns["forwardReproject"],
+ inverseFrom4326: ReprojectionFns["inverseReproject"],
+ forwardTo3857: ReprojectionFns["forwardReproject"],
+ inverseFrom3857: ReprojectionFns["inverseReproject"],
+ ): TileLayer {
+ // TODO: don't hard-code y/x as the last two dims; look at spatial
+ // convention metadata
+ const chunkSizes = arrays.map((arr) => ({
+ width: arr.chunks[arr.chunks.length - 1]!,
+ height: arr.chunks[arr.chunks.length - 2]!,
+ }));
+
+ class ZarrTilesetFactory extends RasterTileset2D {
+ constructor(opts: Tileset2DProps) {
+ const descriptor = geoZarrToDescriptor(
+ meta,
+ forwardTo4326,
+ forwardTo3857,
+ chunkSizes,
+ mpu,
+ );
+ super(opts, descriptor, { projectTo4326: forwardTo4326 });
+ }
+ }
+
+ const {
+ maxRequests,
+ maxCacheSize,
+ maxCacheByteSize,
+ debounceTime,
+ refinementStrategy,
+ } = this.props;
+
+ return new TileLayer({
+ id: `zarr-tile-layer-${this.id}`,
+ TilesetClass: ZarrTilesetFactory,
+ getTileData: (tile) => this._getTileData(tile, meta, arrays),
+ renderSubLayers: (props) =>
+ this._renderSubLayers(
+ props,
+ forwardTo4326,
+ inverseFrom4326,
+ forwardTo3857,
+ inverseFrom3857,
+ ),
+ debounceTime,
+ maxCacheByteSize,
+ maxCacheSize,
+ maxRequests,
+ refinementStrategy,
+ });
+ }
+
+ override renderLayers() {
+ const {
+ meta,
+ arrays,
+ mpu,
+ forwardTo4326,
+ inverseFrom4326,
+ forwardTo3857,
+ inverseFrom3857,
+ } = this.state;
+
+ if (
+ !meta ||
+ !arrays ||
+ mpu === undefined ||
+ !forwardTo4326 ||
+ !inverseFrom4326 ||
+ !forwardTo3857 ||
+ !inverseFrom3857
+ ) {
+ return null;
+ }
+
+ return this.renderTileLayer(
+ meta,
+ arrays,
+ mpu,
+ forwardTo4326,
+ inverseFrom4326,
+ forwardTo3857,
+ inverseFrom3857,
+ );
+ }
+}
+
+async function parseCrs(
+ crs: GeoZarrMetadata["crs"],
+ epsgResolver: EpsgResolver,
+): Promise {
+ if (crs.code) {
+ const [authority, code] = crs.code.split(":");
+ if (authority !== "EPSG") {
+ throw new Error(
+ `Unsupported CRS authority "${authority}". Only "EPSG" is supported.`,
+ );
+ }
+ if (!code) {
+ throw new Error(
+ `Invalid CRS code "${crs.code}". Expected format "EPSG:XXXX".`,
+ );
+ }
+ return await epsgResolver(Number.parseInt(code, 10));
+ } else if (crs.wkt2) {
+ return parseWkt(crs.wkt2);
+ } else if (crs.projjson) {
+ return parseWkt(crs.projjson as unknown as ProjJson);
+ } else {
+ throw new Error("No CRS information found in GeoZarr metadata");
+ }
+}
+
+/**
+ * Convert a band-planar zarr result to an RGBA ImageData.
+ *
+ * Supports:
+ * - shape [3, H, W] → RGB (alpha = 255)
+ * - shape [1, H, W] → grayscale (R=G=B, alpha = 255)
+ * - shape [H, W] → grayscale (R=G=B, alpha = 255)
+ */
+function toImageData(
+ result: zarr.Chunk,
+ width: number,
+ height: number,
+): ImageData {
+ const { data, shape } = result;
+ const rgba = new Uint8ClampedArray(width * height * 4);
+ const numBands = shape.length >= 3 ? shape[shape.length - 3]! : 1;
+ const pixelCount = width * height;
+
+ if (numBands >= 3) {
+ // Band-planar RGB: [3, H, W]
+ const rOffset = 0;
+ const gOffset = pixelCount;
+ const bOffset = pixelCount * 2;
+ for (let i = 0; i < pixelCount; i++) {
+ rgba[i * 4 + 0] = data[rOffset + i]!;
+ rgba[i * 4 + 1] = data[gOffset + i]!;
+ rgba[i * 4 + 2] = data[bOffset + i]!;
+ rgba[i * 4 + 3] = 255;
+ }
+ } else {
+ // Single band: [1, H, W] or [H, W]
+ for (let i = 0; i < pixelCount; i++) {
+ const v = data[i]!;
+ rgba[i * 4 + 0] = v;
+ rgba[i * 4 + 1] = v;
+ rgba[i * 4 + 2] = v;
+ rgba[i * 4 + 3] = 255;
+ }
+ }
+
+ return new ImageData(rgba, width, height);
+}
diff --git a/packages/geozarr/src/parse.ts b/packages/geozarr/src/parse.ts
index 24bc0454..dc1efdfe 100644
--- a/packages/geozarr/src/parse.ts
+++ b/packages/geozarr/src/parse.ts
@@ -36,7 +36,7 @@ export function parseGeoZarrMetadata(attrs: unknown): GeoZarrMetadata {
} else if ("proj:wkt2" in geoProj) {
crs.wkt2 = geoProj["proj:wkt2"];
} else if ("proj:projjson" in geoProj) {
- crs.projjson = geoProj["proj:projjson"] as Record;
+ crs.projjson = geoProj["proj:projjson"];
}
// --- Axes ---
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 171b43df..591b5f83 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -267,6 +267,61 @@ importers:
specifier: ^7.3.1
version: 7.3.1(@types/node@25.3.3)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
+ examples/zarr-sentinel2-tci:
+ dependencies:
+ '@deck.gl/core':
+ specifier: ^9.2.11
+ version: 9.2.11
+ '@deck.gl/geo-layers':
+ specifier: ^9.2.11
+ version: 9.2.11(@deck.gl/core@9.2.11)(@deck.gl/extensions@9.2.5(@deck.gl/core@9.2.11)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6))))(@deck.gl/layers@9.2.11(@deck.gl/core@9.2.11)(@loaders.gl/core@4.3.4)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6))))(@deck.gl/mesh-layers@9.2.11(@deck.gl/core@9.2.11)(@loaders.gl/core@4.3.4)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/gltf@9.2.6(@luma.gl/constants@9.2.6)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@loaders.gl/core@4.3.4)(@luma.gl/constants@9.2.6)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))
+ '@deck.gl/layers':
+ specifier: ^9.2.11
+ version: 9.2.11(@deck.gl/core@9.2.11)(@loaders.gl/core@4.3.4)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))
+ '@deck.gl/mapbox':
+ specifier: ^9.2.11
+ version: 9.2.11(@deck.gl/core@9.2.11)(@luma.gl/constants@9.2.6)(@luma.gl/core@9.2.6)(@math.gl/web-mercator@4.1.0)
+ '@deck.gl/mesh-layers':
+ specifier: ^9.2.11
+ version: 9.2.11(@deck.gl/core@9.2.11)(@loaders.gl/core@4.3.4)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/gltf@9.2.6(@luma.gl/constants@9.2.6)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6))
+ '@developmentseed/deck.gl-zarr':
+ specifier: workspace:^
+ version: link:../../packages/deck.gl-zarr
+ '@luma.gl/core':
+ specifier: ^9.2.6
+ version: 9.2.6
+ maplibre-gl:
+ specifier: ^5.19.0
+ version: 5.19.0
+ react:
+ specifier: ^19.2.4
+ version: 19.2.4
+ react-dom:
+ specifier: ^19.2.4
+ version: 19.2.4(react@19.2.4)
+ react-map-gl:
+ specifier: ^8.1.0
+ version: 8.1.0(maplibre-gl@5.19.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ zarrita:
+ specifier: ^0.6.1
+ version: 0.6.1
+ 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: ^5.1.4
+ version: 5.1.4(vite@7.3.1(@types/node@25.3.3)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
+ gh-pages:
+ specifier: ^6.3.0
+ version: 6.3.0
+ vite:
+ specifier: ^7.3.1
+ version: 7.3.1(@types/node@25.3.3)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
+
packages/affine:
devDependencies:
'@types/node':