-
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
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,139 @@ | ||
| <!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-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"; | ||
|
|
||
| const STAC = "https://planetarycomputer.microsoft.com/api/stac/v1"; | ||
| const SIGN = "https://planetarycomputer.microsoft.com/api/sas/v1/sign?href="; | ||
|
|
||
| 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, | ||
| // 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", () => { | ||
| // Insert deck layers before the first label layer so basemap text | ||
| // (place names, roads) stays legible on top of the imagery. | ||
| beforeId = map.getStyle().layers.find((l) => l.type === "symbol")?.id; | ||
|
Comment on lines
+112
to
+114
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. This is too low in the MapLibre layer stack. If you look at the rendered example:
It's hard to see the image because other layers from the basemap are on top of it. If you load the map style json ( In my example I use this: |
||
| 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,147 @@ | ||||||
| # 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. It's the same model as [Lonboard](./lonboard.md), but in TypeScript for standalone web apps. | ||||||
|
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.
Suggested change
I'd take this out or reword it, because generally Lonboard has used Arrow as the reason why it's faster for vector data, but deck.gl-raster doesn't use Arrow at all. The one thing they both share is that they use deck.gl as the underlying engine for powering geospatial visualizations in the browser. |
||||||
|
|
||||||
| 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-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"; | ||||||
|
|
||||||
| const pool = new DecoderPool({ size: 0 }); | ||||||
| const layer = new COGLayer({ id: "naip", geotiff: signed.href, pool }); | ||||||
| ``` | ||||||
|
|
||||||
| 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", () => { | ||||||
| // draw imagery below the first label layer so basemap text stays on top | ||||||
| const beforeId = map.getStyle().layers.find((l) => l.type === "symbol")?.id; | ||||||
|
|
||||||
| overlay.setProps({ | ||||||
| layers: [new COGLayer({ | ||||||
| id: "naip", | ||||||
| geotiff: signed.href, | ||||||
| pool, | ||||||
| opacity, | ||||||
| beforeId, | ||||||
| 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. 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 }); | ||||||
| ``` | ||||||
|
Comment on lines
+129
to
+134
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. This is fine for a few scenes, maybe up to 10 or 20, but if you have hundreds of non-overlapping scenes, which fits NAIP perfectly, it's better to use the MosaicLayer, which only loads COGLayers when they're visible currently on the screen. The naip-mosaic example is an example you could point people to |
||||||
|
|
||||||
| Browser memory is the practical limit. For large mosaics, reach for `MosaicLayer` / `MultiCOGLayer` from the same package, or fall back to a server-side tiler like [titiler](https://developmentseed.org/titiler/). | ||||||
|
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. Oh I didn't see this. It's not necessarily browser memory as the limit, because if you load 100 COG layers and they're all off screen, you'll be using very little browser memory, because only those COG headers will be loaded into memory. but it still forces you to load all those COG headers. Also, don't suggest the |
||||||
|
|
||||||
| ## 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