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
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,
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;
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.

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.

```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 });
```

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

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