Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Explorer <overview/explorer>
Use VS Code <overview/ui-vscode>
Use GitHub Codespaces <overview/ui-codespaces>
Using QGIS <overview/qgis-plugin>
Rendering rasters with deck.gl-raster <overview/deckgl-raster>
Changelog <overview/changelog>
```

Expand Down
139 changes: 139 additions & 0 deletions docs/overview/deckgl-raster-example/index.html
Copy link
Copy Markdown

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

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,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 renderTile to render as RGB

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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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:

Image

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 (https://basemaps.cartocdn.com/gl/positron-gl-style/style.json), you can try different id values from the layers array.

In my example I use this:
https://github.com/developmentseed/deck.gl-raster/blob/55619f8f15c3e8b8696480f5974d6bd49bcfedf1/examples/naip-mosaic/src/App.tsx#L445

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>
147 changes: 147 additions & 0 deletions docs/overview/deckgl-raster.md
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.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
[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.
[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.

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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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/).
Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 MultiCOGLayer here in the context of a mosaic. That's for cases like landsat/sentinel2 where there are multiple separate COGs that are all referencing the same scene and should be composited together.


## 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).
Binary file added docs/overview/images/deckgl-raster-full-app.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.