Faster Geospatial Animations with PMTiles: Multi-Layer MVT Time Series in OpenLayers

Jun 17, 20267 min read

Most people try to animate time series by stacking separate tile sources or flipping CSS styles mid-frame. That causes flicker and a surprising amount of wasted CPU/GPU work. The core takeaway is simple: bake your timesteps into a single vector-tile archive (PMTiles) with per-timestep named layers, load it once, and drive animation by re-styling already-decoded tiles.

I tested this with OpenLayers and noticed the difference right away: playback became smoother and far less CPU-bound once tiles were pre-baked and cached. The hard part is server-side — how you generate, package, and serve tiles matters more than which client library you pick.

When you animate a map by switching between separate tile sources, each frame causes one of two expensive operations: a network fetch of new tiles, or client-side re-decoding and re-styling. Baking timesteps into one archive avoids that by turning each frame into a cheap style re-evaluation on already-decoded features.

The architecture: single archive, dynamic layers

MVT (Mapbox Vector Tile) is a compact protobuf-based vector tile format that encodes geometry and attributes per z/x/y tile. PMTiles packages MVT tiles into a single file for efficient distribution and partial HTTP range retrieval.

The key workflow:

  1. Server side — each timestep is a separate GeoJSON file. tippecanoe joins them into one PMTiles archive, giving each timestep its own named MVT layer (timestep_00, timestep_01, …).
  2. Client side — one VectorTileSource loads the archive once. One VectorTileLayer uses a style function that reads a mutable currentLayerName variable. Advancing a frame means: update the variable, call layer.changed(). OpenLayers re-runs the style function on the already-decoded tile features and repaints — no network request, no re-decode.

Complete working example

Complete working example

This section contains everything needed to reproduce the demo from scratch: 11 per-timestep GeoJSON files, a tile generation script, and the client HTML.

Step 1 — Per-timestep GeoJSON files

Create 11 files, one per timestep. Each contains a single large polygon with a value property. The temporal identity comes from the file name (and the MVT layer name tippecanoe will assign), not from a property on the feature.

for i in $(seq 0 10); do pad=$(printf "%02d" $i) value=$((i * 10)) cat > "square_t${pad}.geojson" << EOF { "type": "FeatureCollection", "features": [{ "type": "Feature", "geometry": { "type": "Polygon", "coordinates": [[[-60,-50],[60,-50],[60,50],[-60,50],[-60,-50]]] }, "properties": { "value": ${value} } }] } EOF done

This produces square_t00.geojson (value=0) through square_t10.geojson (value=100).

Step 2 — Generate PMTiles with named layers (generate-tiles.sh)

#!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" tippecanoe \ --named-layer=timestep_00:"$SCRIPT_DIR/square_t00.geojson" \ --named-layer=timestep_01:"$SCRIPT_DIR/square_t01.geojson" \ --named-layer=timestep_02:"$SCRIPT_DIR/square_t02.geojson" \ --named-layer=timestep_03:"$SCRIPT_DIR/square_t03.geojson" \ --named-layer=timestep_04:"$SCRIPT_DIR/square_t04.geojson" \ --named-layer=timestep_05:"$SCRIPT_DIR/square_t05.geojson" \ --named-layer=timestep_06:"$SCRIPT_DIR/square_t06.geojson" \ --named-layer=timestep_07:"$SCRIPT_DIR/square_t07.geojson" \ --named-layer=timestep_08:"$SCRIPT_DIR/square_t08.geojson" \ --named-layer=timestep_09:"$SCRIPT_DIR/square_t09.geojson" \ --named-layer=timestep_10:"$SCRIPT_DIR/square_t10.geojson" \ -o "$SCRIPT_DIR/square.pmtiles" \ -z 8 -Z 0 \ --no-simplification \ --no-tile-size-limit \ --no-feature-limit \ -f

Run with: bash generate-tiles.sh

Step 3 — Start the server

npx serve

Step 4 — Client animation (index.html)

The full self-contained HTML file. No build step, no npm install — all dependencies come from esm.sh, which rewrites bare module specifiers (like pbf imported inside ol/format/MVT) to absolute CDN URLs that the browser can resolve.

<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>MVT Animation</title> <link rel="stylesheet" href="<https://cdn.jsdelivr.net/npm/ol@10.9.0/ol.css>" /> <style> * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: monospace; background: #111; color: #eee; display: flex; flex-direction: column; height: 100vh; } #map { flex: 1; } #hud { padding: 12px 20px; background: #1a1a1a; border-top: 1px solid #333; display: flex; align-items: center; gap: 24px; } #hud label { font-size: 13px; color: #aaa; } #hud span { font-size: 16px; font-weight: bold; } #timestep-val { color: #7af; } #value-val { color: #fa7; } #color-swatch { width: 28px; height: 28px; border-radius: 4px; border: 1px solid #555; display: inline-block; } #progress-bar { flex: 1; height: 8px; background: #333; border-radius: 4px; overflow: hidden; } #progress-fill { height: 100%; background: linear-gradient(to right, #00f, #f00); border-radius: 4px; } #controls { display: flex; gap: 8px; } button { background: #333; color: #eee; border: 1px solid #555; padding: 5px 14px; border-radius: 4px; cursor: pointer; font-size: 13px; } button:hover { background: #444; } #loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.55); color: #fff; font-size: 15px; pointer-events: none; z-index: 999; } #loading.hidden { display: none; } </style> </head> <body> <div id="map"><div id="loading">Loading tiles…</div></div> <div id="hud"> <div><label>Timestep </label><span id="timestep-val"></span></div> <div><label>Value </label><span id="value-val"></span></div> <div style="display:flex;align-items:center;gap:8px;"> <label>Color </label><span id="color-swatch"></span> </div> <div id="progress-bar"><div id="progress-fill" style="width:0%"></div></div> <div id="controls"> <button id="btn-playpause">Pause</button> <button id="btn-reset">Reset</button> </div> </div> <script type="module"> import Map from '<https://esm.sh/ol@10.9.0/Map>'; import View from '<https://esm.sh/ol@10.9.0/View>'; import TileLayer from '<https://esm.sh/ol@10.9.0/layer/Tile>'; import OSM from '<https://esm.sh/ol@10.9.0/source/OSM>'; import VectorTileLayer from '<https://esm.sh/ol@10.9.0/layer/VectorTile>'; import VectorTileSource from '<https://esm.sh/ol@10.9.0/source/VectorTile>'; import MVT from '<https://esm.sh/ol@10.9.0/format/MVT>'; import Style from '<https://esm.sh/ol@10.9.0/style/Style>'; import Fill from '<https://esm.sh/ol@10.9.0/style/Fill>'; import Stroke from '<https://esm.sh/ol@10.9.0/style/Stroke>'; import { fromLonLat } from '<https://esm.sh/ol@10.9.0/proj>'; import { PMTiles } from '<https://esm.sh/pmtiles@4.4.1>'; const LAYER_NAMES = [ 'timestep_00', 'timestep_01', 'timestep_02', 'timestep_03', 'timestep_04', 'timestep_05', 'timestep_06', 'timestep_07', 'timestep_08', 'timestep_09', 'timestep_10', ]; const VALUES = LAYER_NAMES.map((_, i) => i * 10); const STYLES = LAYER_NAMES.map((_, i) => new Style({ fill: new Fill({ color: `rgb(${Math.round(255*i/10)},0,${Math.round(255*(1-i/10))})` }), stroke: new Stroke({ color: '#fff', width: 2 }), })); const archive = new PMTiles('square.pmtiles'); const source = new VectorTileSource({ format: new MVT(), url: 'square.pmtiles', maxZoom: 8, }); let currentLayerIndex = 0; const squareLayer = new VectorTileLayer({ source, style: (feature) => { return feature.get('layer') === LAYER_NAMES[currentLayerIndex] ? STYLES[currentLayerIndex] : null; }, }); const map = new Map({ target: 'map', layers: [ new TileLayer({ source: new OSM() }), squareLayer ], view: new View({ center: fromLonLat([0, 0]), zoom: 2 }), }); const FRAME_MS = 500; function animate(frames, onFrame = () => {}) { let i = 0; let intervalId = setInterval(() => { currentLayerIndex = (currentLayerIndex + 1) % frames.length; squareLayer.changed(); onFrame(frames[i]); i = (i + 1) % frames.length; }, FRAME_MS); return { pause() { clearInterval(intervalId); }, resume() { intervalId = setInterval(() => { currentLayerIndex = (currentLayerIndex + 1) % frames.length; squareLayer.changed(); onFrame(frames[i]); i = (i + 1) % frames.length; }, FRAME_MS); }, reset() { i = 0; }, }; } const timestepEl = document.getElementById('timestep-val'); const valueEl = document.getElementById('value-val'); const swatchEl = document.getElementById('color-swatch'); const progressFill = document.getElementById('progress-fill'); function onFrame(layerName) { const i = LAYER_NAMES.indexOf(layerName); timestepEl.textContent = layerName; valueEl.textContent = VALUES[i]; swatchEl.style.background = STYLES[i].getFill().getColor(); progressFill.style.width = `${(i / (LAYER_NAMES.length - 1)) * 100}%`; } onFrame(LAYER_NAMES[0]); const loadingEl = document.getElementById('loading'); let player = null; let started = false; function startWhenReady() { if (started) return; started = true; loadingEl.classList.add('hidden'); player = animate(LAYER_NAMES, onFrame); } source.once('tileloadend', startWhenReady); map.once('rendercomplete', startWhenReady); setTimeout(startWhenReady, 10000); let playing = true; document.getElementById('btn-playpause').addEventListener('click', (e) => { if (!player) return; playing = !playing; e.target.textContent = playing ? 'Pause' : 'Play'; playing ? player.resume() : player.pause(); }); document.getElementById('btn-reset').addEventListener('click', () => { if (!player) return; player.reset(); if (!playing) { playing = true; document.getElementById('btn-playpause').textContent = 'Pause'; player.resume(); } }); </script> </body> </html>

Running the full setup

# 1. Install tippecanoe (macOS) brew install tippecanoe # 2. Create the 11 per-timestep GeoJSON files for i in $(seq 0 10); do pad=$(printf "%02d" $i); value=$((i * 10)) echo '{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-60,-50],[60,-50],[60,50],[-60,50],[-60,-50]]]},"properties":{"value":'$value'}}]}' \ > square_t${pad}.geojson done # 3. Generate the PMTiles archive bash generate-tiles.sh # 4. Start the server npx serve # 5. Open the demo open <http://localhost:3000/index.html>

Conclusion: paint time, not flip it

Don't stack separate tile sources for time-series animation. Bake timesteps into a single PMTiles archive as named MVT layers, load it once, and drive playback by updating a closure variable and calling layer.changed(). Serve the archive with npx serve. Start animation only after the first tile arrives.

The result: smooth playback from first load, no flicker on hard refresh, no blank map with cache disabled.

RELATED POSTS
Paweł Swiridow
Paweł Swiridow
Senior Software Engineer

Argo CD GitLab Authentication: A Complete GitOps-Friendly Setup

Jun 10, 20266 min read
Article image
Maciej Łopalewski
Maciej Łopalewski
Senior Software Engineer

Reciprocal Rank Fusion on free Elasticsearch: licensing, workarounds, and the OpenSearch alternative

Jun 03, 20268 min read
Article image
Michał Miler
Michał Miler
Senior Software Engineer

How to Deploy Payload CMS on AWS Amplify with MongoDB Atlas for Free

May 27, 202611 min read
Article image