Some visualizations animate, pulse, and flash. If you're sensitive to motion or flashing, turn on Reduce motion in your system settings.

poopdeck.gl
deck.gl Layers

AnimatedHeatmapLayer

Export name. This layer is exported as AnimatedHeatmapLayer (with props type AnimatedHeatmapLayerProps) — named to avoid shadowing @deck.gl/aggregation-layers' own HeatmapLayer.

The AnimatedHeatmapLayer renders temporal point data as an animated density heatmap. It is a thin composite over the canonical deck.gl HeatmapLayer (@deck.gl/aggregation-layers): points are splatted into a GPU weight texture with additive density accumulation, reduced to a per-pixel density, and only then mapped through the colour ramp. Dense regions get hotter because more splats land on the same pixels — true per-pixel density, not per-splat colour blending.

Time animation. The canonical HeatmapLayer has no notion of time, so the window is driven by @deck.gl/extensions' DataFilterExtension: each point carries a relativized timestamp as getFilterValue, and the filterRange (the [start, end] window around the play head) is recomputed each frame. Out-of-window points contribute zero density during the weights aggregation pass — and changing filterRange re-runs the aggregation, so the heatmap genuinely re-densifies as the play head moves (it is not a cross-fade). The re-aggregation cadence is capped at ~30 Hz, independently of tile loading.

Data feed. Points from every visible tile are consolidated into one binary buffer set per channel (not per-tile sublayers), so the canonical layer normalises against a single global max — no per-tile brightness seams, and gaussian splats accumulate correctly across tile borders. The consolidated buffers are cached by visible-tile-set key and rebuilt only when that set (or the channel config) changes; per frame only the small filterRange array changes, so nothing is re-uploaded — only the GPU aggregation re-runs. filterRange / filterSoftRange are additionally memoized by content: a tick that lands inside the same window hands back the same array reference and the sublayer sees no change at all, so only a window that actually moved pays for the rebuild (and it must, since that rebuild is what re-runs the aggregation).

Geometry kinds. Point tiles splat one gaussian per feature. LineString tiles splat one per VERTEX (walked through startIndices), which is what makes a "density of AIS tracks / flight paths" heatmap read correctly — indexing positions by feature index there would have splatted the first N vertices of the first few paths instead. Polygon tiles are rejected with a named console warning rather than mis-rendered.

f32 precision: both the per-point filter value and filterRange are relativized against a single layer time offset (the first visible tile's offset), keeping both sides of the shader comparison inside the Float32 mantissa budget (≈ 2²⁴ ms ≈ 4.6 h around the offset; longer-spanning windows quantize at the edges).

It extends SpatioTemporalLayer and supports up to four stacked categorical channels, each rendered as its own canonical HeatmapLayer (own ramp + density normalisation) composited in order.

Installation#

import { AnimatedHeatmapLayer } from '@poopdeck.gl/layers';

Usage#

Single-channel (default)#

const layer = new AnimatedHeatmapLayer({
id: 'earthquake-heat',
data: '/data/earthquakes/manifest.json',
currentTime: 1672531200000,
timeWindow: 86_400_000, // 1 day
radiusPixels: 30,
intensity: 1,
weightProperty: 'magnitude', // per-splat weight
colorDomain: [4.0, 6.5], // accumulated-density domain; tune against the rendered map
colorRange: [
[255, 255, 178, 255],
[254, 204, 92, 255],
[253, 141, 60, 255],
[240, 59, 32, 255],
[189, 0, 38, 255],
],
});

If the archive was built with stt-build --heatmap-weight <prop> and the layer weights by that same column (weightProperty / getWeight), the baked [min, 95p] domain is applied as a per-point weight normaliser — every weight is divided by the baked max so accumulation lands in a predictable range. It is never applied to colorDomain: the baked domain is in raw weight-column units, colorDomain is in accumulated-per-texel units. If the baked domain was measured from a different column, the layer warns once (AnimatedHeatmapLayer:domainPropertyMismatch) and ignores it.

Stacked categorical channels#

const layer = new AnimatedHeatmapLayer({
id: 'taxi-od',
data: '/data/nyc-taxi-od/manifest.json',
currentTime,
timeWindow: 30 * 60 * 1000,
radiusPixels: 40,
channels: [
{
id: 'pickup',
categoryFilter: { property: 'kind', values: ['pickup'] },
colorRange: PICKUP_RAMP,
colorDomain: [1, 80],
},
{
id: 'dropoff',
categoryFilter: { property: 'kind', values: ['dropoff'] },
colorRange: DROPOFF_RAMP,
colorDomain: [1, 80],
},
],
});

Up to four channels are supported (one canonical HeatmapLayer each); beyond that the layer warns and renders only the first four.

Properties#

Inherits all properties from SpatioTemporalLayer.

Render#

PropertyTypeDefaultDescription
radiusPixelsnumber30Splat radius in pixels
intensitynumber1Global intensity multiplier (canonical intensity)
weightPropertystring | nullnullNumeric property → per-point weight. Unset weights every point 1.0.
getWeightstring | nullnullUpstream-vocabulary alias of weightProperty. Accepts a property-column NAME — NOT a function accessor (a function warns once and falls back). When set, it wins.
colorRangeColor[]7-stop YlOrRdLow → high density ramp (single-channel mode)
colorDomain[number, number]nullPinned density domain. Unset → the layer auto-normalises against the current window's max each frame (so colours may "breathe" as the window slides; pin it to keep the mapping stable).
thresholdnumber0.05Density fraction below which pixels are transparent. Only takes effect when colorDomain is unset (the pinned-domain path supersedes it).
aggregation'SUM' | 'MEAN''SUM'Density accumulation operation (canonical pass-through). 'MEAN' averages weights instead — only meaningful with a weightProperty; a constant-weight MEAN flattens the map to a single colour.
weightsTextureSizenumber2048Side length of the density texture (canonical pass-through). Larger = finer detail at higher GPU cost.
debounceTimeoutnumber500Interaction debounce (ms) before re-aggregating during pan/zoom (canonical pass-through).
fadeInDurationnumber0Leading-edge fade (ms), mapped onto the filter soft-range
fadeOutDurationnumber0Trailing-edge fade (ms)

Units of colorDomain. It is a verbatim pass-through of deck's own HeatmapLayer.colorDomain and carries deck's units: the value a weights-texture texel accumulates (the sum of every gaussian splat landing on it), which aggregation: 'SUM' further rescales by metersPerPixel * weightsScale. So it is not in weight-column units, and it is zoom- and latitude-dependent — a domain pinned at one zoom does not mean the same thing at another. Tune it against the rendered map rather than deriving it from the data.

Stacked channels (channels)#

When supplied, each entry renders as its own canonical HeatmapLayer (its own ramp + density normalisation), composited in order.

FieldTypeDefaultDescription
idstringChannel id; matches metadata.heatmapDomain.classes[*].id when present
categoryFilter{ property, values[] }Only features matching this categorical filter contribute to the channel (tiles missing the property are skipped for that channel)
colorRangeColor[]7-stop YlOrRdPer-channel ramp
colorDomain[number, number]nullPinned per-channel density domain (see note above)
intensitynumber1Per-channel weight multiplier, folded into the point weight

Sublayers are not pickable (density pixels have no feature identity). The sublayer short id for _subLayerProps overrides is heatmap — it covers every per-channel sublayer.

Build-time weight normaliser#

stt-build --heatmap-weight <prop> (and optionally --heatmap-class <prop> for per-class domains) computes [min, 95th-percentile] of the raw weight column across all features and writes it to metadata.heatmapDomain. 95p (not absolute max) keeps a single outlier from dominating the scale. The renderer uses it as a per-point weight normaliser (weight / p95) — a dimensionally valid pure scale that keeps accumulated values near the 1-per-splat range the 8-bit weights-texture fallback needs. It never feeds colorDomain, whose units are not convertible from these without knowing point density, radius and zoom.

Source#

packages/layers/src/layers/summary/heatmap-layer.ts