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

poopdeck.gl
Core Reader & Kernel

Render Kernel (@poopdeck.gl/core)

@poopdeck.gl/core has two halves. One is the readerSTTArchive, SpatioTemporalTileset, decodeTile — documented in Tile decoding, SpatioTemporalTileset, and Binary Features. This page documents the other half: the render kernel, a set of framework-free modules under packages/core/src/render/ and packages/core/src/geo/ that hold the CPU logic every renderer backend (@poopdeck.gl/layers on deck.gl, @poopdeck.gl/three, @poopdeck.gl/maplibre, @poopdeck.gl/cesium) needs and would otherwise hand-copy: time-filter alpha math (plus a second, independently-authored expression-AST oracle for it), color expansion, geometry reductions, geographic projection, picking id encoding, tileset fetch-callback glue, and the capability-declaration vocabulary.

None of it is re-exported from the package root (@poopdeck.gl/core) — each module is its own exports sub-path in packages/core/package.json, so a backend imports only the pieces it needs and bundlers tree-shake the rest:

Sub-pathSource fileConsumed by
@poopdeck.gl/core/time-filterrender/time-filter.tslayers, three, maplibre, cesium
@poopdeck.gl/core/shader-codegenrender/shader-codegen.tsbackend conformance tests only — nothing that ships imports it
@poopdeck.gl/core/stylerender/style.tslayers, three, maplibre, cesium
@poopdeck.gl/core/geometryrender/geometry.tslayers, three, maplibre, cesium
@poopdeck.gl/core/tripsrender/trips.tsthree, maplibre, cesium
@poopdeck.gl/core/edge-bundlingrender/edge-bundling.tslayers, three, maplibre, cesium
@poopdeck.gl/core/geogeo/index.tsthree, maplibre, cesium
@poopdeck.gl/core/pickingrender/picking.tsthree, maplibre, cesium
@poopdeck.gl/core/tileset-adapterrender/tileset-adapter.tslayers, three, maplibre
@poopdeck.gl/core/capabilitiesrender/capabilities.tslayers, three, maplibre, cesium
@poopdeck.gl/core/capabilities-docrender/capabilities-doc.tsdoc generation only

A repo test (packages/core/test/kernel-framework-free.test.ts) statically scans every file under packages/core/src and fails the build if it imports three, @deck.gl/*, @luma.gl/*, maplibre-gl, mapbox-gl, cesium, or @react-three/* — the enforcement mechanism that keeps this package renderer-agnostic. For the design rationale behind the kernel's boundaries, see renderer-architecture.md; for what each backend claims to support on top of it, see backend-capabilities.md.

core/time-filter#

The CPU reference for the per-feature/per-vertex temporal alpha every backend animates against, plus the time-relativization scheme and a vocabulary resolver. This is the numeric oracle. Every backend's GPU shader is hand-written — deck's TimeFilterExtension inject strings, maplibre's GLSL snippets, three's TSL node graph — and each implements these four modes independently in its own dialect. What keeps them from drifting is not code generation but a conformance contract: each backend's shader math is pinned by test to this oracle and to core/shader-codegen's independently-authored evalExpr (see core/shader-codegen below).

type TimeFilterMode = 'window' | 'wake' | 'cumulative' | 'trail' | 'none';
interface TimeFilterParams {
windowHalf?: number; // half-width of the symmetric window (ms) — window mode
fadeIn?: number; // leading-edge fade ramp (ms) — window / cumulative
fadeOut?: number; // trailing-edge fade ramp (ms) — window
wakeLength?: number; // wake length behind the playhead (ms) — wake mode
trailLength?: number; // trail length behind the playhead (ms) — trail mode
trailFade?: number; // 1 = head→tail fade, 0 = solid trail — trail mode
}

Per-mode alpha functions#

FunctionSignatureSemantics
windowAlpha(currentTime, startTime, endTime, windowHalf, fadeIn?, fadeOut?) => numberVisible while [startTime, endTime] overlaps [currentTime ± windowHalf], with optional leading/trailing fade ramps.
wakeAlpha(currentTime, startTime, wakeLength) => numberVisible only in [0, wakeLength] ms behind the playhead, fading linearly to 0 at the tail.
cumulativeAlpha(currentTime, startTime, fadeIn?) => numberAppears at startTime and persists forever after ("draw and persist"); optional fadeIn ramps 0→1.
trailAlpha(currentTime, vertexTime, trailLength, trailFade) => numberPer-vertex: visible while vertexTime ∈ [currentTime - trailLength, currentTime]; trailFade blends a solid trail (0) against a head→tail linear fade (1).
wakeSizeScale(alpha, wakeTailScale) => numberThe wake-mode tail point-size multiplier (wakeTailScale at the tail, full size at the head) — mirrors deck's DECKGL_FILTER_SIZE wake branch.
timeFilterAlpha(mode, currentTime, startTime, endTime, params?, vertexTime?) => numberDispatches to the function above matching mode; 'none' always returns 1.

DEFAULT_WAKE_TAIL_SCALE = 0.15 is the single-sourced default tail size multiplier for wake mode.

Relativization scheme#

const MAX_RELATIVE_TIME_MS = 16_777_216; // 2^24 — f32 mantissa exact-integer bound
function relativizeTime(absoluteTime: number, offset: number): number; // absoluteTime - offset
function assertRelTimeInRange(
relativeTime: number,
mode: TimeFilterMode,
key?: string,
): void;

Every time value the kernel compares is relative to a per-tile/per-scene offset (absolute epoch-ms minus the offset), so both sides of a shader comparison stay small enough to survive an exact f32 round-trip. relativizeTime is the single source of truth for that subtraction; assertRelTimeInRange warns once per key when a resolved relative time exceeds MAX_RELATIVE_TIME_MS in a non-cumulative mode (cumulative intentionally spans years and tolerates the coarser quantization). See TimeFilterExtension § The timeOffset contract for the worked deck.gl example — the scheme is identical here.

Vocabulary resolver#

interface TimeFilterVocabulary {
timeWindow?: number; // FULL-width window (ms) — deck/maplibre
fadeInDuration?: number; // deck/maplibre
fadeOutDuration?: number; // deck/maplibre
softTimeWindow?: boolean; // maplibre legacy soft-ramp flag
windowHalf?: number; // HALF-width window (ms) — three-native; wins over timeWindow
fadeIn?: number; // three-native; wins over fadeInDuration
fadeOut?: number; // three-native; wins over fadeOutDuration
wakeLength?: number;
trailLength?: number;
trailFade?: number;
}
interface ResolveTimeFilterPolicy {
defaultWindowHalf?: number;
softDefaultFraction?: number; // undefined = hard-0 fade default (deck/three); set = maplibre-style soft ramp
}
function resolveTimeFilterParams(
v: TimeFilterVocabulary,
policy?: ResolveTimeFilterPolicy,
): TimeFilterParams;

resolveTimeFilterParams normalizes the union of vocabularies deck/maplibre (full-width timeWindow + fadeInDuration/fadeOutDuration) and three (half-width windowHalf + fadeIn/fadeOut) accept, into one TimeFilterParams. When both forms of a knob are supplied, the half-width form wins. Today @poopdeck.gl/three's lib/time-window.ts calls it directly; maplibre still resolves its own fade durations locally (base-layer.ts's resolveFadeDurations) rather than through this function.

core/shader-codegen#

The scalar time-filter alpha authored a second time, as a branchless expression AST, independently of core/time-filter's CPU functions.

The module name is historical and oversells it: nothing that ships is generated from this AST. All four backends hand-write their own shader math (see the table below). What the module actually buys is a second oracleevalExpr is a separate, differently-shaped implementation of the same four modes, so a backend's math can be pinned against two independently-authored references instead of one. A transcription slip in either implementation shows up as a failing test rather than as drifted pixels. Read it as a conformance oracle, not a compiler. The emit* string emitters this module once carried were removed at the 0.6.0 cut because nothing compiled their output; the AST and its evaluator remain because conformance compares the alpha VALUE, not the shader TEXT.

type Expr =
| { op: 'uniform'; name: string }
| { op: 'attr'; name: string }
| { op: 'const'; value: number }
| {
op: 'add' | 'sub' | 'mul' | 'div' | 'min' | 'max' | 'step';
a: Expr;
b: Expr;
}
| { op: 'clamp01'; a: Expr }
| { op: 'select'; c: Expr; t: Expr; f: Expr };

The frozen op-set is exactly: uniform, attr, const, add, sub, mul, div, min, max, step, clamp01, select. select(c, t, f) is c != 0 ? t : f, evaluated lazily (and emitted as a GLSL ternary), so a fade div guarded by select never evaluates a zero-denominator branch. This op-set is deliberately scoped to the four linear modes (window, wake, cumulative, trail) — the surfel/splat temporal-Gaussian weight, the radial falloff, and the wakeSizeScale vertex-stage multiplier use transcendentals outside the set, so they have no second oracle and are pinned to core/time-filter alone by per-backend parity tests.

const ALPHA_EXPR: Record<'window' | 'wake' | 'cumulative' | 'trail', Expr>;
function evalExpr(e: Expr, env: Record<string, number>): number;
const TIME_FILTER_VARS: {
currentTime: 'currentTime';
startTime: 'startTime';
endTime: 'endTime';
vertexTime: 'vertexTime';
windowHalf: 'windowHalf';
fadeIn: 'fadeIn';
fadeOut: 'fadeOut';
wakeLength: 'wakeLength';
trailLength: 'trailLength';
trailFade: 'trailFade';
};
  • ALPHA_EXPR[mode] is the frozen per-mode AST.
  • evalExpr is the second oracle: it must equal core/time-filter's timeFilterAlpha numerically for every mode — asserted by a 2000-sample randomized conformance sweep in packages/core/test/shader-codegen.test.ts. This is the function the backend conformance tests import.
  • TIME_FILTER_VARS is the canonical identifier set an AST refers to. It is the vocabulary each backend maps onto its own shader variable names by hand.

Backend conformance obligations#

No backend consumes an emitted string. Each hand-writes its shader in its own dialect and is pinned by test to both oracles — core/time-filter's timeFilterAlpha and this module's evalExpr:

BackendWhat shipsDialectPinned by
@poopdeck.gl/layershand-written inject strings (extensions/time-filter-extension.ts)GLSL ES 3.00 (layout(std140) uniform, in/out)packages/layers/test/ time-filter conformance
@poopdeck.gl/maplibrehand-written snippets (shaders/time-window.glsl.ts)GLSL ES 1.00 (attribute; linkProgram is WebGL1-compatible)packages/maplibre/test/time-modes.test.ts (the three-way statement of it)
@poopdeck.gl/threehand-written TSL node graph (tsl/time-filter.ts)TSL node graph (WebGPU / WebGL2)packages/three/test/tsl-time-filter-conformance.test.ts (graph executed)
@poopdeck.gl/cesiumno shader — per-frame CPU alpha writes into the batch tablepackages/cesium/test/ (direct oracle calls)

For the GLSL backends the pin has the same shape, because GLSL cannot be executed in a headless unit test: the package keeps a JS reference implementation of its shader math, the tests assert that reference equals timeFilterAlpha and evalExpr(ALPHA_EXPR[mode], env) over a dense sweep including every boundary, and the shipped GLSL is locked to the reference structurally (the formula lines are asserted verbatim, so editing one without the other fails). packages/maplibre/test/time-modes.test.ts states this contract explicitly and is the model for the others.

Three is pinned harder, because TSL is the one dialect that can run headless. A TSL graph is a plain object tree (ConstNode/UniformNode carry .value; OperatorNode carries .op; MathNode carries .method; ConditionalNode carries its branches), so no NodeBuilder, GPU, or WebGPU renderer is needed to walk and evaluate it. tsl-time-filter-conformance.test.ts therefore executes the shipped graph itself against both oracles rather than a hand-kept mirror of it — there is no reference to drift. Its evaluator throws on any unknown node class or operator, so a graph rewritten with a new op fails loudly instead of silently passing. Compiled-shader pixels remain outside what any of this proves — see renderer-architecture.md §2.9.

core/style#

Framework-free color resolution: categorical (keyed or positional-palette) lookup, RGB-numeric-column expansion, and continuous ramp sampling, each able to emit either Uint8Array (0–255) or Float32Array (0..1) output so one implementation serves both a GPU-attribute convention (deck: u8) and another (three: f32).

type RGBA255 = readonly [number, number, number, number];
type ColorOut = 'u8' | 'f32';
const NULL_CATEGORY_INDEX = 0xffff; // sentinel "no category" in categoricalProps.indices
function resolveCategoryColor(
label: string | undefined,
mapping: Record<string, RGBA255> | null | undefined,
fallback: RGBA255,
): RGBA255;
interface CategoricalColorSpec {
property: string;
colorMapping?: Record<string, RGBA255> | null;
palette?: readonly RGBA255[]; // positional fallback (maplibre)
colorMappingDefault?: RGBA255; // default undefined ⇒ transparent [0,0,0,0]
onMissing?: 'null' | 'fill'; // property absent from tile: null (maplibre) or fill (three). default 'null'
requireMappingOrPalette?: boolean; // maplibre guard: null when nothing to paint. default false
}
function expandCategoricalColors(
binary: BinaryFeatures,
spec: CategoricalColorSpec,
out: ColorOut,
): Uint8Array | Float32Array | null;

expandCategoricalColors is a superset of the color-resolution patterns each backend needs (three's lib/color.ts, maplibre's base-layer.ts, deck's animated-point-layer.ts, three's box-tracks.ts scalar resolveColor); out, onMissing, requireMappingOrPalette, and the positional palette argument reproduce each call site's exact behavior. Per-feature resolution order when a categorical property is present: colorMapping[label] ?? colorMappingDefault, then palette[idx % palette.length] if still unset, then transparent. Note deck's GPU CategoryColorExtension (a palette texture sampled by instanceCategoryIndex, see CategoryColorExtension) is not part of this kernel — only the palette data and category-index attribute builder are shared; expandCategoricalColors is deck's CPU colorMapping fallback path, not its GPU hot path.

function expandRgbColumns(
binary: BinaryFeatures,
columns: readonly [string, string, string], // r, g, b numeric property names (0–255)
out: ColorOut,
alpha?: number, // default 255
fallback?: RGBA255, // default [200, 205, 215, 255]
): Uint8Array | Float32Array;
interface RampColorSpec {
property: string;
domain: readonly [number, number];
range: readonly RGBA255[]; // ≥1 evenly-spaced gradient stops
fallback: RGBA255;
}
function rampColorAt(
value: number,
domain: readonly [number, number],
range: readonly RGBA255[],
): RGBA255;
function expandRampColors(
binary: BinaryFeatures,
spec: RampColorSpec,
out: ColorOut,
): Uint8Array | Float32Array;

rampColorAt clamps value into domain, maps it to [0, 1], and linearly interpolates across range's evenly-spaced stops (including alpha); expandRampColors applies it per-feature, falling back to a constant color when the numeric property is absent from the tile.

core/geometry#

Backend-neutral geometry reductions.

interface SourceTargetPositions {
source: Float64Array; // featureCount * dims — feature i's FIRST vertex
target: Float64Array; // featureCount * dims — feature i's LAST vertex
dims: number; // 2 or 3
}
function deriveSourceTargetPositions(
binary: BinaryFeatures,
): SourceTargetPositions;

Derives dense source/target endpoint buffers from a LineString tile's startIndices — the source→target representation OD flow layers (deck ArcLayer/LineLayer, three's OD-line rendering) need. STT stores OD flows as LineString features; each feature collapses to its first vertex (source) and last vertex (target), dropping any intermediate vertices. Output is Float64Array so deck's fp64 position attribute populates hi/lo correctly. Requires binary.startIndices; callers gate on featureCount > 0.

function tessellateFeature(
binary: BinaryFeatures,
featureIndex: number,
opts?: { preferPrebaked?: boolean }, // default true
): Uint32Array | null;

The single tessellation dispatch every backend shares: when the tile carries pre-baked triangles/triangleOffsets (built with stt-build --pre-tessellate) and preferPrebaked is not explicitly disabled, returns a zero-copy subarray of that feature's slice — the holes-correct, multi-ring-correct path. Otherwise falls back to earcut-ing the feature's single ring (startIndices[f] … startIndices[f+1]), matching maplibre's original fallback; this path cannot handle holes/multi-ring polygons (see Binary Features § Polygon rings). Returns null when the feature has no polygon geometry, or (non-prebaked path) fewer than 3 ring vertices.

core/trips#

The CPU trip kernel every non-deck backend shares: index a tile set's LineString features once, then interpolate a moving head — or trim a trailing tail — per frame with no projection and no allocation. Lifted verbatim from @poopdeck.gl/three's pure lib/trip-heads.ts when Cesium became its third consumer; deck keeps the original copies.

type TripPrecision = 'f32' | 'f64';
interface Trip {
positions: Float32Array | Float64Array; // x,y,z interleaved, RELATIVE to the index origin
vertexTimes: Float32Array | Float64Array; // relative to timeOrigin, monotonic non-decreasing
numVerts: number;
start: number;
end: number; // feature window, relative to timeOrigin
binary: BinaryFeatures;
featureIndex: number; // picking provenance
}
interface TripIndex {
trips: Trip[];
origin: [number, number, number]; // f64 world origin all positions are relative to (RTC)
}
interface Head {
x: number;
y: number;
z: number;
}
function buildTripIndex(
tiles: Tile[],
projection: Projection,
timeOrigin: number,
options?: { precision?: TripPrecision },
): TripIndex;
function sampleHead(trip: Trip, t: number): Head | null;
function sampleHeads(index: TripIndex, t: number, out: Float32Array): number;
function trimTrail(
trip: Trip,
t: number,
trailLength: number,
out: Float64Array | Float32Array,
): number;
function synthesizeVertexTimes(binary: BinaryFeatures): Float32Array;

buildTripIndex walks every LineString layer (points/polygons are skipped), projects each vertex once relative to a shared RTC origin — the first usable vertex, so the offsets stay small enough for f32 on a GPU — and rebases each feature's [startTime, endTime] plus its per-vertex times onto the scene's common timeOrigin. Per-vertex times come from the tile's own vertexTimestamps column when present; otherwise synthesizeVertexTimes distributes [start, end] across the vertices by cumulative haversine distance (a verbatim port of deck's function of the same name).

sampleHead binary-searches the segment bracketing t and lerps, returning null when the trip is inactive; sampleHeads writes every active head into a caller-grown Float32Array and returns the count. trimTrail is the CPU analogue of the GPU trail vertex fade, for hosts whose stock polyline primitive has no per-vertex shader hook (Cesium): it returns the sub-polyline in [t − trailLength, t] tail→head, with interpolated head and tail vertices, writing into an out sized for numVerts + 2 vertices.

Precision is a contract, not a tuning knob. 'f32' (the default) is for GPU-buffer consumers and is byte-identical to the pre-lift three build; 'f64' is for CPU-double consumers — Cesium's Cartesian3 — where the RTC offset itself can span the globe and would otherwise quantize to metres.

core/edge-bundling#

The pure, device-free KDEEB math behind the liveBundling capability. Kernel density edge bundling (Hurter, Ersoy & Telea 2012) with a CUBu-style pipeline (van der Zwan & Telea 2016) turns geometrically-close flows into smooth rivers by iteratively advecting each edge's control points up the gradient of an edge-density field. One iteration is:

  1. Splat — additively rasterize an Epanechnikov kernel of radius h at every control point into a density texture.
  2. Advect — move each interior control point a step h along the normalized density gradient ∇ρ/‖∇ρ‖.
  3. Resample — redistribute each edge's points to uniform arc-length spacing (advection bunches them; without this you get gaps and kinks).
  4. Smooth — one 1D Laplacian pass along each edge. This is what makes the bundles smooth; advection alone is jagged.
  5. Anneal — shrink h and repeat, progressively tightening the bundles.
type Vec2 = readonly [number, number];
const BUNDLING_EPS = 1e-9; // degenerate-length guard for lengths and radii
const BUNDLING_WORK_SIZE = 1000; // side of the normalized simulation box
function epanechnikovWeight(dist: number, radius: number): number;
function annealRadius(radius: number, lambda: number): number; // lambda clamped to [0.5, 0.9]
interface BundleEdgesOptions {
iterations?: number; // @default 15
kernelRadius?: number; // initial bandwidth h AND the advection step. @default 3% of the work box
lambda?: number; // per-round bandwidth decay. @default 0.85
smoothing?: number; // Laplacian strength per round. @default 0.5
densityResolution?: number; // density grid per axis. @default 256
}
function bundleEdges(
points: ArrayLike<number>,
edgeCount: number,
pointsPerEdge: number,
opts?: BundleEdgesOptions,
): Float64Array;

points is edge-major and 2-D — edge e's point i sits at (e * pointsPerEdge + i) * 2 — and coordinates are expected in the shared BUNDLING_WORK_SIZE box: map your cosLat-corrected endpoints into it first so the bandwidth constants mean the same thing at every scale, then map back. The input is never mutated; a new buffer is returned. Endpoints are pinned: column 0 and column pointsPerEdge - 1 of every edge are copied through untouched each round, because a bundled flow must still start and end where its data says it does. Degenerate inputs (fewer than 3 points per edge, or a single edge) return a copy rather than throwing.

These primitives live in core so the four backends share ONE definition instead of hand-copying constants that have drifted before. Each backend still writes its own device path — luma for deck, TSL for three, hand-written GLSL for maplibre, and a plain CPU schedule for Cesium — and pins that path to these functions as the CPU oracle, the same conformance idiom core/time-filter uses for the scalar alpha. Note the split of responsibility: endpoint pinning inside bundleEdges is a property of these primitives, but a caller running its own advect loop over epanechnikovWeight/laplacianStep owns that pinning itself.

core/geo#

A pluggable lon/lat(+altitude) ↔ world-space projection, for the three CPU-projecting backends — three, maplibre and Cesium. deck.gl projects entirely on the GPU against a host WebMercatorViewport/GlobeViewport and does not consume this module.

interface GeoAnchor {
longitude: number;
latitude: number;
}
interface LocalFrame {
east: [number, number, number]; // unit world vector, local east
north: [number, number, number]; // unit world vector, local north
up: [number, number, number]; // unit world vector, local up
}
interface Projection {
readonly kind: string;
readonly anchor: GeoAnchor;
project(
longitude: number,
latitude: number,
altitude?: number,
): [number, number, number];
unproject(x: number, y: number, z?: number): [number, number, number];
metersPerWorldUnit(longitude: number, latitude: number): number;
localFrame(longitude: number, latitude: number): LocalFrame;
}
const METERS_PER_DEG_LAT = 111_320;
const EARTH_RADIUS = 6_378_137; // WGS84 semi-major axis (m)

Three implementations share this contract:

ClasskindWorld axesNotes
LocalEnuProjection'local-enu'Z-up metric ENU, 1 world unit = 1 mEquirectangular about a fixed anchor; east scale frozen at cos(anchor.latitude) — the exact inverse of the AV dataset build-time av_common.local_to_lonlat georeferencing.
MercatorProjection'mercator'Z-up, world units = mercator metresStandard Web-Mercator (EPSG:3857); clamps latitude to MAX_MERCATOR_LAT (85.05112877980659); altitude divided by metersPerWorldUnit so vertical scale matches horizontal. Absolute coordinates run ~±2e7, so batches route through the RTC helper below.
GlobeProjection'globe'ECEF (+X → lon 0/lat 0, +Y → lon 90°E, +Z → north pole)datum: 'sphere' (default, a true sphere) or 'wgs84' (real ellipsoid, via WGS84_F/first-eccentricity and Bowring's closed-form inverse for unproject). radius sets the semi-major-axis world-unit length (default EARTH_RADIUS; e.g. pass 100 for a unit-sphere-scale globe).
function projectPositionsToEnu(
proj: Projection,
positions: Float64Array,
count: number,
dims: 2 | 3,
elevation?: Float32Array,
elevScale?: number,
): Float32Array; // interleaved world [x, y, z] triples
interface ProjectedPositions {
positions: Float32Array; // f32, RELATIVE to origin
origin: [number, number, number]; // f64 world-space origin
}
function projectPositions(
proj: Projection,
positions: Float64Array,
count: number,
dims: 2 | 3,
opts?: {
elevation?: Float32Array;
elevScale?: number;
origin?: [number, number, number];
},
): ProjectedPositions;

projectPositions is the precision-safe ("Relative-To-Center") batch projector for mercator/globe frames, where absolute world coordinates overflow f32: it writes each vertex as an f32 offset from a high-precision origin (defaulting to the first projected point), and the caller parents the geometry under an object placed at that origin. Passing origin: [0, 0, 0] reproduces projectPositionsToEnu's behavior.

View state + zoom helpers#

interface ViewState {
longitude: number;
latitude: number;
zoom: number; // Web-Mercator zoom (world is 512·2^zoom px around)
pitch?: number; // degrees, 0 = top-down
bearing?: number; // degrees, 0 = north up
roll?: number; // degrees; ignored by backends whose camera lacks a roll DOF
altitude?: number; // metres, alternative to zoom for height-driven cameras (Cesium)
}
const TILE_SIZE = 512;
const WORLD_CIRCUMFERENCE: number; // 2π · EARTH_RADIUS
function worldUnitsPerPixel(
proj: Projection,
zoom: number,
latitude: number,
): number;
function zoomForWorldUnitsPerPixel(
proj: Projection,
wupp: number,
latitude: number,
): number;

ViewState is the deck-compatible lingua franca for cross-renderer camera sync; the three-specific viewStateToCamera/cameraToViewState bridge (which touches a Three PerspectiveCamera) lives in @poopdeck.gl/three and consumes this type plus the two zoom helpers. worldUnitsPerPixel/zoomForWorldUnitsPerPixel convert between zoom and ground resolution: mercator world units are constant mercator-metres per zoom, globe world units are true metres so ground resolution additionally shrinks by cos(latitude).

core/picking#

The framework-free pieces every backend's hit-testing shares: a normalized result shape, 24-bit id-color packing so any id-buffer backend is interoperable, and a provenance ledger for backends that merge many tiles' geometry into one draw buffer.

const MAX_PICK_ID = 0xffffff; // 16,777,215
function encodePickId(index: number): [number, number, number]; // big-endian, throws if out of range
function decodePickId(rgb: readonly [number, number, number]): number;
function buildIdColors(featureCount: number): Float32Array; // normalized [0,1] RGB triples, one per feature
interface SttPickResult {
object: Record<string, unknown> | null;
index: number; // feature index within its (tile, layer) BinaryFeatures, or -1
tileId?: TileId;
layerId: string;
coordinate?: [number, number]; // geographic [lng, lat]
screen?: [number, number]; // CSS [x, y]
worldPoint?: [number, number, number]; // renderer-frame world space
meta?: Record<string, unknown>; // backend/domain-specific extras (e.g. AV trackId, speed)
}

index joins back to columns via getFeatureProperties(binary, index) (see Binary Features § Reading one feature back).

interface InstanceProvenanceEntry {
tileKey: string; // stable z/x/y/t::layer key
featureIndex: number; // index within that (tile, layer)'s BinaryFeatures
}
class InstanceProvenance {
push(tileKey: string, featureIndex: number): void;
get length(): number;
resolve(instanceIndex: number): InstanceProvenanceEntry | null;
}

InstanceProvenance accumulates one entry per instance as a merged-buffer builder emits it, so a decoded pick index resolves back to the originating (tile, layer, feature). This matters specifically for @poopdeck.gl/three, which merges resident tiles into one InstancedMesh per layer — the merge builders push provenance in the same order they emit instances. deck.gl answers picking through its own upstream picking-color attribute mechanism (GPU render + readback) and does not need this ledger, since each visible tile there keeps its own sublayer with intact per-tile feature indices. The GPU render/readback itself (three's GpuPicker, a CPU ray-OBB test, Cesium's id-buffer) stays per-backend outside this kernel.

core/tileset-adapter#

type TilesetFetchCallbacks = Pick<
SpatioTemporalTilesetOptions,
| 'getAvailableTiles'
| 'getTileData'
| 'getTileDataBatch'
| 'getTileByteSize'
| 'getThroughput'
>;
function makeTilesetCallbacks(archive: STTArchive): TilesetFetchCallbacks;

The single adapter that wires an STTArchive onto the fetch-callback subset of SpatioTemporalTilesetOptions (see SpatioTemporalTileset): getAvailableTiles routes through the archive's bulk range coalescer (getTileIdsInBounds); getTileData/getTileDataBatch forward the batch hooks (onTileReady, fetchPriority, playheadTime, playheadDirection) so the shared request scheduler can rank range-groups comparably across archives; getTileByteSize/getThroughput proxy directly. All three renderer backends (@poopdeck.gl/layers's SpatioTemporalLayer, @poopdeck.gl/three's StreamingTileSource, @poopdeck.gl/maplibre's STTBaseLayer) call this and then spread the result into their own SpatioTemporalTileset options alongside the layout/lifecycle fields it does not cover — minZoom/maxZoom, refinementStrategy, onTileLoad/onTileUnload, onBufferChange, and so on.

core/capabilities#

The cross-backend vocabulary — layer kinds, cross-cutting capabilities, time-filter modes — plus the declare-and-prove machinery every backend's backend-descriptor.ts is built from. The vocabulary is a frozen as const array/union pair per axis, so renaming a token is a compile break in every backend rather than a silent drift; TimeFilterMode is re-exported from core/time-filter so there is exactly one definition of the animation modes.

const LAYER_KINDS = [
'point',
'path',
'polygon',
'arc',
'line',
'icon',
'column',
'trips',
'tripHeads',
'boundingBox',
'surfel',
'heatmap',
'h3Summary',
'quadbinSummary',
'flowmap',
'flowCorridor',
'flowStroke',
'isoLines',
'ego',
'text',
'mesh',
'pointCloud',
'hexbin',
] as const;
type LayerKind = (typeof LAYER_KINDS)[number];
const CAPABILITIES = [
'globe',
'picking',
'extrude3d',
'metricSizing',
'gpuHeatmap',
'liveBundling',
'timeAsHeight',
'interleavedBasemap',
'userExtensions',
'cameraRoll',
] as const;
type Capability = (typeof CAPABILITIES)[number];
type LayerKindSupport =
| { supported: true }
| { supported: false; fallbackKind?: LayerKind; reason: string };
type Degradation =
| { action: 'fallback'; toKind: LayerKind; lost: Capability[] }
| {
action: 'fallbackMode';
fromMode: TimeFilterMode;
toMode: TimeFilterMode;
lost: Capability[];
}
| { action: 'skip'; reason: string }
| { action: 'throw'; reason: string };
interface BackendDescriptor {
readonly id: string;
readonly capabilities: Readonly<Record<Capability, boolean>>;
readonly timeFilterModes: readonly TimeFilterMode[];
readonly layerKinds: Readonly<Record<LayerKind, LayerKindSupport>>;
readonly projectsOnCpu: boolean; // three/Cesium: true; deck: false
readonly tilesetOwnership: 'per-layer' | 'shared'; // deck/three: shared; maplibre: per-layer
readonly pickMechanism: 'gpu-id' | 'cpu-ray' | 'id-fbo' | 'host' | 'none';
readonly interleavedBasemap: boolean;
readonly basemapProjection: 'mercator' | 'globe';
}

Each of the four packages (@poopdeck.gl/layers, @poopdeck.gl/three, @poopdeck.gl/maplibre, @poopdeck.gl/cesium) ships its own backend-descriptor.ts implementing one BackendDescriptor against this contract; the generated matrix at backend-capabilities.md is produced from those four descriptors by core/capabilities-doc's renderCapabilitiesMarkdown.

interface SttRenderNode {
readonly id: string;
setTime(absoluteMs: number): void;
setViewState?(v: ViewState): void;
pick?(
cssX: number,
cssY: number,
o?: { mode?: 'hover' | 'click' },
): SttPickResult | null | Promise<SttPickResult | null>;
dispose(): void;
}

SttRenderNode is the one shared runtime shape — duck-typed, not a base class. A three STTLayer, a deck sublayer wrapper, a maplibre STTBaseLayer, and a Cesium Primitive all satisfy it.

function degradeRequest(
d: BackendDescriptor,
kind: LayerKind,
mode?: TimeFilterMode,
): Degradation | null;
interface ConformanceEvidence {
capabilities: ReadonlySet<Capability>;
layerKinds: ReadonlySet<LayerKind>;
timeFilterModes: ReadonlySet<TimeFilterMode>;
}
function assertDescriptorConsistent(
d: BackendDescriptor,
proven: ConformanceEvidence,
): string[];

degradeRequest resolves how a backend handles a requested (kind, mode): null when fully supported; otherwise a typed Degradation (kind is checked first — an unsupported kind can't render regardless of mode). assertDescriptorConsistent is the over-claim gate: it returns one violation string per capability/kind/mode a descriptor claims that has no passing entry in the supplied ConformanceEvidence, so a descriptor cannot silently drift ahead of what its backend's test suite actually proves.

Import surface#

Every sub-path above resolves through packages/core/package.json's exports map to a dist/render/* or dist/geo/* build output — none of them are re-exported from the package's . entry point (@poopdeck.gl/core itself only exports the reader surface: STTArchive, SpatioTemporalTileset, decodeTile, the default palettes, and so on). Import the sub-path directly:

import { timeFilterAlpha, relativizeTime } from '@poopdeck.gl/core/time-filter';
import { GlobeProjection } from '@poopdeck.gl/core/geo';
import { expandCategoricalColors } from '@poopdeck.gl/core/style';

See also#

Source#