-
Notifications
You must be signed in to change notification settings - Fork 21
Docs add deckgl raster tutorial #530
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
79cadc9
bc12380
116213d
5c014d4
59c2554
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,164 @@ | ||
| <!doctype html> | ||
| <html> | ||
| <head> | ||
| <meta charset="utf-8" /> | ||
| <title>Planetary Computer · deck.gl-raster (no build)</title> | ||
| <link href="https://esm.sh/maplibre-gl@4.7.1/dist/maplibre-gl.css" rel="stylesheet" /> | ||
| <style> | ||
| html, body { margin: 0; height: 100%; font-family: system-ui, sans-serif; } | ||
| #map { position: absolute; inset: 0; } | ||
| #panel { | ||
| position: absolute; top: 16px; left: 16px; z-index: 2; width: 240px; | ||
| background: #ffffff; border-radius: 10px; padding: 16px 18px; | ||
| box-shadow: 0 2px 14px #0003; font-size: 13px; color: #1a1a1a; | ||
| } | ||
| #panel h1 { font-size: 15px; margin: 0 0 6px; } | ||
| #panel .muted { color: #666; line-height: 1.4; } | ||
| #panel label { display: block; margin-top: 14px; font-weight: 600; } | ||
| #panel input[type=range] { width: 100%; } | ||
| #scene { font-family: ui-monospace, monospace; font-size: 11px; word-break: break-all; } | ||
| </style> | ||
|
|
||
| <!-- No build step: an import map resolves every dependency from a CDN. | ||
| All @deck.gl/* and @luma.gl/core MUST share one version (mismatched | ||
| patch versions throw "deck.gl - multiple versions detected"). The | ||
| deck.gl-geotiff entry marks deck/luma/geotiff as `external` so they | ||
| resolve to the singletons above instead of being bundled again. --> | ||
| <script type="importmap"> | ||
| { | ||
| "imports": { | ||
| "maplibre-gl": "https://esm.sh/maplibre-gl@4.7.1", | ||
| "@deck.gl/core": "https://esm.sh/@deck.gl/core@9.3.2", | ||
| "@deck.gl/layers": "https://esm.sh/@deck.gl/layers@9.3.2", | ||
| "@deck.gl/geo-layers": "https://esm.sh/@deck.gl/geo-layers@9.3.2", | ||
| "@deck.gl/mesh-layers": "https://esm.sh/@deck.gl/mesh-layers@9.3.2", | ||
| "@deck.gl/mapbox": "https://esm.sh/@deck.gl/mapbox@9.3.2?external=@deck.gl/core,maplibre-gl", | ||
| "@luma.gl/core": "https://esm.sh/@luma.gl/core@9.3.2", | ||
| "@developmentseed/geotiff": "https://esm.sh/@developmentseed/geotiff@0.7.0", | ||
| "@developmentseed/deck.gl-raster/gpu-modules": "https://esm.sh/@developmentseed/deck.gl-raster@0.7.0/gpu-modules?external=@deck.gl/core,@luma.gl/core", | ||
| "@developmentseed/deck.gl-geotiff": "https://esm.sh/@developmentseed/deck.gl-geotiff@0.7.0?external=@deck.gl/core,@deck.gl/layers,@deck.gl/geo-layers,@deck.gl/mesh-layers,@luma.gl/core,@developmentseed/geotiff" | ||
| } | ||
| } | ||
| </script> | ||
| </head> | ||
| <body> | ||
| <div id="map"></div> | ||
| <div id="panel"> | ||
| <h1>NAIP over Portland</h1> | ||
| <div class="muted">A Cloud Optimized GeoTIFF, decoded and reprojected in your browser with <b>deck.gl-raster</b>. No tile server.</div> | ||
| <label>Imagery opacity</label> | ||
| <input id="opacity" type="range" min="0" max="100" value="100" /> | ||
| <label>Scene</label> | ||
| <div id="scene" class="muted">searching…</div> | ||
| </div> | ||
|
|
||
| <script type="module"> | ||
| import maplibregl from "maplibre-gl"; | ||
| import { MapboxOverlay } from "@deck.gl/mapbox"; | ||
| import { COGLayer } from "@developmentseed/deck.gl-geotiff"; | ||
| import { DecoderPool } from "@developmentseed/geotiff"; | ||
| import { CreateTexture } from "@developmentseed/deck.gl-raster/gpu-modules"; | ||
|
|
||
| const STAC = "https://planetarycomputer.microsoft.com/api/stac/v1"; | ||
| const SIGN = "https://planetarycomputer.microsoft.com/api/sas/v1/sign?href="; | ||
|
|
||
| // NAIP COGs are 4-band (R, G, B, near-infrared). The default render pipeline | ||
| // would treat band 4 as alpha and paint the imagery transparent where NIR is | ||
| // high (e.g. dense vegetation). Force alpha to 1 with a custom shader module | ||
| // and a two-step pipeline: upload the texture, then override alpha. | ||
| const SetAlpha1 = { | ||
| name: "set-alpha-1", | ||
| inject: { "fs:DECKGL_FILTER_COLOR": `color = vec4(color.rgb, 1.0);` }, | ||
| }; | ||
|
|
||
| function renderRGB(tileData) { | ||
| return { | ||
| renderPipeline: [ | ||
| { module: CreateTexture, props: { textureName: tileData.texture } }, | ||
| { module: SetAlpha1 }, | ||
| ], | ||
| }; | ||
| } | ||
|
|
||
| const map = new maplibregl.Map({ | ||
| container: "map", | ||
| style: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json", | ||
| center: [-122.62, 45.52], | ||
| zoom: 11, | ||
| }); | ||
|
|
||
| // size: 0 keeps decoding on the main thread. The default pool spawns a | ||
| // Web Worker from the package URL, which browsers block when that URL is | ||
| // cross-origin (a CDN). Main-thread decoding sidesteps that for a | ||
| // single-file app; add a same-origin worker if you need the throughput. | ||
| const pool = new DecoderPool({ size: 0 }); | ||
| const overlay = new MapboxOverlay({ interleaved: true, layers: [] }); | ||
| map.addControl(overlay); | ||
|
|
||
| let opacity = 1; | ||
| let geotiff = null; | ||
| let beforeId = null; // draw imagery beneath the basemap's labels | ||
| let styleReady = false; | ||
| let fitted = false; | ||
|
|
||
| function render() { | ||
| if (!geotiff || !styleReady) return; | ||
| overlay.setProps({ | ||
| layers: [ | ||
| new COGLayer({ | ||
| id: "naip", | ||
| geotiff, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When you pass in a geotiff as-is, deck.gl-raster uses the "default" styling it can infer. But in this case, the default styling it infers is incorrect. It sees 4 bands but it doesn't have a way to know that the fourth band refers to near-infrared data, instead of an alpha band. You can use this, which you pass into |
||
| pool, | ||
| opacity, | ||
| beforeId, | ||
| renderTile: renderRGB, | ||
| // Frame the map to the COG's own bounds once it has loaded. | ||
| onGeoTIFFLoad: (tiff, { geographicBounds }) => { | ||
| if (fitted) return; | ||
| fitted = true; | ||
| const { west, south, east, north } = geographicBounds; | ||
| map.fitBounds([[west, south], [east, north]], { padding: 40, duration: 0 }); | ||
| }, | ||
| }), | ||
| ], | ||
| }); | ||
| } | ||
|
|
||
| document.getElementById("opacity").addEventListener("input", (e) => { | ||
| opacity = e.target.value / 100; | ||
| render(); | ||
| }); | ||
|
|
||
| map.on("load", () => { | ||
| // Slot the imagery just above road/building geometry but beneath labels. | ||
| // In the positron style, "boundary_country_outline" sits right below the | ||
| // first place-label symbol, giving a much better stacking than the first | ||
| // symbol layer (waterway_label), which leaves basemap features on top. | ||
| beforeId = map.getLayer("boundary_country_outline") | ||
| ? "boundary_country_outline" | ||
| : map.getStyle().layers.find((l) => l.type === "symbol")?.id; | ||
| styleReady = true; | ||
| render(); | ||
| }); | ||
|
|
||
| // Find a NAIP scene over Portland, then sign its asset href. The Planetary | ||
| // Computer signing endpoint is public. No key, no backend. | ||
| const search = await fetch(`${STAC}/search`, { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify({ | ||
| collections: ["naip"], | ||
| bbox: [-122.70, 45.50, -122.55, 45.57], | ||
| datetime: "2022-01-01/2023-01-01", | ||
| limit: 1, | ||
| }), | ||
| }).then((r) => r.json()); | ||
|
|
||
| const item = search.features[0]; | ||
| const signed = await fetch(SIGN + encodeURIComponent(item.assets.image.href)).then((r) => r.json()); | ||
| geotiff = signed.href; | ||
| document.getElementById("scene").textContent = item.id; | ||
| render(); | ||
| </script> | ||
| </body> | ||
| </html> | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,182 @@ | ||
| # Rendering Planetary Computer rasters in the browser with deck.gl-raster | ||
|
|
||
| [deck.gl-raster](https://github.com/developmentseed/deck.gl-raster) renders Cloud Optimized GeoTIFFs directly in the browser. Its `COGLayer` reads the COG header over HTTP, then streams only the tiles visible in the current viewport, decodes them client-side, reprojects, and renders in WebGL2. No tile server, no intermediate downloads. Like [Lonboard](./lonboard.md), it's built on deck.gl, but targets standalone web apps in TypeScript rather than Jupyter notebooks in Python. | ||
|
|
||
| The whole thing fits in **one HTML file with no build step**. The complete example is committed alongside this tutorial at [`deckgl-raster-example/index.html`](deckgl-raster-example/index.html). Save it locally and open it in a browser, or follow along below. | ||
|
|
||
| ## No build: an import map | ||
|
|
||
| Instead of npm and a bundler, an [import map](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap) resolves every dependency from a CDN ([esm.sh](https://esm.sh)). Two rules make it work: | ||
|
|
||
| - Every `@deck.gl/*` and `@luma.gl/core` entry must be the **same version**, since mismatched patch versions throw `deck.gl - multiple versions detected`. | ||
| - The `deck.gl-geotiff` entry marks deck, luma, and geotiff as `external` so they resolve to the singletons above rather than being bundled a second time. | ||
|
Comment on lines
+11
to
+12
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yes these are really important things to note! We should add this to the upstream deck.gl-raster docs as well. |
||
|
|
||
| ```html | ||
| <script type="importmap"> | ||
| { | ||
| "imports": { | ||
| "maplibre-gl": "https://esm.sh/maplibre-gl@4.7.1", | ||
| "@deck.gl/core": "https://esm.sh/@deck.gl/core@9.3.2", | ||
| "@deck.gl/layers": "https://esm.sh/@deck.gl/layers@9.3.2", | ||
| "@deck.gl/geo-layers": "https://esm.sh/@deck.gl/geo-layers@9.3.2", | ||
| "@deck.gl/mesh-layers": "https://esm.sh/@deck.gl/mesh-layers@9.3.2", | ||
| "@deck.gl/mapbox": "https://esm.sh/@deck.gl/mapbox@9.3.2?external=@deck.gl/core,maplibre-gl", | ||
| "@luma.gl/core": "https://esm.sh/@luma.gl/core@9.3.2", | ||
| "@developmentseed/geotiff": "https://esm.sh/@developmentseed/geotiff@0.7.0", | ||
| "@developmentseed/deck.gl-raster/gpu-modules": "https://esm.sh/@developmentseed/deck.gl-raster@0.7.0/gpu-modules?external=@deck.gl/core,@luma.gl/core", | ||
| "@developmentseed/deck.gl-geotiff": "https://esm.sh/@developmentseed/deck.gl-geotiff@0.7.0?external=@deck.gl/core,@deck.gl/layers,@deck.gl/geo-layers,@deck.gl/mesh-layers,@luma.gl/core,@developmentseed/geotiff" | ||
| } | ||
| } | ||
| </script> | ||
| ``` | ||
|
|
||
| ## Sign Planetary Computer URLs in the browser | ||
|
|
||
| The Planetary Computer signing endpoint is public, with no subscription key and no backend proxy. Search the STAC API for a scene, then sign the asset href client-side: | ||
|
|
||
| ```js | ||
| const STAC = "https://planetarycomputer.microsoft.com/api/stac/v1"; | ||
| const SIGN = "https://planetarycomputer.microsoft.com/api/sas/v1/sign?href="; | ||
|
|
||
| const search = await fetch(`${STAC}/search`, { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify({ | ||
| collections: ["naip"], | ||
| bbox: [-122.70, 45.50, -122.55, 45.57], | ||
| datetime: "2022-01-01/2023-01-01", | ||
| limit: 1, | ||
| }), | ||
| }).then((r) => r.json()); | ||
|
|
||
| const item = search.features[0]; | ||
| const signed = await fetch(SIGN + encodeURIComponent(item.assets.image.href)).then((r) => r.json()); | ||
| ``` | ||
|
|
||
| A signed SAS URL lasts ~60 minutes. Long-running sessions should re-sign before expiry. | ||
|
|
||
| ## Render a single NAIP COG | ||
|
|
||
| `COGLayer` takes the signed COG URL as its `geotiff` prop. One detail matters for a no-build app: the default tile decoder spawns a Web Worker from the package's own URL, and browsers block constructing a worker from a cross-origin (CDN) script. Passing a `DecoderPool` with `size: 0` decodes on the main thread and sidesteps that: | ||
|
|
||
| ```js | ||
| import { COGLayer } from "@developmentseed/deck.gl-geotiff"; | ||
| import { DecoderPool } from "@developmentseed/geotiff"; | ||
| import { CreateTexture } from "@developmentseed/deck.gl-raster/gpu-modules"; | ||
|
|
||
| // NAIP COGs are 4-band (R, G, B, near-infrared). The default render pipeline | ||
| // treats band 4 as alpha, which paints the imagery transparent where vegetation | ||
| // is bright (NIR is high). Force alpha to 1 with a custom shader module: | ||
| const SetAlpha1 = { | ||
| name: "set-alpha-1", | ||
| inject: { "fs:DECKGL_FILTER_COLOR": `color = vec4(color.rgb, 1.0);` }, | ||
| }; | ||
|
|
||
| function renderRGB(tileData) { | ||
| return { | ||
| renderPipeline: [ | ||
| { module: CreateTexture, props: { textureName: tileData.texture } }, | ||
| { module: SetAlpha1 }, | ||
| ], | ||
| }; | ||
| } | ||
|
|
||
| const pool = new DecoderPool({ size: 0 }); | ||
| const layer = new COGLayer({ | ||
| id: "naip", | ||
| geotiff: signed.href, | ||
| pool, | ||
| renderTile: renderRGB, | ||
| }); | ||
| ``` | ||
|
|
||
| As the user pans and zooms, `COGLayer` walks the overview pyramid in the COG header and fetches only the tiles the viewport needs. Watch the browser's Network tab and you'll see HTTP **range requests** (status `206`). The first reads the header, the rest pull individual tiles: | ||
|
|
||
| ```text | ||
| 206 bytes=0-65535 ← COG header | ||
| 206 bytes=1826859-2729978 ← tile | ||
| 206 bytes=2729987-3614432 ← tile | ||
| 206 bytes=3767796-4663213 ← tile | ||
| … | ||
| ``` | ||
|
|
||
| Nothing is downloaded that isn't rendered. | ||
|
|
||
| ## Add a MapLibre basemap | ||
|
|
||
| `@deck.gl/mapbox`'s `MapboxOverlay` adds `Deck` layers to a MapLibre map. With `interleaved: true`, deck draws into the same WebGL context as the basemap, so you can slot the imagery beneath the label layers with `beforeId` and keep place names legible on top. `COGLayer`'s `onGeoTIFFLoad` callback hands back the scene's bounds, so the map frames the COG once it loads: | ||
|
|
||
| ```js | ||
| import maplibregl from "maplibre-gl"; | ||
| import { MapboxOverlay } from "@deck.gl/mapbox"; | ||
|
|
||
| const map = new maplibregl.Map({ | ||
| container: "map", | ||
| style: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json", | ||
| center: [-122.62, 45.52], | ||
| zoom: 11, | ||
| }); | ||
|
|
||
| const overlay = new MapboxOverlay({ interleaved: true, layers: [] }); | ||
| map.addControl(overlay); | ||
|
|
||
| map.on("load", () => { | ||
| // Slot the imagery just above road/building geometry but beneath labels. | ||
| // In the positron style "boundary_country_outline" sits right below the | ||
| // first place-label symbol; fall back to the first symbol layer otherwise. | ||
| const beforeId = map.getLayer("boundary_country_outline") | ||
| ? "boundary_country_outline" | ||
| : map.getStyle().layers.find((l) => l.type === "symbol")?.id; | ||
|
|
||
| overlay.setProps({ | ||
| layers: [new COGLayer({ | ||
| id: "naip", | ||
| geotiff: signed.href, | ||
| pool, | ||
| opacity, | ||
| beforeId, | ||
| renderTile: renderRGB, | ||
| onGeoTIFFLoad: (tiff, { geographicBounds }) => { | ||
| const { west, south, east, north } = geographicBounds; | ||
| map.fitBounds([[west, south], [east, north]], { padding: 40 }); | ||
| }, | ||
| })], | ||
| }); | ||
| }); | ||
| ``` | ||
|
|
||
| The [committed example](deckgl-raster-example/index.html) wires this together with a small control panel: an opacity slider and the active scene id: | ||
|
|
||
| ```{image} images/deckgl-raster-full-app.png | ||
| :height: 460 | ||
| :name: deck.gl-raster NAIP over Portland with a MapLibre basemap | ||
| :class: no-scaled-link | ||
| ``` | ||
|
|
||
| ## Render multiple scenes | ||
|
|
||
| Bbox-search returns many items. Two patterns, depending on overlap and count: | ||
|
|
||
| **A handful of scenes** (up to roughly 20): sign each href and pass one `COGLayer` per scene. `overlay.setProps({ layers })` diffs them and only reloads what changed: | ||
|
|
||
| ```js | ||
| const layers = signedHrefs.map((href, i) => new COGLayer({ id: `naip-${i}`, geotiff: href, pool })); | ||
| overlay.setProps({ layers }); | ||
| ``` | ||
|
|
||
| The practical cost isn't browser memory but COG-header fetches: every `COGLayer` reads its scene's header even when the scene is off-screen. | ||
|
|
||
| **Many non-overlapping scenes** (a NAIP mosaic spanning a county or state): use [`MosaicLayer`](https://developmentseed.org/deck.gl-raster/api/deck-gl-geotiff/classes/MosaicLayer/) from the same package. It lazily instantiates a `COGLayer` only when its footprint intersects the current viewport, so off-screen scenes cost nothing. The [naip-mosaic example](https://developmentseed.org/deck.gl-raster/examples/naip-mosaic/) is a worked end-to-end demo. | ||
|
|
||
| For *multi-asset composites* of a single scene (Landsat or Sentinel-2 bands rendered together), reach for `MultiCOGLayer` instead — different use case from mosaicking many scenes. | ||
|
|
||
| ## Ship it | ||
|
|
||
| - **Token refresh.** Re-sign asset URLs before SAS tokens expire (~60 min) so long sessions don't break mid-map. | ||
| - **Throughput.** `size: 0` decodes on the main thread, which is fine for a few COGs. For heavier mosaics, give `DecoderPool` a `createWorker` factory backed by a *same-origin* worker so decoding moves off the main thread. | ||
| - **Failures.** Guard layer construction, since a 404 on one COG shouldn't break the whole map. | ||
| - **Going to production.** The import map is ideal for a demo or internal tool. For a shipped app, move to a bundler (Vite) so dependencies are pinned and served from your own origin. | ||
|
|
||
| ## When to use something else | ||
|
|
||
| deck.gl-raster is a renderer for standalone web apps. For interactive notebook work in Python, [Lonboard](./lonboard.md) wraps the same renderer. For pre-rendered tiles that any frontend can consume, see [titiler](https://developmentseed.org/titiler/). For pixel-level analysis in Python, reach for [async-geotiff](./async-geotiff.md). | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a nice example of how to have a single-page HTML file with deck.gl-raster! I'll adopt something like this in the deck.gl-raster docs