Zoom-Dependent Layers
Map styles often change what is drawn as the camera zooms: coarse summaries at low zoom give way to finer ones, labels thin out, lines keep a readable width. The MapLibre and Mapbox style specs express this declaratively with layer minzoom/maxzoom and ["zoom"] expressions. The two specs share the syntax used on this page.
deck.gl handles these effects efficiently. Zoom-dependent props such as opacity are uniforms, so they can be updated on every frame without regenerating attributes or re-running aggregation. The patterns below show how to achieve each effect with existing layer props and extensions.
| MapLibre / Mapbox style spec | deck.gl |
|---|---|
Layer minzoom / maxzoom | visible or layerFilter |
["interpolate", ["linear"], ["zoom"], ...] on *-opacity | opacity computed from zoom |
circle-radius, line-width, text-size interpolated by zoom | Size units and pixel bounds |
filter against a per-feature zoom | DataFilterExtension |
GeoJSON source cluster: true | Aggregation layers per zoom band |
text-allow-overlap, symbol-sort-key | CollisionFilterExtension |
*-pitch-alignment, *-rotation-alignment | billboard on ScatterplotLayer, IconLayer and TextLayer |
Interpolating by zoom
A small helper reproduces the style spec's ["interpolate", ["linear"], ["zoom"], z0, v0, z1, v1, ...]: linear between stops, clamped at both ends.
type ZoomStops = [zoom: number, value: number][];
function interpolateZoom(zoom: number, stops: ZoomStops): number {
if (zoom <= stops[0][0]) {
return stops[0][1];
}
for (let i = 1; i < stops.length; i++) {
const [z1, v1] = stops[i];
if (zoom <= z1) {
const [z0, v0] = stops[i - 1];
return v0 + ((v1 - v0) * (zoom - z0)) / (z1 - z0);
}
}
return stops[stops.length - 1][1];
}
A step expression such as ["step", ["zoom"], 0, 5, 1] is a comparison: zoom >= 5 ? 1 : 0.
Zoom ranges
The equivalent of minzoom/maxzoom is to hide a layer outside a range of zooms. Keep the layer in the layer list and toggle its visible prop rather than removing it, so its buffers and aggregation results survive (see Favor layer visibility over addition and removal):
new ScatterplotLayer({
id: 'parcels',
// Style spec semantics: minzoom is inclusive, maxzoom is exclusive
visible: zoom >= 14 && zoom < 24
// ...
});
Alternatively, a layerFilter decides per viewport, so it works when several views show different zooms (e.g. a minimap). It is also applied during picking, and composite layers are filtered by the id of the top-level layer:
const ZOOM_RANGES: Record<string, [minZoom: number, maxZoom: number]> = {
counties: [0, 8],
parcels: [14, 24]
};
new Deck({
// ...
layerFilter: ({layer, viewport}) => {
const range = ZOOM_RANGES[layer.id];
return !range || (viewport.zoom >= range[0] && viewport.zoom < range[1]);
}
});
Crossfading between zoom bands
Hard cutoffs make layers pop in and out. To fade instead, compute each layer's opacity from the zoom and let neighboring bands overlap. The example below draws a sequence of GridLayers whose cells get smaller as you zoom in, and switches to the raw points at street level:
- TypeScript
- React
import {Deck, MapViewState} from '@deck.gl/core';
import {GridLayer} from '@deck.gl/aggregation-layers';
import {ScatterplotLayer} from '@deck.gl/layers';
type Point = [longitude: number, latitude: number, gender: number];
type ZoomStops = [zoom: number, value: number][];
const DATA_URL =
'https://raw.githubusercontent.com/visgl/deck.gl-data/master/examples/scatterplot/manhattan.json';
function interpolateZoom(zoom: number, stops: ZoomStops): number {
if (zoom <= stops[0][0]) {
return stops[0][1];
}
for (let i = 1; i < stops.length; i++) {
const [z1, v1] = stops[i];
if (zoom <= z1) {
const [z0, v0] = stops[i - 1];
return v0 + ((v1 - v0) * (zoom - z0)) / (z1 - z0);
}
}
return stops[stops.length - 1][1];
}
const BANDS: {cellSize: number; opacity: ZoomStops}[] = [
{cellSize: 16000, opacity: [[0, 1], [7, 1], [8, 0]]},
{cellSize: 4000, opacity: [[7, 0], [8, 1], [9, 1], [10, 0]]},
{cellSize: 1000, opacity: [[9, 0], [10, 1], [11, 1], [12, 0]]},
{cellSize: 250, opacity: [[11, 0], [12, 1], [13, 1], [14, 0]]}
];
function getLayers(data: Point[], zoom: number) {
const gridLayers = BANDS.map(({cellSize, opacity: stops}) => {
const opacity = interpolateZoom(zoom, stops);
return new GridLayer<Point>({
id: `grid-${cellSize}`,
data,
getPosition: d => [d[0], d[1]],
cellSize,
gpuAggregation: true,
opacity,
visible: opacity > 0,
pickable: opacity >= 0.5
});
});
const pointOpacity = interpolateZoom(zoom, [[13, 0], [14, 1]]);
const pointLayer = new ScatterplotLayer<Point>({
id: 'points',
data,
getPosition: d => [d[0], d[1]],
radiusUnits: 'pixels',
getRadius: 2,
opacity: pointOpacity,
visible: pointOpacity > 0,
pickable: pointOpacity >= 0.5
});
return [...gridLayers, pointLayer];
}
const INITIAL_VIEW_STATE: MapViewState = {longitude: -73.98, latitude: 40.75, zoom: 11};
const data: Point[] = await fetch(DATA_URL).then(resp => resp.json());
const deckInstance = new Deck({
initialViewState: INITIAL_VIEW_STATE,
controller: true,
onViewStateChange: ({viewState}) => {
deckInstance.setProps({layers: getLayers(data, viewState.zoom)});
},
layers: getLayers(data, INITIAL_VIEW_STATE.zoom)
});
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {DeckGL} from '@deck.gl/react';
import {MapViewState} from '@deck.gl/core';
import {GridLayer} from '@deck.gl/aggregation-layers';
import {ScatterplotLayer} from '@deck.gl/layers';
type Point = [longitude: number, latitude: number, gender: number];
type ZoomStops = [zoom: number, value: number][];
const DATA_URL =
'https://raw.githubusercontent.com/visgl/deck.gl-data/master/examples/scatterplot/manhattan.json';
function interpolateZoom(zoom: number, stops: ZoomStops): number {
if (zoom <= stops[0][0]) {
return stops[0][1];
}
for (let i = 1; i < stops.length; i++) {
const [z1, v1] = stops[i];
if (zoom <= z1) {
const [z0, v0] = stops[i - 1];
return v0 + ((v1 - v0) * (zoom - z0)) / (z1 - z0);
}
}
return stops[stops.length - 1][1];
}
const BANDS: {cellSize: number; opacity: ZoomStops}[] = [
{cellSize: 16000, opacity: [[0, 1], [7, 1], [8, 0]]},
{cellSize: 4000, opacity: [[7, 0], [8, 1], [9, 1], [10, 0]]},
{cellSize: 1000, opacity: [[9, 0], [10, 1], [11, 1], [12, 0]]},
{cellSize: 250, opacity: [[11, 0], [12, 1], [13, 1], [14, 0]]}
];
function getLayers(data: Point[], zoom: number) {
const gridLayers = BANDS.map(({cellSize, opacity: stops}) => {
const opacity = interpolateZoom(zoom, stops);
return new GridLayer<Point>({
id: `grid-${cellSize}`,
data,
getPosition: d => [d[0], d[1]],
cellSize,
gpuAggregation: true,
opacity,
visible: opacity > 0,
pickable: opacity >= 0.5
});
});
const pointOpacity = interpolateZoom(zoom, [[13, 0], [14, 1]]);
const pointLayer = new ScatterplotLayer<Point>({
id: 'points',
data,
getPosition: d => [d[0], d[1]],
radiusUnits: 'pixels',
getRadius: 2,
opacity: pointOpacity,
visible: pointOpacity > 0,
pickable: pointOpacity >= 0.5
});
return [...gridLayers, pointLayer];
}
const INITIAL_VIEW_STATE: MapViewState = {longitude: -73.98, latitude: 40.75, zoom: 11};
function App() {
const [data, setData] = useState<Point[]>([]);
const [zoom, setZoom] = useState(INITIAL_VIEW_STATE.zoom);
useEffect(() => {
fetch(DATA_URL)
.then(resp => resp.json())
.then(setData);
}, []);
const onViewStateChange = useCallback(({viewState}) => setZoom(viewState.zoom), []);
const layers = useMemo(() => getLayers(data, zoom), [data, zoom]);
return (
<DeckGL
initialViewState={INITIAL_VIEW_STATE}
controller
onViewStateChange={onViewStateChange}
layers={layers}
/>
);
}
The GridLayer example (source) applies the same approach to Paris street trees, with eight bands whose handover zooms are derived from an on-screen cell size.
Things to keep in mind:
- Aggregation runs once per band. Aggregation layers bin in world space, so changing the zoom only changes uniforms. A band is re-aggregated only when its
dataor bin size changes, so pass the samedataobject every time. - Faded layers can still be picked.
opacitydoes not affect picking, so tiepickable(orvisible) to the band as above, or cull withlayerFilter. - Share typed arrays for large data. Every layer runs its own accessors over the data. For millions of points, supply the positions as a
Float64Arrayin binary attributes and pass the same array to every layer. Each layer still uploads its own copy to the GPU. - Color domains differ between bands. Coarser cells hold larger counts, so each band's automatic color domain is different. Normalize the value (e.g. count per area) or set
colorDomainif colors should mean the same thing across bands. - Opacity is per layer, not per view. With multiple views at different zooms, compute the value for the main view, or use
layerFilterfor hard per-view cutoffs.
Sizes that scale with the map
Map styles often interpolate a size exponentially with base 2, e.g. "circle-radius": ["interpolate", ["exponential", 2], ["zoom"], 10, 2, 20, 2048], so that a circle keeps its geographic size. In deck.gl, that is the default behavior of sizes in meters. Pixel bounds keep them legible at low zoom:
new ScatterplotLayer({
// ...
radiusUnits: 'meters',
getRadius: 50,
// Never draw smaller than 2px or larger than 40px, whatever the zoom
radiusMinPixels: 2,
radiusMaxPixels: 40
});
The same props exist for other layers, e.g. widthUnits/widthMinPixels/widthMaxPixels on PathLayer and sizeUnits/sizeMinPixels/sizeMaxPixels on TextLayer and IconLayer. For any other curve, compute radiusScale, widthScale or sizeScale from the zoom with interpolateZoom. These are uniforms, so no attributes are recalculated.
Per-feature minimum zoom
Vector tiles often assign each feature the zoom at which it appears (e.g. major cities first, towns later) and styles filter on it. With the DataFilterExtension, store that zoom per object and move the filter range with the camera. Only a uniform changes per frame, and filterSoftRange fades features in instead of popping them:
import {DataFilterExtension} from '@deck.gl/extensions';
new TextLayer({
id: 'cities',
data: cities,
getText: d => d.name,
getPosition: d => d.coordinates,
// The zoom at which each city appears, e.g. derived from its population rank
getFilterValue: d => d.minZoom,
filterRange: [0, zoom],
filterSoftRange: [0, zoom - 1],
extensions: [new DataFilterExtension({filterSize: 1})]
});
Clustering
A GeoJSON source with cluster: true groups points differently at each zoom level. In deck.gl, there are two ways to do this:
- Aggregation layers per zoom band, as in Crossfading between zoom bands. Each band is aggregated once, on the GPU, and zooming only changes opacity. This scales to millions of points.
- Re-clustering on zoom in a custom composite layer that returns
changeFlags.somethingChangedfromshouldUpdateState, so thatupdateStatealso runs on viewport changes, and rebuilds clusters when the integer zoom changes. This fits icon or label clusters that need an exact count per cluster. See the IconLayer example, which uses supercluster.
Label collision
Symbol layers hide overlapping symbols by default and prioritize them with symbol-sort-key. The CollisionFilterExtension does the same on the GPU for any layer. Collisions are re-evaluated as the camera moves, so labels thin out automatically when zooming out:
import {CollisionFilterExtension} from '@deck.gl/extensions';
new TextLayer({
id: 'labels',
data: cities,
getText: d => d.name,
getPosition: d => d.coordinates,
// Larger values win. Priorities are clamped to [-1000, 1000]
getCollisionPriority: d => Math.log10(d.population + 1) * 100,
extensions: [new CollisionFilterExtension()]
});