At some point, a map project needs more than markers and polygons. You want something to happen on the map: water moving up a street, smoke drifting with the wind, or a shockwave spreading out from a point.

The first time I tried this, I did what seemed obvious to me. I put a <canvas> on top of the map and drew my animation on it.

But it looked wrong in a way I couldn't fix with more polish. The effect had edges. It floated above the ground. And as soon as I tilted or rotated the map, it stopped lining up with anything underneath.

I hit this while building HazardSim, a browser simulator that runs natural disasters on a real map. The tsunami scenario went through about a dozen rewrites before it stopped looking like a cut-out pasted over the city.

Much of the testing, and a good share of the bug reports, came from my 12-year-old son Tim, who turned out to be a more patient tester than I am. Most of those rewrites taught me the same thing from a different direction: draw your simulation in map coordinates, not screen coordinates.

Left: a video clip of a breaking wave drawn on a canvas overlaid on the map. It is a rectangle floating over Odesa with visible soft edges. Right: the same second of the simulation with the water painted into a canvas that the map treats as a raster source. The water follows the coastline and the tilt of the camera.

That rule carried me through the tsunami. Then I started on the earthquake and ran into the opposite problem. Mapbox GL JS will draw a city in 3D for you: turn on a fill-extrusion layer and every footprint in the vector tiles rises to its real height.

It looks good until you try to make one of those buildings fall down, and you find out that the map's 3D is a rendering trick, not a scene. There are no objects to grab, rotate or break. Things that lie on the ground and things that have height turned out to need two different techniques, and this guide covers both.

Part 1 builds the ground layer. You'll set up a grid aligned to the map's own tiles, load real elevation data into it, run a flood fill that respects hills and valleys, hand the canvas to the map as a raster source, write pixels into it every frame, layer video textures on top with canvas compositing modes, and move the camera into the scene.

Part 2 adds height. You'll pull building footprints and heights out of the map's tiles, make the extrusions look like buildings, decide which ones fall from the magnitude and distance, animate the collapse without rebuilding any layers, set up a three.js custom layer inside the map's own WebGL context, turn a footprint into a box that tips over, throw debris and dust, and shake the camera.

At the end we'll go through the mistakes from both halves, several of which produce no error message and cost me a day each.

Prerequisites

You should be comfortable with JavaScript, requestAnimationFrame, and the basics of the 2D canvas API: getContext("2d"), drawImage, and ImageData.

You don't need any background in web maps. Everything map-specific is explained where it comes up.

Part 2 also assumes you've used three.js at least once: meshes, materials, a scene and a camera. If you haven't, the three.js fundamentals are enough to follow along, and the parts that interact with the map are explained in full.

The examples use Mapbox GL JS, which is what runs on HazardSim. You'll need a free Mapbox access token for the base map and the vector tiles. Every API used here also exists in MapLibre GL JS, the open-source fork, under the same names. The one place the two differ is called out in Part 2.

Table of Contents

Part 1: Drawing on the Ground

The first half of this guide is about anything that belongs to the surface of the map: water, fire spread, a smoke stain, or a shockwave. The technique is one idea applied carefully, and the next seven steps build it up from a grid to a camera move.

The Core Idea: Map Space, Not Screen Space

A web map is a camera looking at a surface. When the user drags, zooms, tilts or rotates, the camera moves and the surface stays where it is. Roads, buildings, and your flood all belong to the surface, so the camera has to transform them together.

A canvas placed over the map's DOM element is attached to the camera, not to the surface. Think of it as a sheet of glass in front of the lens. Whatever you draw on it stays where you drew it while the world moves underneath.

At a top-down zoom with no rotation, you can fake alignment by reprojecting your points every frame, and it almost works. Add pitch and it falls apart: a flat sprite has no perspective, no foreshortening, and nothing in front of or behind it.

Two diagrams side by side. Left, titled "Canvas on top of the map": a tilted map surface with a flat blue rectangle standing upright over it, labelled "flat sprite", with the note "no perspective, visible edges, doesn't rotate with the map". Right, titled "Canvas as a map source": the same tilted surface with the blue area lying flat on it as a trapezoid, labelled "same pixels, foreshortened", with the note "perspective for free, no edges, rotates, tilts, follows terrain".

The fix is to give your canvas to the map as a data source. Mapbox GL JS has a canvas source type. You pass it a canvas element and four geographic corners, and from then on the map drapes that canvas over the rectangle the same way it drapes satellite imagery. Rotate the map and the canvas rotates. Tilt it and the canvas foreshortens. Turn on 3D terrain and the canvas follows the hills.

Once the drawing surface lives in map space, several hard problems disappear. The rest of this handbook is about what you can do with that.

Step 1: Choose a Grid Aligned to Map Tiles

Your simulation needs a grid: a 2D array of cells, each holding elevation, water depth, or whatever your model tracks. The decision that makes everything downstream simpler is to align that grid to the map's own tile pyramid.

Web maps use the Web Mercator projection, cut into 256×256-pixel tiles at zoom levels 0 to 22. At zoom z the world is 2^z tiles wide. If one grid cell is exactly one tile pixel at some zoom, converting between grid indices and longitude/latitude takes four short functions, and every dataset that ships as tiles (elevation, population, imagery) drops into your grid without resampling.

Here are the four conversions. lon2x and lat2y return fractional tile coordinates. Multiply by 256 to get pixels.

function lon2x(lon, z) { return (lon + 180) / 360 * Math.pow(2, z); } function lat2y(lat, z) { const rad = lat * Math.PI / 180; return (1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2 * Math.pow(2, z); } function x2lon(x, z) { return x / Math.pow(2, z) * 360 - 180; } function y2lat(y, z) { const n = Math.PI - 2 * Math.PI * y / Math.pow(2, z); return 180 / Math.PI * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n))); } 

Next, pick the zoom. You want the area of interest to fit in a grid that's fine enough to look smooth but small enough to iterate over sixty times a second. In HazardSim, I start at zoom 15 and step down until the bounding box fits in roughly 780 pixels across and under about 620,000 cells:

function chooseZoom(bbox) { for (let z = 15; z >= 8; z--) { const pw = (lon2x(bbox.e, z) - lon2x(bbox.w, z)) * 256; const ph = (lat2y(bbox.s, z) - lat2y(bbox.n, z)) * 256; if (pw <= 780 && pw * ph <= 620000) return z; } return 8; } 

With the zoom chosen, snap the bounding box outward to whole tiles. That rectangle is what your canvas will cover, and those are the tiles you'll download:

function gridFor(bbox) { const z = chooseZoom(bbox); const x0 = Math.floor(lon2x(bbox.w, z)), x1 = Math.floor(lon2x(bbox.e, z)); const y0 = Math.floor(lat2y(bbox.n, z)), y1 = Math.floor(lat2y(bbox.s, z)); const nx = x1 - x0 + 1, ny = y1 - y0 + 1; const latMid = (bbox.n + bbox.s) / 2; return { z, x0, y0, nx, ny, W: nx * 256, H: ny * 256, // metres per cell at this latitude and zoom cellM: 156543.03392 * Math.cos(latMid * Math.PI / 180) / Math.pow(2, z), // exact geographic corners of the snapped rectangle corners: { w: x2lon(x0, z), e: x2lon(x1 + 1, z), n: y2lat(y0, z), s: y2lat(y1 + 1, z), }, }; } 
Diagram of a 4 by 3 arrangement of map tiles with orange tile borders. A dashed blue rectangle inside marks the city bounding box. One tile is subdivided into small cells to show that one grid cell is one tile pixel. A side panel lists why aligning to tiles helps and shows the conversion formulas.

Keep cellM around. You'll need it to turn "the wave has travelled 400 metres" into "the wave has travelled 3.8 cells". For Odesa, the city in most of the screenshots here, the grid ends up at zoom 10 with 105-metre cells.

Step 2: Load Real Elevation Into the Grid

A flood that ignores hills is a blue blob. What makes a simulation on a real map feel real is real terrain, and terrain is free.

The AWS Open Data program hosts the Mapzen Terrarium elevation tiles. They're ordinary PNGs with height encoded in the pixel colour:

height_m = R * 256 + G + B / 256 - 32768 

Because the grid is tile-aligned, loading elevation means fetching the tiles, drawing them onto one big canvas in their natural positions, reading the pixels back, and decoding. Nothing is resampled. The grid cell and the elevation sample are the same pixel.

function terrariumUrl(z, x, y) { return `https://s3.amazonaws.com/elevation-tiles-prod/terrarium/${z}/${x}/${y}.png`; } function loadImage(url) { return new Promise((resolve, reject) => { const img = new Image(); img.crossOrigin = "anonymous"; img.onload = () => resolve(img); img.onerror = reject; img.src = url; }); } async function loadElevation(grid) { const cv = document.createElement("canvas"); cv.width = grid.W; cv.height = grid.H; const cx = cv.getContext("2d", { willReadFrequently: true }); const jobs = []; for (let iy = 0; iy < grid.ny; iy++) { for (let ix = 0; ix < grid.nx; ix++) { jobs.push( loadImage(terrariumUrl(grid.z, grid.x0 + ix, grid.y0 + iy)) .then(img => cx.drawImage(img, ix * 256, iy * 256)) ); } } await Promise.all(jobs); const d = cx.getImageData(0, 0, grid.W, grid.H).data; const elev = new Float32Array(grid.W * grid.H); for (let i = 0; i < elev.length; i++) { const o = i * 4; elev[i] = d[o] * 256 + d[o + 1] + d[o + 2] / 256 - 32768; } return elev; } 

crossOrigin = "anonymous" is required. Without it the canvas becomes "tainted" and getImageData throws. The Terrarium bucket sends the right CORS headers, so this works as written.

Since you already have a Mapbox token, you can use Mapbox's own Terrain-RGB tiles instead. The URL is https://api.mapbox.com/v4/mapbox.terrain-rgb/{z}/{x}/{y}.pngraw?access_token=… and the decode formula is different:

// Mapbox Terrain-RGB elev[i] = -10000 + (d[o] * 65536 + d[o + 1] * 256 + d[o + 2]) * 0.1; 

HazardSim supports both and switches with a config flag. Terrain-RGB is cleaner data with fewer spikes. Terrarium is free of per-tile billing. For a tutorial, I'd start with Terrarium.

Decoded elevation for the coast around Odesa rendered as a greyscale heightmap, with the sea in dark blue and higher ground lighter. Orange lines show the boundaries of the 4 by 4 Terrarium tiles that were stitched together.

Two practical notes. Terrarium data has occasional single-pixel spikes, and a 3×3 median pass over cells that differ from all their neighbours by more than a few metres cleans them up. And cells at or below zero are sea, except where they aren't, like in the Netherlands, New Orleans, or Death Valley. I treat elev <= 0 as sea only when the cell connects to the map edge through other sea cells. That handles the common cases.

Step 3: Attach a Canvas as a Map Source

Now the central move. Create a canvas the size of your grid and register it with the map as a canvas source whose four corners are the snapped rectangle from Step 1.

<div id="map"></div> <canvas id="floodcanvas" width="10" height="10" style="display:none"></canvas> 
mapboxgl.accessToken = "YOUR_MAPBOX_TOKEN"; const map = new mapboxgl.Map({ container: "map", style: "mapbox://styles/mapbox/satellite-streets-v12", center: [30.73, 46.48], zoom: 11, }); const fc = document.getElementById("floodcanvas"); const fctx = fc.getContext("2d"); let fimg = null; function attachCanvas(grid) { fc.width = grid.W; fc.height = grid.H; fimg = fctx.createImageData(grid.W, grid.H); const c = grid.corners; const coords = [[c.w, c.n], [c.e, c.n], [c.e, c.s], [c.w, c.s]]; if (map.getSource("flood")) { map.getSource("flood").setCoordinates(coords); return; } map.addSource("flood", { type: "canvas", canvas: "floodcanvas", coordinates: coords, animate: true, }); map.addLayer({ id: "flood", type: "raster", source: "flood", paint: { "raster-opacity": 0.94, "raster-resampling": "linear", "raster-fade-duration": 0, }, }); } 

Three options matter here:

  • animate: true tells Mapbox GL to re-upload the canvas as a texture every frame. Without it, the animation freezes on the first frame.

  • "raster-resampling": "linear" blends between cells when the user zooms in past your grid resolution. With 105-metre cells at a city zoom, it's the difference between water and a pixel-art game.

  • "raster-fade-duration": 0 turns off the cross-fade Mapbox GL applies to raster tiles. You're redrawing every frame, and a fade only smears it.

The canvas is display:none because the map reads it directly. It never needs to be visible in the DOM.

From here on, anything you write into fimg and push with fctx.putImageData(fimg, 0, 0) shows up on the map in the right place, at any pitch and bearing, draped over terrain if terrain is on. The rendering problem has turned into a pixel-array problem.

Step 4: Simulate a Flood That Respects the Terrain

A tsunami doesn't flood everything below the wave height. Water has to get there. It enters from the sea, flows over low ground, and stops where the ground rises above the water level, even if there's lower ground on the far side of a ridge. That's a connectivity problem, and breadth-first search is the tool for it.

First, a distance field: how far is each cell from the coast? Seed the queue with every sea cell adjacent to land, then expand into the sea. You'll use this to animate the wave approaching, and the land-side equivalent to animate it spreading inland.

function seaDistance(grid, sea) { const { W, H, cellM } = grid; const N = W * H; const dist = new Float32Array(N).fill(-1); const q = new Int32Array(N); let head = 0, tail = 0; // seed: sea cells that touch land for (let i = 0; i < N; i++) { if (!sea[i]) continue; const x = i % W, y = (i / W) | 0; const touchesLand = (x > 0 && !sea[i - 1]) || (x < W - 1 && !sea[i + 1]) || (y > 0 && !sea[i - W]) || (y < H - 1 && !sea[i + W]); if (touchesLand) { dist[i] = 0; q[tail++] = i; } } // expand while (head < tail) { const c = q[head++]; const cx = c % W, cy = (c / W) | 0; for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { if (!dx && !dy) continue; const nx = cx + dx, ny = cy + dy; if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue; const n = ny * W + nx; if (!sea[n] || dist[n] >= 0) continue; dist[n] = dist[c] + (dx && dy ? cellM * Math.SQRT2 : cellM); q[tail++] = n; } } } return dist; } 

The flood is the same search in the other direction, with one extra rule: a cell is reachable only if the water surface is above its ground. waveH is the wave height at the shore in metres. The water loses height as it travels inland, and a linear decay is enough to look right:

function floodFill(grid, elev, sea, waveH) { const { W, H, cellM } = grid; const N = W * H; const depth = new Float32Array(N); // metres of water above ground const reach = new Float32Array(N).fill(-1); // distance from shore, metres const q = new Int32Array(N); let head = 0, tail = 0; for (let i = 0; i < N; i++) { if (!sea[i]) continue; const x = i % W, y = (i / W) | 0; const nb = [i - 1, i + 1, i - W, i + W]; for (const n of nb) { if (n < 0 || n >= N || sea[n] || reach[n] >= 0) continue; if ((n === i - 1 && x === 0) || (n === i + 1 && x === W - 1)) continue; reach[n] = 0; q[tail++] = n; } } const decayPerM = waveH / 12000; // loses its height over ~12 km while (head < tail) { const c = q[head++]; const level = waveH - reach[c] * decayPerM; const d = level - elev[c]; if (d <= 0) continue; // ground is above the water here: stop depth[c] = d; const cx = c % W, cy = (c / W) | 0; for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { if (!dx && !dy) continue; const nx = cx + dx, ny = cy + dy; if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue; const n = ny * W + nx; if (sea[n] || reach[n] >= 0) continue; reach[n] = reach[c] + (dx && dy ? cellM * Math.SQRT2 : cellM); q[tail++] = n; } } } return { depth, reach }; } 

Run this once per scenario, not per frame. On a 700×500 grid it takes a few milliseconds. The outputs, depth for every cell and reach for how far inland it is, are all the animation needs.

Because the fill follows real elevation, it goes up river valleys and stops at embankments. Multiply the flooded cell count by cellM² and you get a flooded area in square kilometres you can stand behind.

Top-down view of Odesa after the simulated tsunami. Dark brown water covers the low coastal districts and the port, following the shape of the coastline and stopping at higher ground inland. The open sea stays its normal dark blue.

Step 5: Animate by Writing Pixels

The frame loop does one thing. For each flooded land cell, it decides whether the water has arrived yet, and if so, what colour it is.

"Arrived" is a comparison of the cell's reach against a front position F that grows with time. Add some per-cell noise to reach so the front isn't a perfect contour line, and the water starts to look like water:

let t0 = null; const T_LANDFALL = 3.9; // seconds until the wave reaches the shore const T_FULL = 10.9; // seconds until it reaches its maximum extent function ease(k) { return k <= 0 ? 0 : k >= 1 ? 1 : 1 - Math.pow(1 - k, 2.2); } function frame(now) { if (t0 === null) t0 = now; const t = (now - t0) / 1000; const d = fimg.data; const { W, H } = grid; // how far inland the front has got, in metres const F = t < T_LANDFALL ? -1e9 : maxReach * ease((t - T_LANDFALL) / (T_FULL - T_LANDFALL)); for (let i = 0; i < W * H; i++) { const o = i * 4; if (sea[i] || depth[i] <= 0) { d[o + 3] = 0; continue; } const arrival = reach[i] + noise[i] * 180; // ±90 m of jitter if (arrival > F) { d[o + 3] = 0; continue; } const age = (F - arrival) / 150; // how long the cell has been under const dp = depth[i]; // base colour: shallow is lighter, deep is darker let r = 40 + Math.max(0, 60 - dp * 10); let g = 70 + Math.max(0, 50 - dp * 8); let b = 95 + Math.max(0, 40 - dp * 6); let a = dp < 0.5 ? 215 + dp * 60 : 245; // the leading edge is foam: push toward white for the first moments if (age < 1.4) { const k = Math.pow(1 - age / 1.4, 2) * 0.8; r += (240 - r) * k; g += (246 - g) * k; b += (246 - b) * k; } d[o] = r; d[o + 1] = g; d[o + 2] = b; d[o + 3] = a; } fctx.putImageData(fimg, 0, 0); if (t < T_FULL + 3) requestAnimationFrame(frame); } requestAnimationFrame(frame); 

noise is a Float32Array of random values in [-0.5, 0.5] generated once per run. maxReach is the largest reach value among flooded cells.

There are two performance points: A 700×500 grid is 350,000 cells, and this loop runs at 60 fps on my laptop and around 30 fps on a mid-range phone. If you need more, iterate only over the list of flooded land cells (collect the indices once after floodFill) instead of the whole grid. In a coastal city, that's usually a quarter of the cells. And keep fimg between frames. Calling createImageData every frame is the first thing a profiler will point at.

If you stop here, you have a working, terrain-aware simulation. The rest is about making it look like footage instead of a diagram.

Step 6: Layer Video Textures With Canvas Compositing

Flat colour reads as a diagram. Real water has texture that moves. The cheapest way to get that is to drape a looping video of real water over the flooded area, and the restriction "over the flooded area only" comes free from two canvas compositing modes:

  • source-atop draws the new image only where the destination already has pixels. Draw a video onto the flood canvas with source-atop and it appears on water only. Dry land stays untouched, because dry land is transparent.

  • destination-in keeps the destination only where the new image has pixels. Draw a black-and-transparent mask onto a buffer with destination-in, and the buffer is cropped to the mask's shape.

You need a hidden <video> (muted, looping, and playsInline. You need all three, or mobile Safari refuses to autoplay) and one scratch canvas the size of the grid:

const vid = document.createElement("video"); vid.muted = true; vid.loop = true; vid.playsInline = true; vid.setAttribute("muted", ""); vid.setAttribute("playsinline", ""); vid.src = "/fx/water.webm"; vid.style.cssText = "position:absolute;width:1px;height:1px;opacity:0;pointer-events:none"; document.body.appendChild(vid); vid.load(); const buf = document.createElement("canvas"); buf.width = grid.W; buf.height = grid.H; const bctx = buf.getContext("2d"); function drawWaterTexture(mud) { if (vid.readyState < 2) return; // not enough data yet: skip, don't block if (vid.paused) vid.play().catch(() => {}); // 1. video into the scratch buffer, stretched to the grid bctx.globalCompositeOperation = "source-over"; bctx.clearRect(0, 0, grid.W, grid.H); bctx.drawImage(vid, 0, 0, grid.W, grid.H); // 2. tint it, only where the video is (everywhere, at this point) bctx.globalCompositeOperation = "source-atop"; bctx.globalAlpha = 0.5 + 0.38 * mud; bctx.fillStyle = `rgb(${28 + 108 * mud | 0}, ${112 - 10 * mud | 0}, ${178 - 126 * mud | 0})`; bctx.fillRect(0, 0, grid.W, grid.H); bctx.globalAlpha = 1; // 3. crop the buffer to land cells, so the open sea keeps its own colour bctx.globalCompositeOperation = "destination-in"; bctx.drawImage(landMask, 0, 0); // 4. onto the flood canvas, only where there's already water fctx.save(); fctx.globalCompositeOperation = "source-atop"; fctx.globalAlpha = 0.8; fctx.drawImage(buf, 0, 0); fctx.restore(); } 

Call this right after fctx.putImageData(fimg, 0, 0) in the frame loop. Step 4 is where it pays off. Because the flood canvas is transparent wherever there's no water, source-atop puts the texture exactly inside the flood outline, following every bay and valley the BFS found, with no per-pixel work on your side.

landMask is a canvas you build once per run: white where sea[i] is false, transparent elsewhere. Without it the texture also covers the open sea, and the whole gulf turns the colour of the flood. I shipped that version for about an hour before I noticed.

Pitched view from above the shore of Odesa, looking out to sea. A white foam front sweeps across the coast and into the city, following the coastline, with darker water behind it.

The mud parameter is a time ramp from 0 to 1. Tsunami water arrives as seawater and turns dark brown as it picks up sediment off the land. Interpolating the tint from sea blue to mud brown over a few seconds after landfall gives you that for free.

The first time I built this, I had baked a brown grade into the video file itself, and no amount of tinting could make the water blue at the start. Keep the source footage neutral and do colour in code.

The same destination-in trick gives you a depth-driven darkening (a mask whose alpha is proportional to depth[i], filled with a dark colour) and a foam band along the moving front (a mask whose alpha is a Gaussian of reach[i] - F, filled with a second video of churning foam). Each is a few lines, and each one makes the water read a little more as a volume and a little less as a tint.

Step 7: Move the Camera Into the Scene

A top-down map is an illustration. Tilt the camera and the same pixels become a place. The flood canvas is draped on the ground, so it foreshortens correctly. Your only job is to point the camera somewhere worth looking at.

For a tsunami, that means standing on the shore near the city, looking out to sea, and watching it come in. "Near the city" is the coastline cell closest to the map centre. "Out to sea" is the average direction to nearby sea cells from that point. Compute it locally rather than for the whole coastline, or a curved bay will point your camera at the wrong horizon. I found that out with Odesa, where the citywide average pointed the camera at empty fields next to the airport.

function shoreCamera(grid, sea, center) { const { W, H, z, x0, y0, cellM } = grid; // 1. nearest coastline cell to the map centre let best = -1, bestD = Infinity; for (let i = 0; i < W * H; i++) { if (!sea[i]) continue; const x = i % W, y = (i / W) | 0; const coast = (x > 0 && !sea[i - 1]) || (x < W - 1 && !sea[i + 1]) || (y > 0 && !sea[i - W]) || (y < H - 1 && !sea[i + W]); if (!coast) continue; const lng = x2lon(x0 + (x + 0.5) / 256, z), lat = y2lat(y0 + (y + 0.5) / 256, z); const d = (lng - center.lng) ** 2 + (lat - center.lat) ** 2; if (d < bestD) { bestD = d; best = i; } } const sx = best % W, sy = (best / W) | 0; // 2. local seaward direction: average offset to sea cells within ~2.5 km const r = Math.max(6, Math.round(2500 / cellM)); let vx = 0, vy = 0; for (let dy = -r; dy <= r; dy++) { for (let dx = -r; dx <= r; dx++) { const x = sx + dx, y = sy + dy; if (x < 0 || y < 0 || x >= W || y >= H || !sea[y * W + x]) continue; const len = Math.hypot(dx, dy); if (len < 1 || len > r) continue; vx += dx / len; vy += dy / len; } } const len = Math.hypot(vx, vy) || 1; const east = vx / len, north = -vy / len; // grid y grows southward const shoreLng = x2lon(x0 + (sx + 0.5) / 256, z); const shoreLat = y2lat(y0 + (sy + 0.5) / 256, z); const mLng = 111320 * Math.cos(shoreLat * Math.PI / 180), mLat = 111320; return { // stand 900 m inland of the shore... center: [shoreLng - east * 900 / mLng, shoreLat - north * 900 / mLat], // ...facing the sea bearing: Math.atan2(east, north) * 180 / Math.PI, }; } 

Then it's two easeTo calls: dive in before landfall, pull back out before the results appear.

const before = { center: map.getCenter(), zoom: map.getZoom(), pitch: map.getPitch(), bearing: map.getBearing(), }; const cam = shoreCamera(grid, sea, map.getCenter()); map.easeTo({ center: cam.center, zoom: Math.min(13.6, before.zoom + 2.8), pitch: 72, bearing: cam.bearing, duration: 1200, }); setTimeout(() => map.easeTo({ ...before, duration: 2600 }), (T_LANDFALL + 3.2) * 1000); 

Here is what those two calls produce. The camera has dropped to a steep pitch above the shore of Odesa and turned to face the Gulf. The sea pulls back first, exposing a wide band of sand, and then the front comes in over the beach and into the port. Everything you see moving is the same flood canvas from Step 3, foreshortened by the camera and draped over the terrain.

Animated: a tilted camera view over the shore of Odesa. The sea first draws back from the beach, exposing sand, then a dark wave front rolls back in across the beach and the port while the city fills the lower half of the frame.

Notice that nothing in the frame loop knows or cares that the camera moved. The loop keeps writing the same pixels into the same canvas, and the map does the rest. That's what Step 3 bought you.

Where the Ground Layer Stops

Everything so far lives on the ground. The flood canvas is the map's own surface, and that's exactly why it works: perspective, rotation and terrain come free. The moment you need something with height, the trick runs out. You can't drape a tower onto a raster.

I learned where that line is the expensive way. I built a real 3D wall of water as a three.js custom layer, and it worked. I also cut it, for a reason worth knowing before you spend the time.

A 30-metre wave seen from 250 metres away covers about 7 degrees of a 37-degree field of view, a fifth of the screen. To make it fill the frame, you have to put the camera a hundred metres from it and nearly on the ground, where satellite imagery smears into mud. The geometry was correct and the shot was bad. Draped water under a pitched camera turned out to read as more water than a correctly-scaled wall did.

So the ground layer stayed a ground layer. But the earthquake scenario had no such escape: a building that collapses has to be an object. That is Part 2.

Part 2: Things with Height

Street-level view of central Kyiv during a simulated magnitude 7.8 earthquake. Textured high-rise towers stand in the foreground, several of them leaning, and grey dust clouds drift across the scene over the Olympic stadium.

For the earthquake scenario, I needed buildings to fall. This half is what I ended up with after roughly ten rounds of rebuilding it: a mix of map-native extrusions for the thousands of buildings that only need to stand there, and a three.js scene rendered into the map's own WebGL context for the ones that come down.

Both paths read the same vector tiles, share the same depth buffer and move together under every camera move.

Two Kinds of 3D, and Why You Need Both

A fill-extrusion layer is the map engine's own 3D. It's fast, it handles tens of thousands of buildings, it's lit consistently with the rest of the map, and it's driven by data expressions: height from a property, colour from a property, and, the part that matters here, height multiplied by a per-feature state you can change at runtime. What it can't do is move. An extrusion is always a vertical prism standing on the ground.

A custom layer gives you the map's WebGL context and projection matrix every frame and lets you draw whatever you want into it. With three.js on the other end, that means real objects: meshes with position, rotation, and scale, instanced by the thousand, writing into the same depth buffer as the map so that a falling block disappears correctly behind a building that's still standing.

The split that works is simple. Every building is an extrusion. When one is damaged, its extrusion shrinks (that's the per-feature state). When one collapses close enough to the camera that you'd see the difference, the extrusion shrinks to nothing and a three.js object takes its place for the two seconds of the fall. Then the object is replaced with a pile of rubble, also three.js, and the frame rate never notices.

Pitched view of central Kyiv before the shaking starts. Thousands of buildings are extruded from the map's vector tiles with window-pattern textures on their walls; a 3D model of the Olympic stadium stands in the middle distance.

Step 8: Get Buildings Out of the Map's Own Tiles

You don't need a building dataset. The map is already downloading one. Mapbox Streets vector tiles carry a building source layer with a polygon footprint, a height, a min_height for buildings that start above ground, and an extrude flag. querySourceFeatures reads whatever is currently loaded:

function buildingSource(map) { const sources = map.getStyle().sources; if (sources.composite) return "composite"; // Mapbox styles if (sources.openmaptiles) return "openmaptiles"; // MapLibre + OpenMapTiles for (const id in sources) if (sources[id].type === "vector") return id; return null; } function collectBuildings(map, center, kx, ky) { const src = buildingSource(map); if (!src) return []; const feats = map.querySourceFeatures(src, { sourceLayer: "building" }); const out = [], seen = new Set(); for (const f of feats) { const p = f.properties || {}, g = f.geometry; if (!g || p.extrude === "false" || p.underground === "true") continue; const polys = g.type === "Polygon" ? [g.coordinates] : g.type === "MultiPolygon" ? g.coordinates : null; if (!polys || !polys[0] || polys[0][0].length < 4) continue; // centroid and bounds of the outer ring const ring = polys[0][0], n = ring.length - 1; let sx = 0, sy = 0, x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity; for (let j = 0; j < n; j++) { const [x, y] = ring[j]; sx += x; sy += y; if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y; } sx /= n; sy /= n; // the same building appears once per tile it touches const key = sx.toFixed(6) + "," + sy.toFixed(6) + "," + n; if (seen.has(key)) continue; seen.add(key); let h = +(p.height ?? p.render_height); if (!(h > 0)) h = 8; // no data: assume two storeys const mh = Math.min(h, +(p.min_height ?? p.render_min_height) || 0); out.push({ geometry: g, polys, lng: sx, lat: sy, h, mh, d: Math.hypot((sx - center.lng) * kx, (sy - center.lat) * ky), // metres from epicentre rf: 0.5 * Math.sqrt(Math.max(4, (x1 - x0) * kx * (y1 - y0) * ky)), // rough radius, metres }); } out.sort((a, b) => a.d - b.d); return out.slice(0, 9000); } 

kx and ky are metres per degree of longitude and latitude at the city's latitude: 111320 * cos(lat) and 110574. Everything downstream works in metres from the epicentre.

Three things to know about this data. First, querySourceFeatures returns a building once for each tile it intersects, so you have to deduplicate. A rounded centroid plus vertex count makes a good key.

Second, the features have no stable IDs in Mapbox Streets. That matters enormously in Step 11.

Third, you only get what's loaded, which means you have to zoom in to where the buildings are before you collect them, and wait for the tiles to arrive.

In HazardSim, the camera dives in two steps, holds until map.areTilesLoaded() is true and the feature count stops changing between polls, and only then runs collectBuildings. If you collect too early, you get the ten buildings that happened to be in the first tile.

Step 9: Make Extrusions Look Like Buildings

The default extrusion is a flat-shaded box in one colour. It reads as a diagram. The trick that fixes it costs almost nothing: fill-extrusion-pattern takes an image from the style's sprite and tiles it over the walls, and you can add images to the sprite at runtime with map.addImage.

Draw a few façade textures on a canvas (rows of windows on a wall colour, with slight noise so they don't look computer-generated), register them, and pick one per building from its height:

function makeFacade(map, id, wall, glass) { const cv = document.createElement("canvas"); cv.width = 128; cv.height = 64; const c = cv.getContext("2d"); c.fillStyle = wall; c.fillRect(0, 0, 128, 64); // four floors of windows, 16px per floor for (let f = 0; f < 4; f++) { for (let x = 3; x + 6 <= 128; x += 12) { const lit = Math.random() < 0.07; c.fillStyle = lit ? "#c6b68c" : glass; c.fillRect(x, f * 16 + 3, 6, 9); } } // film grain const im = c.getImageData(0, 0, 128, 64), d = im.data; for (let i = 0; i < d.length; i += 4) { const n = (Math.random() - 0.5) * 12; d[i] += n; d[i + 1] += n; d[i + 2] += n; } map.addImage(id, im); } makeFacade(map, "fac-glass", "#566a78", "#3c4e5c"); makeFacade(map, "fac-light", "#cdc8be", "#364048"); makeFacade(map, "fac-brick", "#8a5a44", "#2e2a26"); 

Then the layer. Height and base are expressions, and both are multiplied by a feature-state value k that defaults to 1. That multiplier is the whole collapse mechanism, so it goes in now:

const H = ["coalesce", ["get", "height"], ["get", "render_height"], 8]; const MH = ["coalesce", ["get", "min_height"], ["get", "render_min_height"], 0]; const K = ["coalesce", ["feature-state", "k"], 1]; function addBuildingLayers(map, source, sourceLayer, before) { const common = { type: "fill-extrusion", source, minzoom: 13 }; if (sourceLayer) { common["source-layer"] = sourceLayer; common.filter = ["all", ["!=", ["get", "extrude"], "false"], ["!=", ["get", "underground"], "true"]]; } map.addLayer({ ...common, id: "bld-walls", paint: { "fill-extrusion-pattern": ["case", [">=", H, 45], "fac-glass", [">=", H, 14], "fac-light", "fac-brick"], "fill-extrusion-height": ["*", H, K], "fill-extrusion-base": ["*", MH, K], "fill-extrusion-opacity": 1, }, }, before); // a thin second extrusion on top gives the roof its own texture map.addLayer({ ...common, id: "bld-roof", paint: { "fill-extrusion-pattern": "fac-roof", "fill-extrusion-height": ["+", ["*", H, K], 0.05], "fill-extrusion-base": ["max", ["*", MH, K], ["-", ["*", H, K], 0.45]], "fill-extrusion-opacity": 1, }, }, before); } 

before should be the ID of the first symbol layer in the style, so the buildings go under the labels. Find it with a loop over map.getStyle().layers.

A pattern can't be rotated or scaled per feature, so windows don't line up with floors exactly. At the camera heights this runs at, nobody can tell.

Step 10: Decide What Falls

Now the simulation part, which is short. For each building, you need the shaking intensity at its location and the probability that a building of that type collapses at that intensity.

Shaking is expressed on the Modified Mercalli scale, I to XII. A standard attenuation relation gives intensity from magnitude M and distance r in kilometres (depth is the hypocentre depth, 10 km for a shallow crustal quake):

const DEPTH_KM = 10; function mmiAt(M, rKm) { const R = Math.sqrt(rKm * rKm + DEPTH_KM * DEPTH_KM); const v = 1.5 * M - 3.0 * Math.log10(R) - 0.0018 * R + 1.6; return Math.max(0, Math.min(12, v)); } 

Collapse probability as a function of intensity is a fragility curve. The published ones are per building class and far more detailed than a simulation needs. This piecewise-linear version is tuned to look right and to give sane casualty numbers. Multiply it by a factor for construction quality and another for height. Mid-rise buildings fare worst, very tall ones are engineered and fare better:

const FRAG = [[6.5, 0], [7, 0.004], [8, 0.025], [9, 0.08], [10, 0.2], [11, 0.38], [12, 0.55]]; const QUALITY = { strict: 0.2, avg: 1, poor: 3 }; function collapseProbability(mmi) { if (mmi <= FRAG[0][0]) return 0; for (let i = 1; i < FRAG.length; i++) { if (mmi <= FRAG[i][0]) { const [m0, p0] = FRAG[i - 1], [m1, p1] = FRAG[i]; return p0 + (p1 - p0) * (mmi - m0) / (m1 - m0); } } return FRAG[FRAG.length - 1][1]; } function assignDamage(buildings, M, epicentre, quality) { const kx = 111320 * Math.cos(epicentre.lat * Math.PI / 180), ky = 110574; const q = QUALITY[quality] || 1; for (const b of buildings) { const rKm = Math.hypot((b.lng - epicentre.lng) * kx, (b.lat - epicentre.lat) * ky) / 1000; const mmi = mmiAt(M, rKm); const hf = b.h < 5 ? 0.7 : b.h < 35 ? 1.15 : b.h < 70 ? 0.8 : 0.45; const pCollapse = Math.min(0.9, collapseProbability(mmi) * q * hf); const pDamage = mmi >= 6 ? Math.min(0.6, pCollapse * 2.5 + 0.004) : 0; const r = Math.random(); b.cat = r < pCollapse ? 2 : r < pCollapse + pDamage ? 1 : 0; // 2 collapse, 1 damaged, 0 fine b.start = 0.8 + Math.random() * 2.8; // seconds after the shock } } 

The per-building randomness is what sells it. Two identical towers next to each other, one falls and one stands. That's also how real earthquakes look.

Step 11: Animate Collapse Without Rebuilding Layers

Here's the mechanism from Step 9 paying off. Each frame, for every building that's falling, set its feature-state k to a number between 1 and its final rubble height. The extrusion shrinks. No layer is rebuilt, no GeoJSON is re-set, and no tiles are re-parsed.

map.setFeatureState({ source: "bld", id: b.id }, { k: 0.62 }); 

There's one catch, and it's the one that cost me the most time in this whole project: setFeatureState needs a feature ID, and Mapbox Streets buildings don't have one. Calling it against the vector source silently does nothing.

The fix is to copy the buildings you collected into your own GeoJSON source, with an id you control, and tell the source to promote that property to the feature ID:

const feats = buildings.map((b, i) => { b.id = i; return { type: "Feature", properties: { i, height: b.h, min_height: b.mh }, geometry: b.geometry }; }); map.addSource("bld", { type: "geojson", data: { type: "FeatureCollection", features: feats }, promoteId: "i", maxzoom: 16, }); addBuildingLayers(map, "bld", null, firstSymbolLayer); 

Now setFeatureState({ source: "bld", id: 17 }, ...) reaches building 17. Keep the original tile-backed layers around until the GeoJSON source reports isSourceLoaded, then remove them, so there's no frame where the city blinks.

The shape of k over time is what makes a collapse look like a collapse rather than a building sliding into the ground. I ended up with five modes, chosen at random per building and constrained by height:

// k0 = starting height multiplier (1), kf = final rubble height, u = progress 0..1 function collapseK(b, u) { const k0 = b.k0, sp = k0 - b.kf; switch (b.mode) { case 1: { // floor by floor: ns steps, each with a fast drop then a pause const s = u * b.ns, i = Math.min(b.ns - 1, Math.floor(s)); let f = s - i; f = f < 0.45 ? f * f / 0.45 * 1.1 : 0.55 + (f - 0.45) * 0.45 / 0.55; return k0 - sp * Math.min(1, (i + Math.min(1, f)) / b.ns); } case 2: { // soft storey: ground floor gives, building drops one floor, pauses, then goes const sd = Math.min(sp * 0.5, 3.2 / b.h); if (u < 0.12) return k0 - sd * (u / 0.12) ** 2; if (u < 0.5) return k0 - sd; const v = (u - 0.5) / 0.5; return k0 - sd - (sp - sd) * v * v; } case 3: { // crushed top: upper p...