Skip to main content

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 specdeck.gl
Layer minzoom / maxzoomvisible or layerFilter
["interpolate", ["linear"], ["zoom"], ...] on *-opacityopacity computed from zoom
circle-radius, line-width, text-size interpolated by zoomSize units and pixel bounds
filter against a per-feature zoomDataFilterExtension
GeoJSON source cluster: trueAggregation layers per zoom band
text-allow-overlap, symbol-sort-keyCollisionFilterExtension
*-pitch-alignment, *-rotation-alignmentbillboard 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:

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

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 data or bin size changes, so pass the same data object every time.
  • Faded layers can still be picked. opacity does not affect picking, so tie pickable (or visible) to the band as above, or cull with layerFilter.
  • Share typed arrays for large data. Every layer runs its own accessors over the data. For millions of points, supply the positions as a Float64Array in 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 colorDomain if 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 layerFilter for 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.somethingChanged from shouldUpdateState, so that updateState also 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()]
});