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

poopdeck.gl
Architecture

How spatiotemporal tiles work

Map tiles solved "the world is too big to send" by cutting space into a pyramid. STT applies the same cut to time: every tile is also sliced into time buckets, packed into static files, and streamed like video — buffered ahead of a playhead, animated entirely on the GPU. This page is the whole system, end to end.

01The pipeline

Four stages, no servers

One Rust CLI turns timestamped rows into an archive of static files; one TypeScript reader streams them into any renderer. Between the two there is only an object store — nothing computes per request.

01Source

Rows with geometry + time

  • GeoParquet / ParquetWKB, GeoArrow or lon/lat columns
  • PostGIS--postgres · table or SQL, reprojected server-side
  • DuckDB--duckdb · scans CSV, GeoJSON & Parquet too

Every row: a point, line or polygon in WGS84 plus start (and optional end) timestamps.

02Build

stt-build — the Rust tiler

  • Normalize timeISO 8601 / unix → milliseconds
  • Tile in spaceWeb-Mercator z/x/y across the zoom range
  • Cut in timeeach tile splits into buckets (default 1 h)
  • EncodeArrow columns; the schema is hoisted, not repeated
  • Compress + dedupzstd per blob, blake3 collapses identical bytes
  • Packblobs packed into 64 MiB objects + a directory

cargo install spatiotemporal-tiles

03Publish

A folder of static files

  • manifest.jsontiny + mutable — names the current build
  • index/<blake3>.sttdthe tile directory, immutable
  • packs/<blake3>.sttptile blobs, immutable, range-read

Any object store behind any CDN. No tile server, no database, cache-forever URLs.

04Play

Streamed like video

  • @poopdeck.gl/corerange requests, off-thread Arrow decode
  • @poopdeck.gl/playbackclock + buffering governor
  • GPU time gatetwo uniforms per frame, zero rebuilds
  • Four renderersdeck.gl · MapLibre · Three (WebGPU) · Cesium

npm i @poopdeck.gl/layers

02The idea

Tiles cut in space and time

A classic tile is addressed by (z, x, y). An STT tile adds a fourth coordinate: the start of its time bucket. That one extra axis is what makes a map scrubbable — the viewer fetches only the buckets around the playhead, for only the tiles in view.

Space — a tile pyramidz = 5z = 6z = 7Time — the same tile, cut into bucketsone archive day, 1-hour buckets · address = (z, x, y, t)00:0003:0006:0009:0012:00playheadfetching tile (z = 7, x = 41, y = 52, t = 04:00)The viewer streams only viewport ∩ zoom ∩ time window — never the whole archive.
playedon screenbuffered runwayfetchingnot requested

Deeper: the time axis has zoom levels too

z 0–430-day bucketsa planet scrubs monthsz 5–81-day bucketsa region scrubs weeksz 9–141-hour bucketsa street scrubs a daythe same instant, three tiers — one tile of data at every altitude

--temporal-lod "1d@8,30d@4" bakes aggregate tiers whose buckets are strict multiples of the base bucket; each directory entry records its own temporal_bucket_ms, so the reader dispatches per zoom with no special cases. Dense-then-quiet datasets can instead use --adaptive-temporal N, which sizes windows by feature count rather than the clock.

Deeper: long-lived features live in one blob — the reader looks back

An interval feature is written once, in the bucket of its start time — never copied into every bucket it spans. So rendering time T means fetching the bucket that contains T and looking back to earlier buckets that still hold live features.

look-back window · bounded by cover_t_min06:0007:0008:0009:0010:0011:00one archive day · 1-hour buckets · a feature sits in its start bucket onlycover_t_min = 08:20ended 08:10 · past08:20 → 10:40 · still on screen09:15 → 10:20born in the 10:00 bucket11:10 · not yeteach dot = the one bucket a feature is stored in; the bar = its lifespanplayhead · T = 10:00
stored in start bucketfetched (look-back)skipped
×3 copies

The naive alternative duplicates each feature into every bucket it overlaps, so a long-lived trip, storm or vessel track is written N times and the archive balloons. The look-back avoids that: store once, pay a bounded read.

cover_t_min is a tight lower covering bound recorded per directory entry — the 08:00 bucket advertises 08:20, so the reader knows exactly how far back to walk and stops once an earlier bucket's newest feature has already ended. When it is None (repacked or pre-covering archives) the reader falls back to time_start: correct, but it looks all the way back to the bucket start.

Deeper: trips are clipped at tile seams — the crossing vertex is interpolated in time

A trajectory carries a timestamp per vertex. When it crosses a tile boundary the build clips it at the border and splits it into one self-contained sub-path per tile. The crossing point becomes a new border vertex whose position lands on the edge and whose time is interpolated between the two real vertices — so each tile owns a complete little polyline with continuous times.

Space — two neighbouring tiles, one trajectorytile (x, y)tile (x+1, y)keeps t₀ … t_crosskeeps t_cross … t₃seam · split heret = 00:00t = 00:12t = 00:31t = 00:48border vertex — inserted at the clip• position → interpolated onto the tile edge• time → linearly interpolated (t ≈ 00:19)t_border = t_a + (t_b − t_a) · ff = fraction of the a → b segment left of the seamclipped once, at build — each tile carries a complete polyline, so no cross-tile fetch is needed to draw a partial trip
real vertexinterpolated border vertexper-tile sub-path
|Δlon| > 180°−180+180

Edge case: a segment whose longitude jumps more than 180° straddles the dateline. Left whole, the clamped tile walk draws it the long way and bakes a globe-spanning sliver into every column between — so it is split at the wrap first. Dateline wrap → split, so a line never smears across the globe.

Clipping runs once, at build — clip_trajectory during per-tile placement — so every tile ships a self-contained ClippedSegment and the viewer never fetches a neighbour to finish a partial trip. The load-bearing detail is that the border vertex carries an interpolated time, not just a position: interpolate_timestamp gives the left tile's last frame and the right tile's first frame the same clock, to the millisecond. Without it, trips flash and teleport at tile borders.

03The archive

Three files, three round trips

Millions of tiles would mean millions of URLs. Instead the archive is a handful of immutable, content-addressed objects: a directory that knows where every tile lives, and packs read by HTTP range. Anything that can serve a static file can serve STT.

Three kinds of file — one mutable, two immutable

my-dataset/
├─ manifest.jsonmutable · ~1 kBnames the current directory + packs — the only short-TTL fetch
├─ index/
│ └─ 9c41…f2.sttdimmutablethe tile directory, content-addressed by blake3 — cache forever
└─ packs/
├─ 0e49…b2.sttpimmutable · ≤ 64 MiB targetzstd tile blobs, read with HTTP range requests (a single oversized blob gets its own larger pack — blobs are never split)
└─ a7d3…91.sttp

A new build writes new hashed objects and flips one pointer in manifest.json — deploys are atomic and additive, and every heavy object is cacheable forever. To hand the dataset to someone rather than serve it, stt-bundle pack folds the tree into one .sttb file and back out again, re-verifying every content address on the way.

Cold start = three round trips

1GET manifest.json2GET directory root page3ranged GETs into packs

After that, panning, zooming and scrubbing only issue range reads into immutable packs — there is no per-tile URL and no server doing work.

The directory: 52 bytes per leaf up front, ~1 byte per tile inside

One object holds every tile address in the archive, and it has to stay small enough to fetch cold. Two mechanisms do that: a root page that rules whole leaves out before they're downloaded, and a body that exploits how little consecutive tile keys differ.

Above: it prunes before anything is fetched

The root page carries one 52-byte descriptor per leaf — zoom range, geo bbox and time span. Only leaves overlapping the query are downloaded.

query: northeast viewport × z 7 × 08:00–11:00
leaf 0z 5–9skip — wrong place
leaf 1z 5–9fetch
leaf 2z 5–9skip — wrong time
leaf 3z 10–14skip — wrong zoom

Below: sorted keys plus run-length encoding

Entries sort by (zoom, hilbert(x, y), time) — the 2D walk drawn in the next figure — so consecutive keys differ by almost nothing and delta-code down to about a byte each.

entry key (delta-coded)featuresblob run (written once per run)
z7 · h2841 · 08:00214run ×3 → pack 0 @ 0, 41 kBthree identical hours — blob columns written once
z7 · h2841 · 09:00214
z7 · h2841 · 10:00214
z7 · h2841 · 11:00890run ×1 → pack 0 @ 41 kB, 96 kBoffset stores 0 — it follows the previous blob
z7 · h2842 · 08:003 102run ×1 → pack 0 @ 137 kB, 210 kBnext Hilbert cell — still sequential
z7 · h2842 · 09:002 977run ×1 → pack 0 @ 347 kB, 205 kB
keys delta-code to ~1 byte (LEB128 + zig-zag)crc32c integrity tag per blob.sttd itself is zstd at rest (~2×)cover section: tight t_min per entry

This is the temporal analogue of PMTiles collapsing identical ocean tiles across space: because the writer dedups byte-identical blobs, a cell that doesn't change across consecutive buckets costs one run instead of N entries' worth of blob columns. Tens of thousands of tiles index in a few hundred kilobytes.

Deeper: laying the space-time cube down in a line

A blob's address has three axes — (x, y) on the map plus a time bucket — but a pack is one flat byte string. --blob-ordering chooses the space-filling walk that linearizes the cube. Pick a walk, then play a real map+time gesture and watch which bytes it touches: the walk decides how many range reads the gesture costs.

walk
gesture
timethe map plane (x, y)
reading now (the gesture's viewport)already read this passstill to readthe walk: step / jump (a seek)

Fixed viewport, the playhead runs forward — the classic playback loop. Each visible tile wants its whole timeline in one pull, so the time-deep spatial walk (and the 3D generalist) read it as a single run; time-major seeks on every tick.

byte 0end of pack

1 range read for the play gesture under hilbert3 — watch the brackets fuse as it plays, before gap-coalescing merges the near-misses too.

walkseeksplaypanpan+playzoom+playclick
spatial1511610121
hilbert3014532
time-major341634

Range reads per gesture. Notice the cube walk never spikes — 1·4·5·3·2, no catastrophes — while the specialists each blow up on the gesture they ignore.

--blob-ordering auto — the default — reads the archive's occupied space-vs-time extent and picks: time-deep datasets (a buoy's four-year track over a few cells) get spatial, because playback wants each cell's whole timeline in one pull; balanced or space-heavy datasets (a day of global flights) get the hilbert3 generalist. To decide from measurement instead of shape, build with --blob-ordering measured or audit an existing archive with stt-optimize order-audit, which simulates the range-read cost of each walk over the real directory. Either way the directory index keeps its own (zoom, hilbert(x,y), t) sort — the knob permutes bytes inside packs, never keys.

Deeper still: the same walk on real archives

The cube above is idealized — every cell full. Real archives are sparse and lopsided, and that shape is exactly what --blob-ordering auto reads to pick a walk. Switch datasets and watch the winner flip: a buoy's multi-year track over a few cells wants spatial; a day of flights wants the 3D-Hilbert generalist. Density is sampled from each archive's directory.

dataset

Loading density profile…

Packs — dedup by content, fetch by range

directory entries (tile addresses)…z7·x41·y52·t08z7·x41·y52·t09z7·x41·y52·t10z7·x41·y52·t11z7·x41·y53·t09z7·x41·y53·t10identical bytes → one blob (blake3)packs/0e49f7a4….sttpzstd blobzstd blobzstd blobzstd blobzstd blobone coalesced HTTP range request (gaps ≤ 2 MiB are fused)
04The payload

Inside a tile

Each tile is columnar Apache Arrow — the layout GPUs and analytics engines already speak. Three required columns describe points, paths and polygons in time; optional columns layer on trips, per-vertex values, splats and pre-aggregation.

A tile is a sectioned frame — and its schema isn't in it

u16 0xFFFF
u8 ver = 2
u16 layer count
name "default"
schema ref → blake3-128
section TOC
pad → 8 B
TILE_METAper-tile JSON: t0 · st/et · qa · vt · vb · vq
CORE_BATCHid · times · geometry · vertex columns
PROPS_BATCHyour property columns — omitted if none

Arrow schemas are dataset-constant, so shipping one per tile pays for the same bytes tens of thousands of times. Instead each layer carries a 16-byte blake3 reference into a schema table embedded in manifest.json; only genuinely per-tile metadata rides in TILE_META. Splitting geometry from properties lets a reader decode a tile's shape without touching its columns. Every section pads to 8 bytes and the IPC writer is pinned to 8-byte buffer alignment, so column buffers hand to the GPU without copying.

…and the batches are columns

identityidUInt64stable feature id — picking and cross-tile identityrequired
timestart_timeUInt32 Δ from t0offset from the layer's own t0 by default; absolute Int64 when a tile needs itrequired
spacegeometryGeoArrow pt · line · polyinterleaved f64 — or fixed-point ints on a world-anchored stt:quant gridrequired
timeend_timeUInt32 durationdropped entirely when every feature is instantaneous — true of most event dataoptional
per-vertex timevertex_timeList<UInt16|UInt32>a timestamp per vertex (origin + step in TILE_META.vt; exact Int64 fallback) — trips & trailsoptional
per-vertex valuesvertex_valueList<Float32|UInt16>one scalar per vertex, NaN = no sample — e.g. temperature along a trackoptional
per-vertex valuesvertex_value_matrixList<Float32|UInt16>vertex-major × time buckets (TILE_META.vb) — static geometry, animated valuesoptional
geometry extrastrianglesList<UInt16|UInt32>pre-baked earcut indices — polygons skip tessellation on loadoptional
geometry extraspart_offsetsList<UInt32>MultiPolygon part boundaries — without it, parts 2..n read as holesoptional
props batch<your columns>Float64 · Dict<UInt16,Utf8>numeric + categorical props; optionally fixed-point ints + a TILE_META.qa affineoptional
props batch<vector groups>FixedSizeList<f32|u8, N>interleaved quats / colors / scales — bound to the GPU zero-copyoptional

A tile of simple events costs three columns — most event data is instantaneous, so end_time isn't written at all. Trips, splats, flow corridors and summary tiers are the same container with more columns switched on; there is no separate format per layer type.

Deeper: how the numbers shrink

Every quantization is opt-in and ships its own inverse, so the reader reconstructs real values on decode and nothing downstream knows the wire was smaller. Each one also declares itself in manifest.capabilities — a reader that doesn't implement it refuses the dataset at open rather than silently drawing integer grid indices as degrees.

coordinates

lon = x0 + q · sx

Vertices snap to one grid anchored at the world corner (−180°, −90°), constant across the dataset, sized to your ground precision. Because every tile shares it, identical geometry stays byte-identical — a per-tile grid would give the same point different indices per tile and defeat blob dedup (measured +61%). Affine lives in stt:quant.

f64 8 B → int 4 B
vertex timesoriginstep = 250 ms

t = origin + q · step

Trajectory timestamps become UInt16 step counts, widening to UInt32 when a tile's span needs it and falling back to exact Int64 past the --vertex-time-precision ceiling.

i64 8 B → u16 2 B / vertex
attributesminmax65 536 levels across the range

value = o + q · s

--quantize-attrs-auto maps every Float64 property onto 65 536 levels of its own range; --quantize-attr z=0.05 pins one column to a fixed precision instead. The per-column affine rides in TILE_META.qa.

f64 8 B → u16 2 B / value

Integers also compress better: small deltas and repeated values give zstd far more to work with than float mantissas, so the wire savings compound beyond the raw width cut. On dense LiDAR archives the combination has measured 4–6× end-to-end.

Fetched bytes decode on a worker pool; two cache tiers skip the work entirely

UI threaddeck.gl render loopstays at 60 fpsnever decodes on-threadtwo-tier cache — checked firstRAMin-memory LRU · compressed bytesdevice-aware cap (scales w/ deviceMemory)OPFSon-disk · decompressed payloads (opt-in)keyed by directory fingerprint · 512 MBnetworkHTTP range readcontent-addressed pack (R2)workerpool · N = max(2, min(4, cores−1))decompresszstd · ~30 kB JSparseArrow IPCextractcolumn bufferseach buffer is TRANSFERRED back — moved, not copiedgetTile(z,x,y,t) → look up cacheOPFS hit · decoded→ straight to UI (skips workers)RAM hit · compressed→ decodeboth missfetched pack → decodezero-copy transfercolumn buffers → GPUscroll a tile off-view ⇒ its in-flight decode is cancelled mid-pool
least-pending dispatchcancel on scroll-offredeploy ⇒ new fingerprint ⇒ clean invalidation

Decoding one tile inline is ~5–20 ms of decompress + tableFromIPC + buffer extraction — a whole frame gone. So each fetched slice goes to the least-pending worker instead, and the decoded typed arrays are transferred back rather than copied; the render loop never touches the work. The cache tiers are why a second visit is instant: RAM skips the network, OPFS skips the network and the zstd step. And because the OPFS key is the directory's content address, a redeploy mints new keys and stale tiles simply stop being found — no walk-and-delete.

05The runtime

Streamed like video

The runtime treats sim-time the way a video player treats seconds: buffer a runway ahead of the playhead, gate the clock on it, and never let the picture outrun the data. Rendering stays cheap because animation is a shader, not a data update.

The governor buffers sim-time like a video player buffers seconds

playednot yet fetchedbuffered runway — contiguous, fully resident sim-timeplayheadstall watermark · 0.6 s × speedstart gate · 2 s × speed (resume needs 2×)
startingplayingbufferingplaying— the clock freezes rather than let the playhead outrun the data; if the network can't keep up, auto-speed downshifts instantly and upshifts cautiously.

The governor is a small state machine — it degrades before it freezes

idlestartingfill the runwayplayingclock runsbufferingclock frozenseekingpost-scrub gatedegraded creeppin to data frontierplay()gate ✓≥ 2 s × speedrunway <watermarkresumegate · 2×scrubseek gate8 s escape hatch(also from starting)frontier catches up
canplaythrough predictor

A gate also passes when the missing bytes are predicted to arrive within the wall time the current runway buys — so a cold seek on a fast link starts almost instantly instead of demanding seconds of resident sim-time up front.

degraded creep, not a freeze

If a gate can't pass within maxStartWaitMs (~8 s), the governor stops waiting and pins the playhead to the data frontier, advancing at arrival rate — motion never lurches, and it re-tightens the moment the network recovers.

The governor never touches the network itself — it sees the loader only through a small BufferSource seam (getBufferedRunway, estimateCost, flushPrefetch, setAnimationState), which is why @poopdeck.gl/playback stays zero-dependency and renderer-agnostic.

Per frame, the GPU alpha-gates every feature — buffers never rebuild

currentTime (uniform)window = currentTime ± windowHalffade in / fade out at the edgeseach bar = one feature's [start_time, end_time] — stored once as GPU vertex attributes

Per-feature (and per-vertex) times upload once as attributes. Each frame updates a handful of uniforms and the vertex shader gates alpha; fully hidden vertices collapse to a degenerate position so they cost no fragments. Times are relativized against a per-tile offset before upload, keeping f32 attributes millisecond-accurate (relative windows cap at 2²⁴ ms ≈ 4.6 h). The identical gate math runs as deck.gl GLSL, MapLibre GLSL and Three TSL.

Deeper: four looks, one mechanism — alpha as a function of age

window

visible around the playhead, fade ramps at both edges

trail

alpha = 1 − age / trailLength — comet tails per vertex

wake

long low tail, bright head — size shrinks toward the tail too

cumulative

everything up to the playhead stays — build-up stories

Dashed line = the playhead. Switching modes changes a uniform, not the data — the same buffers render as a sliding window, a comet trail, a vessel wake or an accumulating record. A separate injection can also map age to height, turning any dataset into a space-time cube.

The control loop — two packages, one seam

TimeControllerwall clock × speedPlaybackGovernorgates the clock on runwayBufferSourceTilesetcoverage probe + time-ordered prefetchSharedRequestSchedulerEDF urgency + DRR fairnessHTTPcoalesced range reads
@poopdeck.gl/playback · zero-dep clock + governor@poopdeck.gl/core · loader + scheduler

In deck.gl apps the controller rides deck's own frame loop (useDeckClock mirrors it onto context.userData.stt), so STT layers stay cached layer instances that simply redraw. MapLibre, Three and Cesium bridge their render loops the same way.

Deeper: many datasets, one playhead

taxi flowsrequiredweight 18.2 shealthy — waiting on the slowest sibling
lidar sweeprequiredweight 42.1 sthe binding constraint — it sets the shared clock's pace
poi labelsoptionalweight 10.4 soptional — may lag, can never stall the clock

The governor folds health over the required set only — combined runway is the minimum, completeness is the AND. The shared scheduler then splits the connection budget by deficit round robin (the lidar's weight 4 earns 4× the bandwidth) and orders requests earliest-deadline-first, where a request's deadline is its distance from the playhead.

auto-speed ladder
0.25×0.5×0.75×1×1.5×2×2.5×3×4×5×6×8×10×

When throughput can't sustain the requested speed, playback steps down this ladder immediately; it steps back up only after sustained headroom clears a deadband — asymmetry borrowed from video ABR, so the picture degrades fast and recovers calmly instead of oscillating.

06The levers

What keeps it small and smooth

Every lever below is measured and recorded in the archive's own metadata, so the reader reconstructs exact values on decode. The lossless ones are on by default; anything that trades precision for bytes is opt-in. Together they compound to 4–6× smaller archives that still animate at 60 fps.

Smaller archives

Cut bytes at build time. The lossless levers run by default; the ones that trade precision for size are opt-in, exact to a declared precision, and undone on decode.

Coordinate quantization

Geometry stored as fixed-point integers on one world-anchored grid — the same grid for every tile, so identical geometry stays byte-identical and content-addressed dedup survives — at a chosen ground precision, a fraction of float64 with no visible loss.

--quantize-coords · stt:quant

Attribute quantization

Every Float64 property maps onto 65 536 levels of its own range, plus a tiny affine to reconstruct real values on decode — or pin one column to a fixed precision.

--quantize-attrs-auto · TILE_META.qa

Compact feature times

On by default: start_time becomes a UInt32 offset from the tile’s own t0, and end_time vanishes entirely when every feature is instantaneous. Measured a third of a point archive’s bytes.

TILE_META.st / .et

Shared schema templates

Arrow schemas are dataset-constant, so tiles reference one by 16-byte blake3 hash instead of repeating ~900 B of it each. Single-digit templates cover a whole dataset.

manifest.schemas

Per-blob zstd

Every tile blob is an independent zstd frame, so the browser decodes with a ~30 kB pure-JS decoder — no WASM, no shared dictionary.

--zstd-level 1…22 · --publish

Content-addressed dedup

Blobs are stored once, keyed by blake3. Byte-identical time buckets collapse — static or quiet periods cost almost nothing.

packs/<blake3>.sttp

Simplify + pre-tessellate

Time-aware line simplification keeps the motion and drops the noise, at a latitude-corrected ground tolerance; polygons can bake their earcut triangles so clients skip tessellation.

--time-aware-simplify · --pre-tessellate

Fewer, smarter fetches

Move only the bytes the current viewport, zoom and playhead actually need.

Paged directory

The default: the tile index ships in pages, and 52-byte root descriptors prune whole leaves by zoom ∧ bbox ∧ time span before any fetch.

layout: "paged" · --page-entries

Range coalescing

Neighbouring blobs in a pack fuse into one HTTP range request when the gap is under 2 MiB — Hilbert ordering makes neighbours common.

HTTP 206 · coalesceGapBytes

Temporal LOD

Coarser time buckets at low zooms: a continent view scrubs days per tile while street level streams hours.

--temporal-lod "1d@8,30d@4"

Summary tiers

Pre-aggregated H3 or quadbin cells serve the overview zooms, so a hundred million points still open instantly.

--summary-tier quadbin

Zoom bands

Each feature carries a [min, max] zoom range — clustered overviews own the low zooms, full resolution appears as you dive.

--min/max-zoom-field

Tile budgets

Opt-in per-tile caps on bytes or features drop the lowest-importance rows to fit, and log exactly what was dropped where.

--maximum-tile-bytes

Smoother frames

Keep the render thread idle: animation lives in the shader, decode lives in workers.

GPU time gate

Feature and vertex times upload once as attributes; each frame changes a few uniforms and the shader alpha-gates — no rebuilds, ever.

TimeFilterExtension

Zero-copy GPU columns

Interleaved FixedSizeList columns (quaternions, colors, scales) bind to the GPU exactly as decoded — no main-thread repacking.

--vector-group

Off-thread decode + OPFS

Workers decompress and parse tiles off the UI thread; decoded tiles persist in the browser's private file system across visits.

createDefaultTileDecoder

Space-time values

A per-vertex × per-bucket value cube animates static geometry — streets breathing with hourly traffic while their shape ships once. Optionally UInt16-quantized, the only lever those columns have.

vertex_value_matrix · --quantize-vertex-values
07The analyst

The build tunes itself

The levers above are powerful and easy to misuse, so the toolchain ships its own analyst. It profiles the source, measures each lever on a real sample of the data, and recommends flags with a confidence grade — while a firewall guarantees it never throws data away to hit a size target unless you say so.

The pipeline gains a build-time analyst

analyze · recommendprofile source · zoom + bucket + advicestt-build --autobasic = zoom + bucket · encode = + byte leversinspect · order-auditper-column cost · range-read costdoctor · diffaudit + before/after · CI gatesre-tune next build

stt-optimize wraps the tiler with a loop that runs entirely at build time — nothing here touches the serve path. analyze picks the zoom range and temporal bucket and runs the advisors; --auto folds the safe ones in (bare, it only fills zoom + bucket; --auto encode also applies the non-lossy byte levers); then inspect, diff and doctor turn the manual tuning passes this project kept re-running by hand into commands CI can gate on (diff --fail-on-growth, doctor --strict).

Measure, don't guess — every projection is a real encode

source rowsdeterministic stride samplereal stt-core encoder + zstdper-column bytes
attributed compressed cost (each column re-encoded alone)
id 41%
geometry 34%
properties 18%
7

Instead of a size formula, the analyzer keeps a deterministic stride sample of real geometries and property values and pushes it through the production encoder plus zstd, then attributes cost by re-encoding each column alone. That is where a projection like −36% sample encode (measured) comes from — and how the doctor knows a near-incompressible id column, not the geometry, is eating the archive.

From analysis to advice — with a no-thinning firewall

densitygeometryspatialtemporalproperties
quantize advisortemporal advisorlayout advisorbudget advisor

five analyzers profile the source; four advisors turn that profile into flag recommendations, each carrying a measured projection and a confidence grade.

Reversible → auto-appliable

Byte-level, undone on decode. Folded into --auto encode and appended to the pasteable command, in advisor order. Advice that carries a real tradeoff (spatial ordering hurts pan) is flagged suggestion_only and steps out of both.

--publishhigh
decode-free win at rest (zstd 19)· −14% (measured)
--blob-ordering spatialhigh
time-deep, few cells· measured-best over 4 812 tiles
--pack-size 128medium
estimated archive ~7 GiB· ~56 objects instead of ~112
Lossy → surfaced only, never auto

Degrades data. Reported loudly with its projection, but you add it by hand — never in --auto, never in the command.

--quantize-coords 1high
coords dominate the bytes· −31% (measured)
--quantize-attrs-automedium
raw f64 property columns· −9% (measured)
--maximum-tile-features 20kmedium
p99 tile is 12× the median· drops features to fit

This split is the project's no-thinning principle made mechanical: a build never silently throws data away to hit a size target. Quantization and per-tile budgets can shrink an archive a lot — so they stay a deliberate, per-dataset choice, and the default build is byte-identical whether or not you ran the analyzer.

The doctor — severity-ranked findings on the built tileset

sevfinding (stable code)what it smellsremediation
expensive-feature-idsnear-incompressible hash-like ids dominate the bytessequential ids / drop
raw-f64-columnplain Float64 property columns worth quantizing--quantize-attr
missing-summary-tierhuge point dataset with no aggregated overview--summary-tier quadbin
unpaged-largewhole-load directory on a large tile countlayout: "paged"
oversized-blobsindividual tiles past 1 MiB compressedraise min-zoom / split
z0-bombdeep pyramid under a tiny geographic extentclamp zoom range
dead-columnsconstant / all-null property columnsdrop at source

Each rule keys off numbers already measured for this tileset — directory stats and per-column costs from inspect — and cites them in its message; the doctor never re-encodes, so every delta is labelled (estimated from measured column costs). Run it with --strict and a fresh smell fails the build.

Deeper: the archive describes how to style itself

Every non-streaming build already bakes the cheap signals into manifest.json — the dominant layer kind and a suggested playback duration — so a client can open a strange archive and pick a clock with no config. --style-hints adds the expensive part: a full per-property profile with percentiles and a suggested colour domain.

"style_hints": {
  "version": 1,
  "layer_hint": "trips",              // always baked
  "suggested_playback_seconds": 45,   // always baked
  "properties": [                     // --style-hints only
    { "name": "speed_kmh", "min": 0, "p50": 14.2, "p95": 31.6,
      "p97": 34.0, "p99": 41.8, "max": 58, "suggested_domain": [0, 34] },
    { "name": "route_id", "cardinality": 214 }
  ]
}

The domain clamps at p97, not max — one outlier must not dim the whole ramp. Values are sampled at a deterministic stride (capped per property), so the block is byte-identical across rebuilds of the same input, safe to content-address alongside everything else.

08The parts

Crates and packages

The format lives in one Rust crate and one TypeScript package; everything else is a thin consumer. The same decoded tiles drive deck.gl, MapLibre, Three.js and Cesium — pick the renderer your app already uses.

Build time — the Rust crates

spatiotemporal-tilescrates.io · one install, five CLIsstt-build · stt-optimize · stt-validate · stt-bundle · stt-servestt-buildthe tiler / encoderstt-optimizeanalyzer — powers --autostt-corethe format — packs · directory · Arrow tiles · projection

Alongside them: stt-wasm, the WebAssembly decoder, and stt-generate, the demo-dataset generator behind every showcase demo — it fetches real data, writes GeoParquet and shells out to stt-build.

Runtime — the npm packages (@poopdeck.gl)

/layersdeck.gl catalog/maplibrecustom layers/threeTSL · WebGPU/cesiumWGS84 globe/reacthooks + UI@poopdeck.gl/coredecoder · tileset · scheduler…/playbackclock · governor · zero depstiles (depends on core)clock (depends on playback)

One decoder, one clock: every renderer consumes the same decoded tiles from core. layers, three and react import the playback engine directly; MapLibre and Cesium take no dependency on it and drive from any clock through a small structural interface. An eighth package, /mcp, exposes the dataset catalog and docs to AI agents and depends on neither.

Who renders what — the capability matrix

layer kinddeckthreemaplibrecesium
point
path
polygon
arc
line
icon
column
trips
tripHeads
boundingBox
surfel↳ point
heatmap↳ point
h3Summary
quadbinSummary
flowmap↳ line
flowCorridor↳ line
flowStroke↳ flowCorridor↳ line
isoLines↳ path↳ path
ego
text↳ icon↳ icon↳ icon
mesh↳ boundingBox↳ boundingBox
pointCloud↳ point↳ point↳ point
hexbin↳ h3Summary↳ h3Summary
time-filter modedeckthreemaplibrecesium
window
trail
wake
cumulative

deck.gl is the reference backend and the only one with no gaps; Three (WebGPU/TSL) tracks it closest and adds the ego/cockpit kinds; MapLibre renders most of the catalog as native custom layers on the basemap; Cesium takes the movement kinds natively on a WGS84 globe and degrades the rest to a simpler kind rather than dropping them.

native · ↳ falls back to a simpler kind · — unsupported. Generated from each backend's BackendDescriptor — the full, regenerated table lives in the spec.

One formula, every engine

render/time-filter.tsTHE CPU oracle · window / trail / wake / cumulative → alpharender/shader-codegen.tsthe SECOND oracle · same formulas, branchless, derived separatelyevalExpr == oracle over 2000 random envs╌╌ hand-written shader— runs the oracle itselfdeck.glGLSL ES 3.00hand-writtenMapLibreGLSL ES 1.00hand-writtenThree.jsTSL · WebGPU nodeshand-writtenCesiumCPU — no shadercalls the oracleconformance tests pin all four to BOTH oracleseach backend's JS reference == both oracles, and its shipped shader == that reference

This is the guarantee behind the package map above. The alpha math is defined once, as a framework-free CPU function in @poopdeck.gl/core — and then stated a second time, independently, as a branchless expression AST. Two derivations disagree wherever the spec is ambiguous, which one implementation can never reveal; that is how two out-of-contract inputs were found and pinned. Nothing here is machine-emitted. deck.gl, MapLibre and Three each hand-write the math in their own dialect, because each host wants it inlined its own way, and Cesium skips the shader entirely and runs the oracle on the CPU. What keeps them honest is a chain of assertions rather than a compiler: every backend keeps a JS reference of its shader math, tests pin that reference to both oracles numerically, and the shipped shader is locked to the reference. So a trail looks and times identically whether you draw it in deck.gl or on a Cesium globe — and changing the formula fails four test suites at once until every renderer follows.

09Rationale

Why it's built this way

Formats live or die on their constraints. These are the locked-in choices — each one traded away flexibility to buy something measurable, and each is written down in the spec so the trade stays visible.

Why static files instead of a tile server?

Tiling cost is paid once, at build. Serving is any object store behind any CDN — no capacity planning, nothing to fall over under load, and archives work offline or air-gapped. The entire runtime contract is HTTP GET plus range requests.

Static files — but can a tile be generated on the fly?

Yes, and it is the same tile. The offline tiler and the on-demand stt-serve share one EncoderConfig and per-tile encode path, so a dynamically served tile is byte-identical to what a batch build would have written — a live PostGIS or DuckDB query can back the map, and static hosting stays the default rather than the only mode.

Why Apache Arrow for the payload?

Columns arrive GPU-shaped and slice zero-copy, and the same schema opens in Rust, JS and Python with stock libraries. Everything the format adds rides in metadata rather than side files — the dataset-constant part in the schema itself (stt:quant), the per-tile part in the frame's own TILE_META section.

How does the format add features without breaking readers?

Two rules. Additive columns need no announcement — a reader that doesn't know part_offsets ignores it. But a feature that re-types an existing column (quantized coordinates become integers) would make an old reader silently misdecode rather than fail, so each one declares itself in manifest.capabilities and a reader that lacks it must refuse the dataset at open. Silent wrongness is the only failure mode the format treats as unacceptable.

Why zstd per blob, with no shared dictionary?

Every blob decodes alone, which is what makes random access real: any tile is readable from a single range read, the browser decoder is ~30 kB of pure JS, and caching never depends on fetching a dictionary first.

Why packs instead of a file per tile?

Millions of tiny objects make deploys, listings and cold caches slow. Packs target 64 MiB (blobs are never split), stay far under CDN object caps, upload in seconds and serve precise range reads — with gap coalescing fusing neighbours into one request.

Why blake3 content addressing?

One hash is simultaneously the dedup key, the cache key and the integrity check. Immutable URLs cache forever, a deploy is an atomic manifest pointer flip, stale objects age out via retention, and corruption is detectable end to end (crc32c guards each blob besides).

Why animate in the shader?

Rebuilding buffers per frame caps out orders of magnitude below what GPUs can draw. Attributes upload once; a frame is a handful of uniform writes — which is why a million features scrub at 60 fps and why scrubbing backwards is free.

Why four renderers over one kernel?

Teams already own a map stack. Decode, streaming, the clock and the definition of the gate math are shared; only the last inch is per-host — GLSL for deck.gl and MapLibre, TSL for Three's WebGPU path, a custom Appearance for Cesium — and conformance tests pin every one of those to the same CPU oracle.

Are builds reproducible?

Byte-for-byte, across processes. Blob order and directory order carry total tiebreaks, and the encoder assembles every metadata key from sorted maps that Arrow ≥59 then serializes in that order — so an unchanged dataset rebuilt in a fresh process re-derives identical content addresses, nothing re-uploads, and identical tiles across datasets share one physical object. (This was the format's one open gap under Arrow 54, whose writer emitted metadata in hash-map order; a formerly-#[ignore]d canary test now guards it.)

What happens if you never tune it?

Nothing is taken away. Every size budget and quantization lever is inert unless you opt in, so an untuned build is byte-identical to one you never analyzed — the no-thinning rule. Run --auto or --publish and any flag you set explicitly still wins (resolved from clap's ValueSource, not sentinels), while lossy advice is surfaced loudly but never applied for you.

Keep going