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:
-
Server side — each timestep is a separate GeoJSON file.
tippecanoejoins them into one PMTiles archive, giving each timestep its own named MVT layer (timestep_00,timestep_01, …). -
Client side — one
VectorTileSourceloads the archive once. OneVectorTileLayeruses a style function that reads a mutablecurrentLayerNamevariable. Advancing a frame means: update the variable, calllayer.changed(). OpenLayers re-runs the style function on the already-decoded tile features and repaints — no network request, no re-decode.
Complete working example
!animation.gif
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.
Top comments (0)