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 +

+ +
+ + + {debug && ( +
+ +
+ )} +
+
+
+
+ ); +} 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':