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
164 changes: 164 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,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,

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,
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>
182 changes: 182 additions & 0 deletions docs/overview/deckgl-raster.md
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

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-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).
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.