From 9ed5a731bbb52ca1c6ba40876bed204aa67a7eb0 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Fri, 24 Apr 2026 14:01:41 -0400 Subject: [PATCH 01/10] docs: spec for titiler COG example Design for a new example that renders titiler .npy tiles via RasterTileLayer directly (the first example using that layer without a COGLayer/ZarrLayer subclass). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-04-24-titiler-cog-example-design.md | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 dev-docs/specs/2026-04-24-titiler-cog-example-design.md diff --git a/dev-docs/specs/2026-04-24-titiler-cog-example-design.md b/dev-docs/specs/2026-04-24-titiler-cog-example-design.md new file mode 100644 index 00000000..e720b1ff --- /dev/null +++ b/dev-docs/specs/2026-04-24-titiler-cog-example-design.md @@ -0,0 +1,215 @@ +# Titiler COG example design + +## Goal + +A prototype example that renders map tiles fetched from a titiler server as +`.npy` numpy arrays, decoded client-side, and drawn with +`RasterTileLayer` from `@developmentseed/deck.gl-raster`. + +The value proposition: unlike `COGLayer` and `ZarrLayer`, which parse the +source file's metadata on the frontend, here the frontend has no direct +access to the source image — it only talks to a tile server that hands +back per-tile binary arrays. Any backend that serves `.npy` tiles for an +OGC tile matrix set can plug into `RasterTileLayer` the same way. + +This is also the first example in the repo that uses `RasterTileLayer` +directly (not via a `COGLayer` / `ZarrLayer` subclass). + +## Non-goals + +- A dataset picker. One hardcoded COG URL. +- A general-purpose "TitilerLayer" package. Kept as example-local code. +- Support for non-8-bit outputs (e.g. 16-bit Sentinel-2 L2A bands). Only + `uint8` npy is accepted; other dtypes throw with a clear message. +- Reactive URL / parameter changes at runtime. The COG URL is static. +- Unit tests. Examples in this repo are not tested. + +## Server + +Uses the public `https://titiler.xyz` instance. Two endpoints: + +- `GET /cog/info?url=` — returns metadata including + `bounds: [west, south, east, north]` in WGS84. Used to fit the map. +- `GET /tileMatrixSets/WebMercatorQuad` — returns an OGC TMS 2.0 JSON + document. Used to build the `TilesetDescriptor`. +- `GET /cog/tiles/WebMercatorQuad/{z}/{x}/{y}.npy?url=` — returns + a numpy `.npy` file for one tile, shape `(bands, height, width)`, + dtype `uint8` for an RGB COG. For a 3-band RGB source titiler returns + 4 bands: R, G, B, mask. + +The COG URL is the Sentinel-2 TCI one already used in `cog-basic`: +`https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/18/T/WL/2026/1/S2B_18TWL_20260101_0_L2A/TCI.tif`. + +## Directory layout + +Mirrors `examples/cog-basic`: + +``` +examples/titiler-cog/ + index.html + package.json + tsconfig.json + vite.config.ts + src/ + App.tsx + main.tsx +``` + +The app is small enough that no further subdivision is warranted. If the +UI grows later (e.g. dataset picker, band pickers), split out a +`components/` directory — for now, everything lives in `App.tsx`. + +## Dependencies + +- `@deck.gl/core`, `@deck.gl/geo-layers`, `@deck.gl/layers`, + `@deck.gl/mapbox` +- `@developmentseed/deck.gl-raster` (workspace) — provides + `RasterTileLayer`, `TileMatrixSetAdaptor`, and the gpu-modules + (`CreateTexture`, `MaskTexture`). +- `@developmentseed/morecantile` (workspace) — for the `TileMatrixSet` + type used to type the response from `/tileMatrixSets/WebMercatorQuad`. +- `@developmentseed/proj` (workspace) — for `proj4` EPSG:3857 ↔ 4326 + transforms. (CRS is fixed here, no epsg-resolution is needed.) +- `npyjs` — decodes the `.npy` response. +- `maplibre-gl`, `react`, `react-dom`, `react-map-gl`. + +Dev dependencies: `vite`, `@vitejs/plugin-react`, `@types/react`, +`@types/react-dom`, `gh-pages`, `typescript` (whatever the existing +examples use). + +## Runtime data flow + +1. Mount. App is in a "loading" state with no tile layer. +2. Two fetches kick off in parallel: + - `GET /cog/info?url=` → `{ bounds, ... }`. + - `GET /tileMatrixSets/WebMercatorQuad` → OGC `TileMatrixSet`. +3. Both resolve. + - Build EPSG:3857 ↔ 4326 projection functions once. + - `projectTo3857` and `projectFrom3857` are identity (WebMercatorQuad's + CRS _is_ EPSG:3857). + - `projectTo4326` / `projectFrom4326` wrap `proj4("EPSG:3857", + "EPSG:4326").forward` / `.inverse`. + - `tilesetDescriptor = new TileMatrixSetAdaptor(tms, { projectTo3857, + projectFrom3857, projectTo4326, projectFrom4326 })`. + - Stash the descriptor in state. Call `mapRef.current.fitBounds(bounds)`. +4. `RasterTileLayer` renders with the descriptor, `getTileData`, and + `renderTile` props (below). +5. For each tile deck.gl requests, `getTileData` runs once; the result + is cached by the inner `TileLayer` (configured via the raster tile + layer's `maxCacheSize`/`maxCacheByteSize`). + +## `getTileData` + +``` +async function getTileData(tile, { device, signal }) { + const url = `https://titiler.xyz/cog/tiles/WebMercatorQuad/${tile.index.z}/${tile.index.x}/${tile.index.y}.npy?url=${encodeURIComponent(COG_URL)}`; + const response = await fetch(url, { signal }); + if (!response.ok) { + throw new Error(`titiler ${response.status}: ${await response.text()}`); + } + const buffer = await response.arrayBuffer(); + const { data, shape, dtype } = new npyjs().parse(buffer); + // Validate shape and dtype — throw if unexpected. + // Expect shape = [B, H, W] with B ∈ {3, 4} and dtype === "uint8". + const [bands, height, width] = shape; + const rgba = repackBandSeparateToRGBA(data, bands, height, width); + const texture = device.createTexture({ + data: rgba, + format: "rgba8unorm", + width, + height, + sampler: { minFilter: "linear", magFilter: "linear" }, + }); + let mask; + if (bands === 4) { + const maskBand = data.subarray(3 * height * width, 4 * height * width); + mask = device.createTexture({ + data: maskBand, + format: "r8unorm", + width, + height, + sampler: { minFilter: "nearest", magFilter: "nearest" }, + }); + } + return { + width, + height, + byteLength: rgba.byteLength + (mask ? height * width : 0), + texture, + mask, + }; +} +``` + +`repackBandSeparateToRGBA` is a tight loop: for each output pixel index +`i` in `0..H*W`, copy `data[0*HW+i]`, `data[1*HW+i]`, `data[2*HW+i]` +into `rgba[i*4..i*4+3]`, and set `rgba[i*4+3] = 255`. (The 4th band is +the mask, handled separately.) + +The returned object satisfies `MinimalTileData` and carries `texture` / +`mask` through to `renderTile`. + +## `renderTile` + +``` +function renderTile(data) { + const renderPipeline = [ + { module: CreateTexture, props: { textureName: data.texture } }, + ]; + if (data.mask) { + renderPipeline.push({ module: MaskTexture, props: { maskTexture: data.mask } }); + } + return { renderPipeline }; +} +``` + +Both `CreateTexture` and `MaskTexture` are already exported from +`@developmentseed/deck.gl-raster/gpu-modules`. + +## UI + +One top-left collapsible info panel, styled like `cog-basic`: + +- Title: "Titiler + RasterTileLayer". +- Paragraph: "Tiles are fetched as numpy `.npy` arrays from `titiler.xyz`, + parsed and uploaded as textures client-side, and rendered via + `RasterTileLayer`." +- Link: titiler documentation (`https://developmentseed.org/titiler/`). +- "Show Debug Mesh" checkbox wired to `debug` state; when on, a + "Debug Opacity" slider bound to `debugOpacity` state. Both are passed + through as `RasterTileLayer` props. + +Initial `viewState`: `{ longitude: 0, latitude: 0, zoom: 2 }`. On info +resolve, `mapRef.current.fitBounds([[w, s], [e, n]], { padding: 40, +duration: 1000 })`. + +Loading state: the map renders with no tile layer. The info panel +renders normally (it's not blocked on the fetches). No spinner. + +Error state: if either startup fetch fails, replace the paragraph with +an error message. Per-tile fetch errors are surfaced by deck.gl's own +tile error handling and do not block the app. + +## Resilience + +- Tile fetches pass the `AbortSignal` from `getTileData`'s `options`, + so cancelled tiles don't hold open requests. +- Non-2xx responses throw with a message including the status code and + body text, so failures are visible in the console. +- A clear `Error` is thrown when the npy dtype is not `uint8` or the + shape has fewer than 3 bands. No silent fallback. + +## File sizes (rough) + +- `src/App.tsx`: ~180 lines (UI + map setup + the two startup fetches + + `getTileData` + `renderTile`). If it blows past ~250 lines, split the + tile fetch helpers into `src/titiler.ts` and the UI panel into + `src/components/InfoPanel.tsx`. +- `index.html`, `main.tsx`, `package.json`, `tsconfig.json`, + `vite.config.ts`: standard Vite boilerplate copied from `cog-basic`. + +## Open questions + +None at spec time. If `npyjs` turns out to have an awkward ESM/CJS +packaging story under Vite, we fall back to a ~40-line inline parser +(the format is trivial for the dtypes we care about). From b64978df9dc589671bcddcf3a6fb4d192469b459 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Fri, 24 Apr 2026 14:10:54 -0400 Subject: [PATCH 02/10] chore: scaffold titiler-cog example Copies the cog-basic scaffolding (package.json, tsconfig, vite, index, main) and adds a blank-map App. Adds npyjs and proj4 as deps. --- examples/titiler-cog/index.html | 22 ++++ examples/titiler-cog/package.json | 36 +++++++ examples/titiler-cog/src/App.tsx | 34 ++++++ examples/titiler-cog/src/main.tsx | 9 ++ examples/titiler-cog/src/vite-env.d.ts | 1 + examples/titiler-cog/tsconfig.json | 4 + examples/titiler-cog/vite.config.ts | 11 ++ pnpm-lock.yaml | 140 +++++++++++++++++++++++-- 8 files changed, 251 insertions(+), 6 deletions(-) create mode 100644 examples/titiler-cog/index.html create mode 100644 examples/titiler-cog/package.json create mode 100644 examples/titiler-cog/src/App.tsx create mode 100644 examples/titiler-cog/src/main.tsx create mode 100644 examples/titiler-cog/src/vite-env.d.ts create mode 100644 examples/titiler-cog/tsconfig.json create mode 100644 examples/titiler-cog/vite.config.ts diff --git a/examples/titiler-cog/index.html b/examples/titiler-cog/index.html new file mode 100644 index 00000000..539f5490 --- /dev/null +++ b/examples/titiler-cog/index.html @@ -0,0 +1,22 @@ + + + + + + Titiler + RasterTileLayer + + + +
+ + + diff --git a/examples/titiler-cog/package.json b/examples/titiler-cog/package.json new file mode 100644 index 00000000..6a694d9e --- /dev/null +++ b/examples/titiler-cog/package.json @@ -0,0 +1,36 @@ +{ + "name": "deck.gl-titiler-cog-example", + "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/titiler-cog" + }, + "dependencies": { + "@deck.gl/core": "^9.3.0", + "@deck.gl/geo-layers": "^9.3.0", + "@deck.gl/layers": "^9.3.0", + "@deck.gl/mapbox": "^9.3.0", + "@developmentseed/deck.gl-raster": "workspace:^", + "@developmentseed/morecantile": "workspace:^", + "@developmentseed/proj": "workspace:^", + "@luma.gl/core": "9.3.2", + "maplibre-gl": "^5.19.0", + "npyjs": "^1.1.0", + "proj4": "^2.20.4", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-map-gl": "^8.1.0" + }, + "devDependencies": { + "@types/proj4": "^2.5.5", + "@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/titiler-cog/src/App.tsx b/examples/titiler-cog/src/App.tsx new file mode 100644 index 00000000..93c1281d --- /dev/null +++ b/examples/titiler-cog/src/App.tsx @@ -0,0 +1,34 @@ +import type { MapboxOverlayProps } from "@deck.gl/mapbox"; +import { MapboxOverlay } from "@deck.gl/mapbox"; +import "maplibre-gl/dist/maplibre-gl.css"; +import { useRef } 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; +} + +export default function App() { + const mapRef = useRef(null); + + return ( +
+ + + +
+ ); +} diff --git a/examples/titiler-cog/src/main.tsx b/examples/titiler-cog/src/main.tsx new file mode 100644 index 00000000..f8fc6f51 --- /dev/null +++ b/examples/titiler-cog/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/titiler-cog/src/vite-env.d.ts b/examples/titiler-cog/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/examples/titiler-cog/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/titiler-cog/tsconfig.json b/examples/titiler-cog/tsconfig.json new file mode 100644 index 00000000..4bd6962d --- /dev/null +++ b/examples/titiler-cog/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.base.json", + "include": ["src"] +} diff --git a/examples/titiler-cog/vite.config.ts b/examples/titiler-cog/vite.config.ts new file mode 100644 index 00000000..378b96ad --- /dev/null +++ b/examples/titiler-cog/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/titiler-cog/", + worker: { format: "es" }, + server: { + port: 3000, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aabb35d1..47747371 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -450,6 +450,70 @@ importers: specifier: ^7.3.1 version: 7.3.1(@types/node@25.6.0)(jiti@1.21.7)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3) + examples/titiler-cog: + dependencies: + '@deck.gl/core': + specifier: ^9.3.0 + version: 9.3.0 + '@deck.gl/geo-layers': + specifier: ^9.3.0 + version: 9.3.0(@deck.gl/core@9.3.0)(@deck.gl/extensions@9.2.5(@deck.gl/core@9.3.0)(@luma.gl/core@9.3.2)(@luma.gl/engine@9.3.2(@luma.gl/core@9.3.2)(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.2))))(@deck.gl/layers@9.3.0(@deck.gl/core@9.3.0)(@loaders.gl/core@4.3.4)(@luma.gl/core@9.3.2)(@luma.gl/engine@9.3.2(@luma.gl/core@9.3.2)(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.2))))(@deck.gl/mesh-layers@9.3.0(@deck.gl/core@9.3.0)(@loaders.gl/core@4.3.4)(@luma.gl/core@9.3.2)(@luma.gl/engine@9.3.2(@luma.gl/core@9.3.2)(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.2)))(@luma.gl/gltf@9.3.2(@luma.gl/core@9.3.2)(@luma.gl/engine@9.3.2(@luma.gl/core@9.3.2)(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.2)))(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.2)))(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.2)))(@loaders.gl/core@4.3.4)(@luma.gl/core@9.3.2)(@luma.gl/engine@9.3.2(@luma.gl/core@9.3.2)(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.2))) + '@deck.gl/layers': + specifier: ^9.3.0 + version: 9.3.0(@deck.gl/core@9.3.0)(@loaders.gl/core@4.3.4)(@luma.gl/core@9.3.2)(@luma.gl/engine@9.3.2(@luma.gl/core@9.3.2)(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.2))) + '@deck.gl/mapbox': + specifier: ^9.3.0 + version: 9.3.0(@deck.gl/core@9.3.0)(@luma.gl/core@9.3.2)(@math.gl/web-mercator@4.1.0) + '@developmentseed/deck.gl-raster': + specifier: workspace:^ + version: link:../../packages/deck.gl-raster + '@developmentseed/morecantile': + specifier: workspace:^ + version: link:../../packages/morecantile + '@developmentseed/proj': + specifier: workspace:^ + version: link:../../packages/proj + '@luma.gl/core': + specifier: ^9.3.2 + version: 9.3.2 + maplibre-gl: + specifier: ^5.19.0 + version: 5.19.0 + npyjs: + specifier: ^1.1.0 + version: 1.1.0 + proj4: + specifier: ^2.20.4 + version: 2.20.4 + 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) + devDependencies: + '@types/proj4': + specifier: ^2.5.5 + version: 2.19.0 + '@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.6.0)(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 + vite: + specifier: ^7.3.1 + version: 7.3.1(@types/node@25.6.0)(jiti@1.21.7)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3) + examples/usgs-topo-cutline: dependencies: '@deck.gl/core': @@ -3650,6 +3714,10 @@ packages: '@types/prismjs@1.26.6': resolution: {integrity: sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==} + '@types/proj4@2.19.0': + resolution: {integrity: sha512-mirdnXu5sW9+IphQ4r+ZVii2JVLDbXgMp1lxqbiCppdgL22EwMwL4Dy/cyHVaJ2y/mi7LUQHghpILd3w3j2bnA==} + deprecated: This is a stub types definition. proj4 provides its own type definitions, so you do not need this installed. + '@types/qs@6.14.0': resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} @@ -4427,6 +4495,9 @@ packages: typescript: optional: true + cross-fetch@4.1.0: + resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -6190,6 +6261,15 @@ packages: resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==} engines: {node: '>=18'} + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} @@ -6211,6 +6291,10 @@ packages: nprogress@0.2.0: resolution: {integrity: sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==} + npyjs@1.1.0: + resolution: {integrity: sha512-hHCr0iPGXWU4qWw5p0TztZgfUOmm7VEg4ztY9en/99wnetPGCvOtqSROL9VWk+UZgBu240JQWupY55srvmApnA==} + engines: {node: '>=18'} + nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} @@ -7676,6 +7760,9 @@ packages: resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} engines: {node: '>=16'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@6.0.0: resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} engines: {node: '>=20'} @@ -8039,6 +8126,9 @@ packages: web-worker@1.5.0: resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==} + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@8.0.1: resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} engines: {node: '>=20'} @@ -8145,6 +8235,9 @@ packages: resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -9931,7 +10024,7 @@ snapshots: postcss-preset-env: 10.6.1(postcss@8.5.6) terser-webpack-plugin: 5.3.17(esbuild@0.27.2)(webpack@5.106.2(esbuild@0.27.2)) tslib: 2.8.1 - url-loader: 4.1.1(file-loader@6.2.0(webpack@5.105.4(esbuild@0.27.2)))(webpack@5.106.2(esbuild@0.27.2)) + url-loader: 4.1.1(file-loader@6.2.0(webpack@5.106.2(esbuild@0.27.2)))(webpack@5.106.2(esbuild@0.27.2)) webpack: 5.106.2(esbuild@0.27.2) webpackbar: 6.0.1(webpack@5.106.2(esbuild@0.27.2)) transitivePeerDependencies: @@ -10034,7 +10127,7 @@ snapshots: '@slorber/remark-comment': 1.0.0 escape-html: 1.0.3 estree-util-value-to-estree: 3.5.0 - file-loader: 6.2.0(webpack@5.106.2(esbuild@0.27.2)) + file-loader: 6.2.0(webpack@5.105.4(esbuild@0.27.2)) fs-extra: 11.3.3 image-size: 2.0.2 mdast-util-mdx: 3.0.0 @@ -10050,7 +10143,7 @@ snapshots: tslib: 2.8.1 unified: 11.0.5 unist-util-visit: 5.1.0 - url-loader: 4.1.1(file-loader@6.2.0(webpack@5.105.4(esbuild@0.27.2)))(webpack@5.106.2(esbuild@0.27.2)) + url-loader: 4.1.1(file-loader@6.2.0(webpack@5.106.2(esbuild@0.27.2)))(webpack@5.106.2(esbuild@0.27.2)) vfile: 6.0.3 webpack: 5.106.2(esbuild@0.27.2) transitivePeerDependencies: @@ -10660,7 +10753,7 @@ snapshots: prompts: 2.4.2 resolve-pathname: 3.0.0 tslib: 2.8.1 - url-loader: 4.1.1(file-loader@6.2.0(webpack@5.105.4(esbuild@0.27.2)))(webpack@5.106.2(esbuild@0.27.2)) + url-loader: 4.1.1(file-loader@6.2.0(webpack@5.106.2(esbuild@0.27.2)))(webpack@5.106.2(esbuild@0.27.2)) utility-types: 3.11.0 webpack: 5.106.2(esbuild@0.27.2) transitivePeerDependencies: @@ -12093,6 +12186,10 @@ snapshots: '@types/prismjs@1.26.6': {} + '@types/proj4@2.19.0': + dependencies: + proj4: 2.20.4 + '@types/qs@6.14.0': {} '@types/range-parser@1.2.7': {} @@ -12968,6 +13065,12 @@ snapshots: optionalDependencies: typescript: 6.0.3 + cross-fetch@4.1.0: + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -13596,6 +13699,12 @@ snapshots: dependencies: escape-string-regexp: 1.0.5 + file-loader@6.2.0(webpack@5.105.4(esbuild@0.27.2)): + dependencies: + loader-utils: 2.0.4 + schema-utils: 3.3.0 + webpack: 5.105.4(esbuild@0.27.2) + file-loader@6.2.0(webpack@5.106.2(esbuild@0.27.2)): dependencies: loader-utils: 2.0.4 @@ -15214,6 +15323,10 @@ snapshots: emojilib: 2.4.0 skin-tone: 2.0.0 + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + node-releases@2.0.27: {} node-releases@2.0.38: {} @@ -15228,6 +15341,12 @@ snapshots: nprogress@0.2.0: {} + npyjs@1.1.0: + dependencies: + cross-fetch: 4.1.0 + transitivePeerDependencies: + - encoding + nth-check@2.1.1: dependencies: boolbase: 1.0.0 @@ -16879,6 +16998,8 @@ snapshots: dependencies: tldts: 7.0.23 + tr46@0.0.3: {} + tr46@6.0.0: dependencies: punycode: 2.3.1 @@ -17111,14 +17232,14 @@ snapshots: dependencies: punycode: 2.3.1 - url-loader@4.1.1(file-loader@6.2.0(webpack@5.105.4(esbuild@0.27.2)))(webpack@5.106.2(esbuild@0.27.2)): + url-loader@4.1.1(file-loader@6.2.0(webpack@5.106.2(esbuild@0.27.2)))(webpack@5.106.2(esbuild@0.27.2)): dependencies: loader-utils: 2.0.4 mime-types: 2.1.35 schema-utils: 3.3.0 webpack: 5.106.2(esbuild@0.27.2) optionalDependencies: - file-loader: 6.2.0(webpack@5.106.2(esbuild@0.27.2)) + file-loader: 6.2.0(webpack@5.105.4(esbuild@0.27.2)) util-deprecate@1.0.2: {} @@ -17242,6 +17363,8 @@ snapshots: web-worker@1.5.0: {} + webidl-conversions@3.0.1: {} + webidl-conversions@8.0.1: {} webpack-bundle-analyzer@4.10.2: @@ -17437,6 +17560,11 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + which@2.0.2: dependencies: isexe: 2.0.0 From 9a9043a1827fd566678f852114ce992953f39380 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Fri, 24 Apr 2026 14:13:09 -0400 Subject: [PATCH 03/10] feat: add info panel + debug state to titiler-cog example --- examples/titiler-cog/src/App.tsx | 136 ++++++++++++++++++++++++++++++- 1 file changed, 135 insertions(+), 1 deletion(-) diff --git a/examples/titiler-cog/src/App.tsx b/examples/titiler-cog/src/App.tsx index 93c1281d..32caa102 100644 --- a/examples/titiler-cog/src/App.tsx +++ b/examples/titiler-cog/src/App.tsx @@ -1,7 +1,7 @@ import type { MapboxOverlayProps } from "@deck.gl/mapbox"; import { MapboxOverlay } from "@deck.gl/mapbox"; import "maplibre-gl/dist/maplibre-gl.css"; -import { useRef } from "react"; +import { useRef, useState } from "react"; import type { MapRef } from "react-map-gl/maplibre"; import { Map as MaplibreMap, useControl } from "react-map-gl/maplibre"; @@ -13,6 +13,9 @@ function DeckGLOverlay(props: MapboxOverlayProps) { export default function App() { const mapRef = useRef(null); + const [debug, setDebug] = useState(false); + const [debugOpacity, setDebugOpacity] = useState(0.25); + const [panelOpen, setPanelOpen] = useState(true); return (
@@ -29,6 +32,137 @@ export default function App() { > + +
+
+ + {panelOpen && ( + <> +

+ Tiles are fetched as numpy .npy arrays from{" "} + titiler.xyz, parsed and uploaded as textures + client-side, then rendered via RasterTileLayer. +

+

+ + Titiler Documentation ↗ + +

+ +
+ + + {debug && ( +
+ +
+ )} +
+ + )} +
+
); } From cf330d572c893c70412025110cb00d929d791f0b Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Fri, 24 Apr 2026 14:15:22 -0400 Subject: [PATCH 04/10] feat: fetch titiler info + TMS, fit bounds on load --- examples/titiler-cog/src/App.tsx | 112 +++++++++++++++++++++++++++---- 1 file changed, 100 insertions(+), 12 deletions(-) diff --git a/examples/titiler-cog/src/App.tsx b/examples/titiler-cog/src/App.tsx index 32caa102..2471a438 100644 --- a/examples/titiler-cog/src/App.tsx +++ b/examples/titiler-cog/src/App.tsx @@ -1,7 +1,11 @@ import type { MapboxOverlayProps } from "@deck.gl/mapbox"; import { MapboxOverlay } from "@deck.gl/mapbox"; +import type { TilesetDescriptor } from "@developmentseed/deck.gl-raster"; +import { TileMatrixSetAdaptor } from "@developmentseed/deck.gl-raster"; +import type { TileMatrixSet } from "@developmentseed/morecantile"; import "maplibre-gl/dist/maplibre-gl.css"; -import { useRef, useState } from "react"; +import proj4 from "proj4"; +import { useEffect, useRef, useState } from "react"; import type { MapRef } from "react-map-gl/maplibre"; import { Map as MaplibreMap, useControl } from "react-map-gl/maplibre"; @@ -11,11 +15,83 @@ function DeckGLOverlay(props: MapboxOverlayProps) { return null; } +const COG_URL = + "https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/18/T/WL/2026/1/S2B_18TWL_20260101_0_L2A/TCI.tif"; +const TITILER_BASE = "https://titiler.xyz"; + +type InfoResponse = { + bounds: [number, number, number, number]; // WGS84 [w, s, e, n] + band_descriptions?: [string, Record][]; + dtype?: string; + [key: string]: unknown; +}; + +function buildDescriptor(tms: TileMatrixSet): TilesetDescriptor { + const converter = proj4("EPSG:3857", "EPSG:4326"); + const projectTo4326 = (x: number, y: number) => + converter.forward<[number, number]>([x, y], false); + const projectFrom4326 = (x: number, y: number) => + converter.inverse<[number, number]>([x, y], false); + const identity = (x: number, y: number): [number, number] => [x, y]; + return new TileMatrixSetAdaptor(tms, { + projectTo3857: identity, + projectFrom3857: identity, + projectTo4326, + projectFrom4326, + }); +} + export default function App() { const mapRef = useRef(null); const [debug, setDebug] = useState(false); const [debugOpacity, setDebugOpacity] = useState(0.25); const [panelOpen, setPanelOpen] = useState(true); + const [descriptor, setDescriptor] = useState(); + const [error, setError] = useState(); + void descriptor; + + useEffect(() => { + const controller = new AbortController(); + (async () => { + try { + const [infoRes, tmsRes] = await Promise.all([ + fetch(`${TITILER_BASE}/cog/info?url=${encodeURIComponent(COG_URL)}`, { + signal: controller.signal, + }), + fetch(`${TITILER_BASE}/tileMatrixSets/WebMercatorQuad`, { + signal: controller.signal, + }), + ]); + if (!infoRes.ok) { + throw new Error( + `cog/info ${infoRes.status}: ${await infoRes.text()}`, + ); + } + if (!tmsRes.ok) { + throw new Error( + `tileMatrixSets ${tmsRes.status}: ${await tmsRes.text()}`, + ); + } + const info = (await infoRes.json()) as InfoResponse; + const tms = (await tmsRes.json()) as TileMatrixSet; + setDescriptor(buildDescriptor(tms)); + const [w, s, e, n] = info.bounds; + mapRef.current?.fitBounds( + [ + [w, s], + [e, n], + ], + { padding: 40, duration: 1000 }, + ); + } catch (err) { + if ((err as { name?: string }).name === "AbortError") { + return; + } + setError((err as Error).message); + } + })(); + return () => controller.abort(); + }, []); return (
@@ -86,17 +162,29 @@ export default function App() { {panelOpen && ( <> -

- Tiles are fetched as numpy .npy arrays from{" "} - titiler.xyz, parsed and uploaded as textures - client-side, then rendered via RasterTileLayer. -

+ {error ? ( +

+ Error: {error} +

+ ) : ( +

+ Tiles are fetched as numpy .npy arrays from{" "} + titiler.xyz, parsed and uploaded as textures + client-side, then rendered via RasterTileLayer. +

+ )}

Date: Fri, 24 Apr 2026 14:17:46 -0400 Subject: [PATCH 05/10] feat: decode titiler .npy tiles and render via RasterTileLayer --- examples/titiler-cog/src/App.tsx | 131 ++++++++++++++++++++++++++++++- 1 file changed, 127 insertions(+), 4 deletions(-) diff --git a/examples/titiler-cog/src/App.tsx b/examples/titiler-cog/src/App.tsx index 2471a438..97d8bf9c 100644 --- a/examples/titiler-cog/src/App.tsx +++ b/examples/titiler-cog/src/App.tsx @@ -1,9 +1,25 @@ +import type { _TileLoadProps as TileLoadProps } from "@deck.gl/geo-layers"; import type { MapboxOverlayProps } from "@deck.gl/mapbox"; import { MapboxOverlay } from "@deck.gl/mapbox"; -import type { TilesetDescriptor } from "@developmentseed/deck.gl-raster"; -import { TileMatrixSetAdaptor } from "@developmentseed/deck.gl-raster"; +import type { + GetTileDataOptions, + MinimalTileData, + RasterModule, + RenderTileResult, + TilesetDescriptor, +} from "@developmentseed/deck.gl-raster"; +import { + RasterTileLayer, + TileMatrixSetAdaptor, +} from "@developmentseed/deck.gl-raster"; +import { + CreateTexture, + MaskTexture, +} from "@developmentseed/deck.gl-raster/gpu-modules"; import type { TileMatrixSet } from "@developmentseed/morecantile"; +import type { Texture } from "@luma.gl/core"; import "maplibre-gl/dist/maplibre-gl.css"; +import npyjs from "npyjs"; import proj4 from "proj4"; import { useEffect, useRef, useState } from "react"; import type { MapRef } from "react-map-gl/maplibre"; @@ -41,6 +57,103 @@ function buildDescriptor(tms: TileMatrixSet): TilesetDescriptor { }); } +type TileData = MinimalTileData & { + texture: Texture; + mask?: Texture; +}; + +/** + * Repack a band-separate uint8 buffer of shape [B, H, W] into an + * interleaved RGBA uint8 buffer of length H*W*4. Bands 0-2 go to R/G/B; + * alpha is always 255 (the 4th titiler band is a mask, handled separately). + */ +function repackToRGBA( + bandSeparate: Uint8Array, + height: number, + width: number, +): Uint8Array { + const pixelCount = height * width; + const rgba = new Uint8Array(pixelCount * 4); + const bandOffset0 = 0; + const bandOffset1 = pixelCount; + const bandOffset2 = 2 * pixelCount; + for (let i = 0; i < pixelCount; i++) { + rgba[i * 4] = bandSeparate[bandOffset0 + i]!; + rgba[i * 4 + 1] = bandSeparate[bandOffset1 + i]!; + rgba[i * 4 + 2] = bandSeparate[bandOffset2 + i]!; + rgba[i * 4 + 3] = 255; + } + return rgba; +} + +function tileNpyUrl(x: number, y: number, z: number): string { + return `${TITILER_BASE}/cog/tiles/WebMercatorQuad/${z}/${x}/${y}.npy?url=${encodeURIComponent(COG_URL)}`; +} + +async function getTileData( + tile: TileLoadProps, + options: GetTileDataOptions, +): Promise { + const { device, signal } = options; + const { x, y, z } = tile.index; + const response = await fetch(tileNpyUrl(x, y, z), { signal }); + if (!response.ok) { + throw new Error( + `titiler tile ${z}/${x}/${y} ${response.status}: ${await response.text()}`, + ); + } + const buffer = await response.arrayBuffer(); + const parsed = await new npyjs().load(buffer); + if (parsed.dtype !== "u1") { + throw new Error(`Expected uint8 (u1) npy, got dtype=${parsed.dtype}`); + } + if (parsed.shape.length !== 3) { + throw new Error( + `Expected shape [B, H, W], got [${parsed.shape.join(", ")}]`, + ); + } + const [bands, height, width] = parsed.shape as [number, number, number]; + if (bands !== 3 && bands !== 4) { + throw new Error(`Expected 3 or 4 bands, got ${bands}`); + } + const data = parsed.data as Uint8Array; + const rgba = repackToRGBA(data, height, width); + const texture = device.createTexture({ + data: rgba, + format: "rgba8unorm", + width, + height, + sampler: { minFilter: "linear", magFilter: "linear" }, + }); + let mask: Texture | undefined; + let byteLength = rgba.byteLength; + if (bands === 4) { + const maskBand = data.subarray(3 * height * width, 4 * height * width); + mask = device.createTexture({ + data: maskBand, + format: "r8unorm", + width, + height, + sampler: { minFilter: "nearest", magFilter: "nearest" }, + }); + byteLength += maskBand.byteLength; + } + return { width, height, byteLength, texture, mask }; +} + +function renderTile(data: TileData): RenderTileResult { + const pipeline: RasterModule[] = [ + { module: CreateTexture, props: { textureName: data.texture } }, + ]; + if (data.mask) { + pipeline.push({ + module: MaskTexture, + props: { maskTexture: data.mask }, + }); + } + return { renderPipeline: pipeline }; +} + export default function App() { const mapRef = useRef(null); const [debug, setDebug] = useState(false); @@ -48,7 +161,6 @@ export default function App() { const [panelOpen, setPanelOpen] = useState(true); const [descriptor, setDescriptor] = useState(); const [error, setError] = useState(); - void descriptor; useEffect(() => { const controller = new AbortController(); @@ -93,6 +205,17 @@ export default function App() { return () => controller.abort(); }, []); + const layers = descriptor + ? [ + new RasterTileLayer({ + id: "titiler-raster", + tilesetDescriptor: descriptor, + getTileData, + renderTile, + }), + ] + : []; + return (

- +
Date: Fri, 24 Apr 2026 14:18:35 -0400 Subject: [PATCH 06/10] feat: wire debug toggle to RasterTileLayer in titiler-cog example Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/titiler-cog/src/App.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/examples/titiler-cog/src/App.tsx b/examples/titiler-cog/src/App.tsx index 97d8bf9c..cc3f81de 100644 --- a/examples/titiler-cog/src/App.tsx +++ b/examples/titiler-cog/src/App.tsx @@ -212,6 +212,8 @@ export default function App() { tilesetDescriptor: descriptor, getTileData, renderTile, + debug, + debugOpacity, }), ] : []; From 309b3b5e1df8dc48a10795a19dc2eea4a6fff303 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Fri, 24 Apr 2026 14:26:50 -0400 Subject: [PATCH 07/10] refactor(titiler-cog): address code review - Extract titiler helpers (URLs, buildDescriptor, decode, getTileData, renderTile) into src/titiler.ts. App.tsx now only handles the React UI and layer wiring. - Drop unused @developmentseed/proj dep; add peer dep @deck.gl/mesh-layers. - Replace `parsed.data as Uint8Array` cast with an instanceof guard. - Note texture-destroy semantics in getTileData docstring. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/titiler-cog/package.json | 2 +- examples/titiler-cog/src/App.tsx | 152 ++------------------------ examples/titiler-cog/src/titiler.ts | 159 ++++++++++++++++++++++++++++ pnpm-lock.yaml | 6 +- 4 files changed, 173 insertions(+), 146 deletions(-) create mode 100644 examples/titiler-cog/src/titiler.ts diff --git a/examples/titiler-cog/package.json b/examples/titiler-cog/package.json index 6a694d9e..e8181963 100644 --- a/examples/titiler-cog/package.json +++ b/examples/titiler-cog/package.json @@ -14,9 +14,9 @@ "@deck.gl/geo-layers": "^9.3.0", "@deck.gl/layers": "^9.3.0", "@deck.gl/mapbox": "^9.3.0", + "@deck.gl/mesh-layers": "^9.3.0", "@developmentseed/deck.gl-raster": "workspace:^", "@developmentseed/morecantile": "workspace:^", - "@developmentseed/proj": "workspace:^", "@luma.gl/core": "9.3.2", "maplibre-gl": "^5.19.0", "npyjs": "^1.1.0", diff --git a/examples/titiler-cog/src/App.tsx b/examples/titiler-cog/src/App.tsx index cc3f81de..62366473 100644 --- a/examples/titiler-cog/src/App.tsx +++ b/examples/titiler-cog/src/App.tsx @@ -1,29 +1,20 @@ -import type { _TileLoadProps as TileLoadProps } from "@deck.gl/geo-layers"; import type { MapboxOverlayProps } from "@deck.gl/mapbox"; import { MapboxOverlay } from "@deck.gl/mapbox"; -import type { - GetTileDataOptions, - MinimalTileData, - RasterModule, - RenderTileResult, - TilesetDescriptor, -} from "@developmentseed/deck.gl-raster"; -import { - RasterTileLayer, - TileMatrixSetAdaptor, -} from "@developmentseed/deck.gl-raster"; -import { - CreateTexture, - MaskTexture, -} from "@developmentseed/deck.gl-raster/gpu-modules"; +import type { TilesetDescriptor } from "@developmentseed/deck.gl-raster"; +import { RasterTileLayer } from "@developmentseed/deck.gl-raster"; import type { TileMatrixSet } from "@developmentseed/morecantile"; -import type { Texture } from "@luma.gl/core"; import "maplibre-gl/dist/maplibre-gl.css"; -import npyjs from "npyjs"; -import proj4 from "proj4"; import { useEffect, useRef, useState } from "react"; import type { MapRef } from "react-map-gl/maplibre"; import { Map as MaplibreMap, useControl } from "react-map-gl/maplibre"; +import type { InfoResponse, TileData } from "./titiler"; +import { + buildDescriptor, + COG_URL, + getTileData, + renderTile, + TITILER_BASE, +} from "./titiler"; function DeckGLOverlay(props: MapboxOverlayProps) { const overlay = useControl(() => new MapboxOverlay(props)); @@ -31,129 +22,6 @@ function DeckGLOverlay(props: MapboxOverlayProps) { return null; } -const COG_URL = - "https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/18/T/WL/2026/1/S2B_18TWL_20260101_0_L2A/TCI.tif"; -const TITILER_BASE = "https://titiler.xyz"; - -type InfoResponse = { - bounds: [number, number, number, number]; // WGS84 [w, s, e, n] - band_descriptions?: [string, Record][]; - dtype?: string; - [key: string]: unknown; -}; - -function buildDescriptor(tms: TileMatrixSet): TilesetDescriptor { - const converter = proj4("EPSG:3857", "EPSG:4326"); - const projectTo4326 = (x: number, y: number) => - converter.forward<[number, number]>([x, y], false); - const projectFrom4326 = (x: number, y: number) => - converter.inverse<[number, number]>([x, y], false); - const identity = (x: number, y: number): [number, number] => [x, y]; - return new TileMatrixSetAdaptor(tms, { - projectTo3857: identity, - projectFrom3857: identity, - projectTo4326, - projectFrom4326, - }); -} - -type TileData = MinimalTileData & { - texture: Texture; - mask?: Texture; -}; - -/** - * Repack a band-separate uint8 buffer of shape [B, H, W] into an - * interleaved RGBA uint8 buffer of length H*W*4. Bands 0-2 go to R/G/B; - * alpha is always 255 (the 4th titiler band is a mask, handled separately). - */ -function repackToRGBA( - bandSeparate: Uint8Array, - height: number, - width: number, -): Uint8Array { - const pixelCount = height * width; - const rgba = new Uint8Array(pixelCount * 4); - const bandOffset0 = 0; - const bandOffset1 = pixelCount; - const bandOffset2 = 2 * pixelCount; - for (let i = 0; i < pixelCount; i++) { - rgba[i * 4] = bandSeparate[bandOffset0 + i]!; - rgba[i * 4 + 1] = bandSeparate[bandOffset1 + i]!; - rgba[i * 4 + 2] = bandSeparate[bandOffset2 + i]!; - rgba[i * 4 + 3] = 255; - } - return rgba; -} - -function tileNpyUrl(x: number, y: number, z: number): string { - return `${TITILER_BASE}/cog/tiles/WebMercatorQuad/${z}/${x}/${y}.npy?url=${encodeURIComponent(COG_URL)}`; -} - -async function getTileData( - tile: TileLoadProps, - options: GetTileDataOptions, -): Promise { - const { device, signal } = options; - const { x, y, z } = tile.index; - const response = await fetch(tileNpyUrl(x, y, z), { signal }); - if (!response.ok) { - throw new Error( - `titiler tile ${z}/${x}/${y} ${response.status}: ${await response.text()}`, - ); - } - const buffer = await response.arrayBuffer(); - const parsed = await new npyjs().load(buffer); - if (parsed.dtype !== "u1") { - throw new Error(`Expected uint8 (u1) npy, got dtype=${parsed.dtype}`); - } - if (parsed.shape.length !== 3) { - throw new Error( - `Expected shape [B, H, W], got [${parsed.shape.join(", ")}]`, - ); - } - const [bands, height, width] = parsed.shape as [number, number, number]; - if (bands !== 3 && bands !== 4) { - throw new Error(`Expected 3 or 4 bands, got ${bands}`); - } - const data = parsed.data as Uint8Array; - const rgba = repackToRGBA(data, height, width); - const texture = device.createTexture({ - data: rgba, - format: "rgba8unorm", - width, - height, - sampler: { minFilter: "linear", magFilter: "linear" }, - }); - let mask: Texture | undefined; - let byteLength = rgba.byteLength; - if (bands === 4) { - const maskBand = data.subarray(3 * height * width, 4 * height * width); - mask = device.createTexture({ - data: maskBand, - format: "r8unorm", - width, - height, - sampler: { minFilter: "nearest", magFilter: "nearest" }, - }); - byteLength += maskBand.byteLength; - } - return { width, height, byteLength, texture, mask }; -} - -function renderTile(data: TileData): RenderTileResult { - const pipeline: RasterModule[] = [ - { module: CreateTexture, props: { textureName: data.texture } }, - ]; - if (data.mask) { - pipeline.push({ - module: MaskTexture, - props: { maskTexture: data.mask }, - }); - } - return { renderPipeline: pipeline }; -} - export default function App() { const mapRef = useRef(null); const [debug, setDebug] = useState(false); diff --git a/examples/titiler-cog/src/titiler.ts b/examples/titiler-cog/src/titiler.ts new file mode 100644 index 00000000..2951af0c --- /dev/null +++ b/examples/titiler-cog/src/titiler.ts @@ -0,0 +1,159 @@ +import type { _TileLoadProps as TileLoadProps } from "@deck.gl/geo-layers"; +import type { + GetTileDataOptions, + MinimalTileData, + RasterModule, + RenderTileResult, + TilesetDescriptor, +} from "@developmentseed/deck.gl-raster"; +import { TileMatrixSetAdaptor } from "@developmentseed/deck.gl-raster"; +import { + CreateTexture, + MaskTexture, +} from "@developmentseed/deck.gl-raster/gpu-modules"; +import type { TileMatrixSet } from "@developmentseed/morecantile"; +import type { Texture } from "@luma.gl/core"; +import npyjs from "npyjs"; +import proj4 from "proj4"; + +export const COG_URL = + "https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/18/T/WL/2026/1/S2B_18TWL_20260101_0_L2A/TCI.tif"; +export const TITILER_BASE = "https://titiler.xyz"; + +export type InfoResponse = { + /** WGS84 [west, south, east, north]. */ + bounds: [number, number, number, number]; + band_descriptions?: [string, Record][]; + dtype?: string; + [key: string]: unknown; +}; + +export type TileData = MinimalTileData & { + texture: Texture; + mask?: Texture; +}; + +/** + * Build a TilesetDescriptor for a WebMercatorQuad tile pyramid. The CRS of + * WebMercatorQuad is EPSG:3857, so the to/from 3857 projections are identity; + * to/from 4326 use proj4. + */ +export function buildDescriptor(tms: TileMatrixSet): TilesetDescriptor { + const converter = proj4("EPSG:3857", "EPSG:4326"); + const projectTo4326 = (x: number, y: number) => + converter.forward<[number, number]>([x, y], false); + const projectFrom4326 = (x: number, y: number) => + converter.inverse<[number, number]>([x, y], false); + const identity = (x: number, y: number): [number, number] => [x, y]; + return new TileMatrixSetAdaptor(tms, { + projectTo3857: identity, + projectFrom3857: identity, + projectTo4326, + projectFrom4326, + }); +} + +/** + * Repack a band-separate uint8 buffer of shape [B, H, W] into an interleaved + * RGBA uint8 buffer of length H*W*4. Bands 0-2 go to R/G/B; alpha is always + * 255 (the 4th titiler band is a mask, handled separately). + */ +function repackToRGBA( + bandSeparate: Uint8Array, + height: number, + width: number, +): Uint8Array { + const pixelCount = height * width; + const rgba = new Uint8Array(pixelCount * 4); + const bandOffset1 = pixelCount; + const bandOffset2 = 2 * pixelCount; + for (let i = 0; i < pixelCount; i++) { + rgba[i * 4] = bandSeparate[i]!; + rgba[i * 4 + 1] = bandSeparate[bandOffset1 + i]!; + rgba[i * 4 + 2] = bandSeparate[bandOffset2 + i]!; + rgba[i * 4 + 3] = 255; + } + return rgba; +} + +function tileNpyUrl(x: number, y: number, z: number): string { + return `${TITILER_BASE}/cog/tiles/WebMercatorQuad/${z}/${x}/${y}.npy?url=${encodeURIComponent(COG_URL)}`; +} + +/** + * Fetch one titiler .npy tile, decode it, and upload the resulting RGBA + * texture (plus a separate mask texture when the response has 4 bands). + * + * Note: textures are not explicitly destroyed when the tile cache evicts them. + * This matches the COGLayer pattern in this repo; fine for example/demo use, + * but a long-running app should wire an onTileUnload callback through the + * underlying TileLayer and call `.destroy()` on both textures. + */ +export async function getTileData( + tile: TileLoadProps, + options: GetTileDataOptions, +): Promise { + const { device, signal } = options; + const { x, y, z } = tile.index; + const response = await fetch(tileNpyUrl(x, y, z), { signal }); + if (!response.ok) { + throw new Error( + `titiler tile ${z}/${x}/${y} ${response.status}: ${await response.text()}`, + ); + } + const buffer = await response.arrayBuffer(); + const parsed = await new npyjs().load(buffer); + if (parsed.dtype !== "u1") { + throw new Error(`Expected uint8 (u1) npy, got dtype=${parsed.dtype}`); + } + if (!(parsed.data instanceof Uint8Array)) { + throw new Error( + `Expected Uint8Array payload for dtype=u1, got ${parsed.data?.constructor?.name}`, + ); + } + if (parsed.shape.length !== 3) { + throw new Error( + `Expected shape [B, H, W], got [${parsed.shape.join(", ")}]`, + ); + } + const [bands, height, width] = parsed.shape as [number, number, number]; + if (bands !== 3 && bands !== 4) { + throw new Error(`Expected 3 or 4 bands, got ${bands}`); + } + const data = parsed.data; + const rgba = repackToRGBA(data, height, width); + const texture = device.createTexture({ + data: rgba, + format: "rgba8unorm", + width, + height, + sampler: { minFilter: "linear", magFilter: "linear" }, + }); + let mask: Texture | undefined; + let byteLength = rgba.byteLength; + if (bands === 4) { + const maskBand = data.subarray(3 * height * width, 4 * height * width); + mask = device.createTexture({ + data: maskBand, + format: "r8unorm", + width, + height, + sampler: { minFilter: "nearest", magFilter: "nearest" }, + }); + byteLength += maskBand.byteLength; + } + return { width, height, byteLength, texture, mask }; +} + +export function renderTile(data: TileData): RenderTileResult { + const pipeline: RasterModule[] = [ + { module: CreateTexture, props: { textureName: data.texture } }, + ]; + if (data.mask) { + pipeline.push({ + module: MaskTexture, + props: { maskTexture: data.mask }, + }); + } + return { renderPipeline: pipeline }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 47747371..07b90d77 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -464,15 +464,15 @@ importers: '@deck.gl/mapbox': specifier: ^9.3.0 version: 9.3.0(@deck.gl/core@9.3.0)(@luma.gl/core@9.3.2)(@math.gl/web-mercator@4.1.0) + '@deck.gl/mesh-layers': + specifier: ^9.3.0 + version: 9.3.0(@deck.gl/core@9.3.0)(@loaders.gl/core@4.3.4)(@luma.gl/core@9.3.2)(@luma.gl/engine@9.3.2(@luma.gl/core@9.3.2)(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.2)))(@luma.gl/gltf@9.3.2(@luma.gl/core@9.3.2)(@luma.gl/engine@9.3.2(@luma.gl/core@9.3.2)(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.2)))(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.2)))(@luma.gl/shadertools@9.3.3(@luma.gl/core@9.3.2)) '@developmentseed/deck.gl-raster': specifier: workspace:^ version: link:../../packages/deck.gl-raster '@developmentseed/morecantile': specifier: workspace:^ version: link:../../packages/morecantile - '@developmentseed/proj': - specifier: workspace:^ - version: link:../../packages/proj '@luma.gl/core': specifier: ^9.3.2 version: 9.3.2 From 57758b00f1dc2c0a7d2a529398ba52d29a43ede7 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Fri, 24 Apr 2026 14:30:38 -0400 Subject: [PATCH 08/10] fix(titiler-cog): use tilejson for WGS84 bounds; inject boundingBox on TMS Two runtime fixes: 1. /cog/info returns bounds in the COG's native CRS (UTM for Sentinel-2), which MapLibre's fitBounds rejects with "Invalid LngLat latitude value". Switch to /cog/tilejson.json, whose bounds are in WGS84 per the TileJSON spec. Also thread the tilejson's minzoom/maxzoom onto the layer so we don't request tiles past the COG's resolution. 2. Titiler's /tileMatrixSets/WebMercatorQuad response omits the optional boundingBox field, which TileMatrixSetAdaptor requires for viewport culling. Inject one built from the tilejson's geographic bounds projected to EPSG:3857. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/titiler-cog/src/App.tsx | 57 ++++++++++++++++++----------- examples/titiler-cog/src/titiler.ts | 35 +++++++++++++++--- 2 files changed, 64 insertions(+), 28 deletions(-) diff --git a/examples/titiler-cog/src/App.tsx b/examples/titiler-cog/src/App.tsx index 62366473..ce8fe497 100644 --- a/examples/titiler-cog/src/App.tsx +++ b/examples/titiler-cog/src/App.tsx @@ -7,7 +7,7 @@ import "maplibre-gl/dist/maplibre-gl.css"; import { useEffect, useRef, useState } from "react"; import type { MapRef } from "react-map-gl/maplibre"; import { Map as MaplibreMap, useControl } from "react-map-gl/maplibre"; -import type { InfoResponse, TileData } from "./titiler"; +import type { TileData, TileJSON } from "./titiler"; import { buildDescriptor, COG_URL, @@ -28,23 +28,29 @@ export default function App() { const [debugOpacity, setDebugOpacity] = useState(0.25); const [panelOpen, setPanelOpen] = useState(true); const [descriptor, setDescriptor] = useState(); + const [zoomRange, setZoomRange] = useState< + { minZoom: number; maxZoom: number } | undefined + >(); const [error, setError] = useState(); useEffect(() => { const controller = new AbortController(); (async () => { try { - const [infoRes, tmsRes] = await Promise.all([ - fetch(`${TITILER_BASE}/cog/info?url=${encodeURIComponent(COG_URL)}`, { - signal: controller.signal, - }), + const [tilejsonRes, tmsRes] = await Promise.all([ + fetch( + `${TITILER_BASE}/cog/tilejson.json?url=${encodeURIComponent( + COG_URL, + )}&tileMatrixSetId=WebMercatorQuad`, + { signal: controller.signal }, + ), fetch(`${TITILER_BASE}/tileMatrixSets/WebMercatorQuad`, { signal: controller.signal, }), ]); - if (!infoRes.ok) { + if (!tilejsonRes.ok) { throw new Error( - `cog/info ${infoRes.status}: ${await infoRes.text()}`, + `tilejson ${tilejsonRes.status}: ${await tilejsonRes.text()}`, ); } if (!tmsRes.ok) { @@ -52,10 +58,14 @@ export default function App() { `tileMatrixSets ${tmsRes.status}: ${await tmsRes.text()}`, ); } - const info = (await infoRes.json()) as InfoResponse; + const tilejson = (await tilejsonRes.json()) as TileJSON; const tms = (await tmsRes.json()) as TileMatrixSet; - setDescriptor(buildDescriptor(tms)); - const [w, s, e, n] = info.bounds; + setDescriptor(buildDescriptor(tms, tilejson.bounds)); + setZoomRange({ + minZoom: tilejson.minzoom, + maxZoom: tilejson.maxzoom, + }); + const [w, s, e, n] = tilejson.bounds; mapRef.current?.fitBounds( [ [w, s], @@ -73,18 +83,21 @@ export default function App() { return () => controller.abort(); }, []); - const layers = descriptor - ? [ - new RasterTileLayer({ - id: "titiler-raster", - tilesetDescriptor: descriptor, - getTileData, - renderTile, - debug, - debugOpacity, - }), - ] - : []; + const layers = + descriptor && zoomRange + ? [ + new RasterTileLayer({ + id: "titiler-raster", + tilesetDescriptor: descriptor, + getTileData, + renderTile, + minZoom: zoomRange.minZoom, + maxZoom: zoomRange.maxZoom, + debug, + debugOpacity, + }), + ] + : []; return (
diff --git a/examples/titiler-cog/src/titiler.ts b/examples/titiler-cog/src/titiler.ts index 2951af0c..3bd1d5be 100644 --- a/examples/titiler-cog/src/titiler.ts +++ b/examples/titiler-cog/src/titiler.ts @@ -20,11 +20,19 @@ export const COG_URL = "https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/18/T/WL/2026/1/S2B_18TWL_20260101_0_L2A/TCI.tif"; export const TITILER_BASE = "https://titiler.xyz"; -export type InfoResponse = { - /** WGS84 [west, south, east, north]. */ +/** + * Subset of the TileJSON response from titiler's + * `/cog/tilejson.json?url=...&tileMatrixSetId=WebMercatorQuad`. + * + * `bounds` is in WGS84 — unlike `/cog/info`, which returns bounds in the + * COG's native CRS (e.g. UTM for Sentinel-2) that can't be handed to a + * MapLibre map directly. + */ +export type TileJSON = { bounds: [number, number, number, number]; - band_descriptions?: [string, Record][]; - dtype?: string; + minzoom: number; + maxzoom: number; + tiles: string[]; [key: string]: unknown; }; @@ -37,15 +45,30 @@ export type TileData = MinimalTileData & { * Build a TilesetDescriptor for a WebMercatorQuad tile pyramid. The CRS of * WebMercatorQuad is EPSG:3857, so the to/from 3857 projections are identity; * to/from 4326 use proj4. + * + * `geographicBounds` (WGS84 [w, s, e, n]) is required: titiler's + * `/tileMatrixSets/WebMercatorQuad` response omits the optional + * `boundingBox`, but `TileMatrixSetAdaptor` needs one for viewport culling. + * We project the dataset's geographic bounds to EPSG:3857 and attach them. */ -export function buildDescriptor(tms: TileMatrixSet): TilesetDescriptor { +export function buildDescriptor( + tms: TileMatrixSet, + geographicBounds: [number, number, number, number], +): TilesetDescriptor { const converter = proj4("EPSG:3857", "EPSG:4326"); const projectTo4326 = (x: number, y: number) => converter.forward<[number, number]>([x, y], false); const projectFrom4326 = (x: number, y: number) => converter.inverse<[number, number]>([x, y], false); const identity = (x: number, y: number): [number, number] => [x, y]; - return new TileMatrixSetAdaptor(tms, { + const [w, s, e, n] = geographicBounds; + const lowerLeft = projectFrom4326(w, s); + const upperRight = projectFrom4326(e, n); + const tmsWithBbox: TileMatrixSet = { + ...tms, + boundingBox: { lowerLeft, upperRight, crs: tms.crs }, + }; + return new TileMatrixSetAdaptor(tmsWithBbox, { projectTo3857: identity, projectFrom3857: identity, projectTo4326, From 6f8b2d9e25227beb526a83c734ae02caf659e27a Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Fri, 24 Apr 2026 14:32:35 -0400 Subject: [PATCH 09/10] fix(titiler-cog): use path-based tileMatrixSetId in tilejson URL Titiler's route is /cog/{tileMatrixSetId}/tilejson.json, not /cog/tilejson.json?tileMatrixSetId=...; the latter 404s. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/titiler-cog/src/App.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/titiler-cog/src/App.tsx b/examples/titiler-cog/src/App.tsx index ce8fe497..c6e2d3e9 100644 --- a/examples/titiler-cog/src/App.tsx +++ b/examples/titiler-cog/src/App.tsx @@ -39,9 +39,9 @@ export default function App() { try { const [tilejsonRes, tmsRes] = await Promise.all([ fetch( - `${TITILER_BASE}/cog/tilejson.json?url=${encodeURIComponent( + `${TITILER_BASE}/cog/WebMercatorQuad/tilejson.json?url=${encodeURIComponent( COG_URL, - )}&tileMatrixSetId=WebMercatorQuad`, + )}`, { signal: controller.signal }, ), fetch(`${TITILER_BASE}/tileMatrixSets/WebMercatorQuad`, { From e861abb520fc92fc95948cd53555c46db19bfb4e Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Fri, 24 Apr 2026 14:40:15 -0400 Subject: [PATCH 10/10] modify import --- examples/titiler-cog/src/titiler.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/titiler-cog/src/titiler.ts b/examples/titiler-cog/src/titiler.ts index 3bd1d5be..577551e2 100644 --- a/examples/titiler-cog/src/titiler.ts +++ b/examples/titiler-cog/src/titiler.ts @@ -13,7 +13,7 @@ import { } from "@developmentseed/deck.gl-raster/gpu-modules"; import type { TileMatrixSet } from "@developmentseed/morecantile"; import type { Texture } from "@luma.gl/core"; -import npyjs from "npyjs"; +import { load } from "npyjs"; import proj4 from "proj4"; export const COG_URL = @@ -125,7 +125,7 @@ export async function getTileData( ); } const buffer = await response.arrayBuffer(); - const parsed = await new npyjs().load(buffer); + const parsed = await load(buffer); if (parsed.dtype !== "u1") { throw new Error(`Expected uint8 (u1) npy, got dtype=${parsed.dtype}`); }