reszta poprawek

This commit is contained in:
2026-08-09 09:47:34 +02:00
parent 966dd3f209
commit a0c9d7947b
439 changed files with 112891 additions and 0 deletions
@@ -0,0 +1,35 @@
---
name: remotion-maps
description: Remotion Map animation knowledge
---
# Remotion Maps
Choose exactly one technique from the intended shot, then load only that technique's `TECHNIQUE.md`.
Every technique directory is self-contained and may be removed without breaking the others.
## [Static map](techniques/static-map/TECHNIQUE.md)
- Requires you grab a satellite image and mount it in a `<Img>` tag, and animate on top
## [Mapbox](techniques/mapbox/TECHNIQUE.md)
- Requires a Mapbox key
- Nicer styles by default
- Map can display a round globe when zoomed out
- Includes nice 3D buildings such as the Eiffel tower
## [MapLibre](techniques/maplibre/TECHNIQUE.md)
- Requires no API key, fully free
- Does not include 3D building
## [MapTiler](techniques/maptiler/TECHNIQUE.md)
- Uses MapTiler
- Annotations can be drawn on top of geographic features: borders, rivers, labels
## [CesiumJS](techniques/cesium/TECHNIQUE.md)
- Flythroughs through terrain and mountains
- "Flight simulator" perspective
@@ -0,0 +1,89 @@
# CesiumJS — 3D flyovers in Remotion
Instructions for achieving map animations with "flight-simulator" perspective in Remotion.
## Modes
| Mode | Data | Use for |
| ----------- | ----------------------------------------------------- | ------------------------------------------------------ |
| `landscape` | MapTiler `terrain-quantized-mesh-v2` + `satellite-v2` | Mountains, gorges, rivers, coastlines and rural routes |
| `city` | Google Photorealistic 3D Tiles | Cities, architecture and recognizable landmarks |
Do not use footprint extrusions for city flyovers. They produce crude building blocks rather than
textured architecture.
## Credentials
For `landscape`, set:
```text
REMOTION_MAPTILER_KEY=...
```
Create a MapTiler key at https://cloud.maptiler.com/account/keys/.
For `city`, set:
```text
REMOTION_GOOGLE_MAPS_API_KEY=...
```
Create a billing-enabled Google Map Tiles API key by following
https://developers.google.com/maps/documentation/tile/get-api-key. Enable the **Map Tiles API** and
restrict the key to that API. Ensure its application restriction permits local headless Remotion
requests.
## Build the flight
1. Copy `assets/CesiumFlythrough.tsx`, a path JSON and `assets/example-Root.tsx` into the Remotion
project, or import the component directly.
2. Supply the camera route as `[longitude, latitude][]`. Use only meaningful control points; do not hand-author dozens of tiny corrections.
3. Leave `pathSmoothingPasses={3}` initially. The component applies repeated Chaikin corner cutting, turning straight-then-corner input into a continuous swerve. Increase to `4` for a softer route or reduce to `2` when the camera must follow a tight corridor.
4. Set absolute camera altitudes for the location. City cameras normally fly lower than landscape
cameras.
5. Render a middle-frame still before rendering the full video.
```tsx
<CesiumFlythrough
mode="city"
path={cameraPath}
pathSmoothingPasses={3}
altitudeStart={700}
altitudeEnd={500}
lookAheadKm={0.7}
travelKm={4.5}
/>
```
## Camera behavior
Walk the smoothed curve by arc length for constant ground speed. Aim at a real point farther along
the curve rather than its next vertex. Derive roll from the change in look-ahead bearing so the
camera banks into a turn instead of twitching left and right.
For landscape routes, `scripts/prep-cesium-path.mjs` also clips, resamples, smooths and dampens a
GeoJSON centerline before the component applies its final curve smoothing.
## Mechanics
- Set `viewer.useDefaultRenderLoop = false`.
- Call `viewer.render()`, never `scene.render()`, while settling.
- Use `preserveDrawingBuffer: true`.
- Gate initialization and every frame with `delayRender`.
- Drive camera animation from `useCurrentFrame()`; do not use CSS transitions or browser-timed animation.
- Settle landscapes on `globe.tilesLoaded` and cities on `tileset.tilesLoaded`.
- Keep all provider attribution visible.
- Record the source and effective date of custom or disputed geography.
- Inspect rendered pixels, not only Studio playback, at every required aspect ratio.
- Read `references/3d-flyover-architecture.md` for camera math, `references/3d-data-sources.md` for provider details, and `references/3d-troubleshooting.md` for blank, coarse, unauthorized or timed-out renders.
## Files
- `assets/CesiumFlythrough.tsx` — reusable two-mode component.
- `assets/flight-path.ts` — dependency-free Chaikin route smoothing.
- `assets/example-Root.tsx` — landscape and city compositions.
- `assets/cesium-path.json` — sample landscape route.
- `assets/city-path.json` — sample city route.
- `assets/sample-river.geojson` — sample path-preparation input.
- `scripts/prep-cesium-path.mjs` — dependency-free landscape route preparation.
@@ -0,0 +1,284 @@
import React, {useEffect, useMemo, useRef, useState} from 'react';
import {
AbsoluteFill,
cancelRender,
continueRender,
delayRender,
useCurrentFrame,
useVideoConfig,
} from 'remotion';
import terrainPath from './cesium-path.json';
import {smoothFlightPath, type LngLat} from './flight-path';
export type FlyoverMode = 'landscape' | 'city';
export type {LngLat} from './flight-path';
export type CesiumFlythroughProps = {
mode?: FlyoverMode;
path?: LngLat[];
pathSmoothingPasses?: number;
altitudeStart?: number;
altitudeEnd?: number;
lookAheadKm?: number;
travelKm?: number;
pitchFromNadir?: number;
verticalExaggeration?: number;
maximumScreenSpaceError?: number;
};
const MAPTILER_KEY = process.env.REMOTION_MAPTILER_KEY;
const GOOGLE_MAPS_API_KEY = process.env.REMOTION_GOOGLE_MAPS_API_KEY;
const CESIUM_VER = '1.143';
const CDN = `https://cesium.com/downloads/cesiumjs/releases/${CESIUM_VER}/Build/Cesium/`;
const R = 6371;
const MAX_BANK = 0.13;
const BANK_GAIN = 0.6;
const havKm = (a: number[], b: number[]) => {
const r = Math.PI / 180;
const dLat = (b[1] - a[1]) * r;
const dLng = (b[0] - a[0]) * r;
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(a[1] * r) * Math.cos(b[1] * r) * Math.sin(dLng / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(h));
};
const makePathWalker = (path: LngLat[]) => {
if (path.length < 2)
throw new Error('Flyover path needs at least two points');
const cumulative = [0];
for (let i = 1; i < path.length; i++) {
cumulative.push(cumulative[i - 1] + havKm(path[i - 1], path[i]));
}
const lengthKm = cumulative[cumulative.length - 1];
const along = (km: number): LngLat => {
const d = Math.max(0, Math.min(lengthKm, km));
let i = 1;
while (i < cumulative.length && cumulative[i] < d) i++;
if (i >= cumulative.length) return path[path.length - 1];
const segmentLength = cumulative[i] - cumulative[i - 1] || 1;
const t = (d - cumulative[i - 1]) / segmentLength;
return [
path[i - 1][0] + (path[i][0] - path[i - 1][0]) * t,
path[i - 1][1] + (path[i][1] - path[i - 1][1]) * t,
];
};
return {along, lengthKm};
};
const bearing = (a: number[], b: number[]) => {
const r = Math.PI / 180;
const y = Math.sin((b[0] - a[0]) * r) * Math.cos(b[1] * r);
const x =
Math.cos(a[1] * r) * Math.sin(b[1] * r) -
Math.sin(a[1] * r) * Math.cos(b[1] * r) * Math.cos((b[0] - a[0]) * r);
return Math.atan2(y, x);
};
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
const clamp = (v: number, lo: number, hi: number) =>
Math.max(lo, Math.min(hi, v));
const loadCesium = () =>
new Promise<any>((resolve, reject) => {
if ((window as any).Cesium) return resolve((window as any).Cesium);
(window as any).CESIUM_BASE_URL = CDN;
const css = document.createElement('link');
css.rel = 'stylesheet';
css.href = `${CDN}Widgets/widgets.css`;
document.head.appendChild(css);
const script = document.createElement('script');
script.src = `${CDN}Cesium.js`;
script.onload = () => resolve((window as any).Cesium);
script.onerror = () =>
reject(new Error(`Failed to load CesiumJS ${CESIUM_VER}`));
document.head.appendChild(script);
});
export const CesiumFlythrough: React.FC<CesiumFlythroughProps> = ({
mode = 'landscape',
path = terrainPath as LngLat[],
pathSmoothingPasses = 3,
altitudeStart = 4600,
altitudeEnd = 4300,
lookAheadKm = 1.5,
travelKm = 13,
pitchFromNadir = 76,
verticalExaggeration = 1.1,
maximumScreenSpaceError = 8,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const started = useRef(false);
const viewerRef = useRef<any>(null);
const tilesetRef = useRef<any>(null);
const frame = useCurrentFrame();
const {durationInFrames, fps, width, height} = useVideoConfig();
const [ready, setReady] = useState(false);
const [handle] = useState(() =>
delayRender(`cesium init: ${mode}`, {timeoutInMilliseconds: 120000}),
);
const walker = useMemo(
() => makePathWalker(smoothFlightPath(path, pathSmoothingPasses)),
[path, pathSmoothingPasses],
);
const setCamera = (C: any, viewer: any, progress: number) => {
const maxTravel = Math.max(0, walker.lengthKm - lookAheadKm * 2);
const cameraDistance = Math.min(travelKm, maxTravel) * progress;
const camera = walker.along(cameraDistance);
const aim = walker.along(cameraDistance + lookAheadKm);
const aim2 = walker.along(cameraDistance + lookAheadKm * 2);
const heading = bearing(camera, aim);
let headingDelta = bearing(aim, aim2) - heading;
while (headingDelta > Math.PI) headingDelta -= 2 * Math.PI;
while (headingDelta < -Math.PI) headingDelta += 2 * Math.PI;
viewer.camera.setView({
destination: C.Cartesian3.fromDegrees(
camera[0],
camera[1],
lerp(altitudeStart, altitudeEnd, progress),
),
orientation: {
heading,
pitch: C.Math.toRadians(-(90 - pitchFromNadir)),
roll: clamp(headingDelta * BANK_GAIN, -MAX_BANK, MAX_BANK),
},
});
};
const tilesAreLoaded = (viewer: any) => {
if (mode === 'landscape') return viewer.scene.globe.tilesLoaded;
return Boolean(tilesetRef.current?.tilesLoaded);
};
const settle = (viewer: any) =>
new Promise<void>((resolve) => {
let stable = 0;
let ticks = 0;
const tick = () => {
viewer.render();
ticks++;
stable = tilesAreLoaded(viewer) ? stable + 1 : 0;
if (stable > 8 || ticks > 600) {
viewer.render();
resolve();
} else {
setTimeout(tick, 8);
}
};
tick();
});
useEffect(() => {
if (started.current) return;
started.current = true;
(async () => {
if (mode === 'landscape' && !MAPTILER_KEY) {
throw new Error(
'Set REMOTION_MAPTILER_KEY. Create a key at https://cloud.maptiler.com/account/keys/',
);
}
if (mode === 'city' && !GOOGLE_MAPS_API_KEY) {
throw new Error(
'Set REMOTION_GOOGLE_MAPS_API_KEY. Create a Map Tiles API key at https://developers.google.com/maps/documentation/tile/get-api-key',
);
}
if (mode === 'city' && durationInFrames / fps > 30) {
throw new Error(
'Google Photorealistic 3D Tiles compositions must not exceed 30 seconds',
);
}
const C = await loadCesium();
const viewer = new C.Viewer(containerRef.current, {
baseLayer: false,
baseLayerPicker: false,
geocoder: false,
homeButton: false,
sceneModePicker: false,
navigationHelpButton: false,
animation: false,
timeline: false,
fullscreenButton: false,
infoBox: false,
selectionIndicator: false,
contextOptions: {webgl: {preserveDrawingBuffer: true}},
});
if (mode === 'landscape') {
viewer.imageryLayers.addImageryProvider(
new C.UrlTemplateImageryProvider({
url: `https://api.maptiler.com/tiles/satellite-v2/{z}/{x}/{y}.jpg?key=${MAPTILER_KEY}`,
maximumLevel: 20,
}),
);
viewer.terrainProvider = await C.CesiumTerrainProvider.fromUrl(
`https://api.maptiler.com/tiles/terrain-quantized-mesh-v2/?key=${MAPTILER_KEY}`,
{requestVertexNormals: true},
);
viewer.creditDisplay.addStaticCredit(
new C.Credit(
'<a href="https://www.maptiler.com/copyright/" target="_blank">&copy; MapTiler</a>',
true,
),
);
}
if (mode === 'city') {
viewer.scene.globe.show = false;
const tileset = await C.Cesium3DTileset.fromUrl(
`https://tile.googleapis.com/v1/3dtiles/root.json?key=${GOOGLE_MAPS_API_KEY}`,
{
showCreditsOnScreen: true,
maximumScreenSpaceError,
},
);
viewer.scene.primitives.add(tileset);
tilesetRef.current = tileset;
}
viewer.useDefaultRenderLoop = false;
viewer.scene.skyAtmosphere.show = true;
viewer.scene.fog.enabled = true;
viewer.scene.globe.enableLighting = false;
viewer.scene.verticalExaggeration = verticalExaggeration;
(window as any).__CESIUM_FLYOVER__ = {C, mode};
viewerRef.current = viewer;
setCamera(C, viewer, 0);
await settle(viewer);
setReady(true);
continueRender(handle);
})().catch((error) => cancelRender(error));
}, [durationInFrames, fps, handle, mode]);
useEffect(() => {
if (!ready) return;
const frameHandle = delayRender(`cesium ${mode} frame ${frame}`, {
timeoutInMilliseconds: 60000,
});
const C = (window as any).__CESIUM_FLYOVER__.C;
const viewer = viewerRef.current;
const progress = durationInFrames <= 1 ? 0 : frame / (durationInFrames - 1);
setCamera(C, viewer, progress);
settle(viewer).then(() => continueRender(frameHandle));
}, [ready, frame, durationInFrames, mode]);
return (
<AbsoluteFill style={{backgroundColor: '#000'}}>
<div ref={containerRef} style={{width, height, position: 'absolute'}} />
{mode === 'city' ? (
<div
style={{
position: 'absolute',
top: 20,
right: 24,
color: 'white',
font: '500 18px/1.2 sans-serif',
textShadow: '0 1px 4px black',
}}
>
For promotional purposes only
</div>
) : null}
</AbsoluteFill>
);
};
@@ -0,0 +1,299 @@
[
[94.986139893537, 29.76417312959953],
[94.98637054463002, 29.764163024004816],
[94.98661427422437, 29.764153025681516],
[94.9868698103708, 29.764142908388347],
[94.98713600736019, 29.764132461562934],
[94.98741179569139, 29.76412145291623],
[94.9876962466277, 29.764109663422126],
[94.98798853462426, 29.764096898390772],
[94.98828790834106, 29.764082995679242],
[94.98859364523021, 29.764067827511347],
[94.98890509685035, 29.764051279216236],
[94.98922167960325, 29.76403324759024],
[94.98954294512464, 29.76401367774424],
[94.9898685550894, 29.76399255290906],
[94.99019820264337, 29.76396985787546],
[94.9905316178847, 29.763945606334815],
[94.9908685732739, 29.763919866576575],
[94.99120886105722, 29.763892700951896],
[94.99155229116181, 29.7638641665054],
[94.99189872300363, 29.76383429811986],
[94.99224811692223, 29.763803085651237],
[94.99260053337049, 29.763770509641105],
[94.99295601118384, 29.76373658086857],
[94.99331456742908, 29.763701322339223],
[94.99367598745215, 29.763664830647013],
[94.99404000156176, 29.763627219082974],
[94.99440635968021, 29.763588592698778],
[94.99477481502787, 29.76354905236459],
[94.9951451006731, 29.763508748648892],
[94.99578766204738, 29.763462125646818],
[94.99643710580968, 29.763412119743368],
[94.99709296623192, 29.763358988682928],
[94.99775479298779, 29.763302979031952],
[94.99842214521965, 29.763244327404465],
[94.99909470569231, 29.763183258547627],
[94.9997722961385, 29.76312010454948],
[95.00045474013422, 29.763055184243758],
[95.00114189582168, 29.762988795623112],
[95.00183368970049, 29.762921207778405],
[95.0025300799095, 29.76285270023514],
[95.00323103443773, 29.762783549665084],
[95.00393658318124, 29.76271400155497],
[95.00464681949704, 29.76264432424487],
[95.00536183331896, 29.76257477740286],
[95.00608171146906, 29.762505612813378],
[95.00680653793428, 29.762437075077955],
[95.00753639864557, 29.762369398844733],
[95.00827137837749, 29.762302811854063],
[95.00901153006761, 29.762237519590354],
[95.00975680776318, 29.76217368790719],
[95.01050716875933, 29.762111478518566],
[95.0112626067996, 29.76205102042169],
[95.01202312290016, 29.761992432502932],
[95.01278871942189, 29.76193583156792],
[95.01355940108247, 29.761881333165324],
[95.01433525277555, 29.761829093407865],
[95.01511636205572, 29.761779224166457],
[95.01590278872715, 29.76173180995877],
[95.0166868810408, 29.76168612055982],
[95.01746871725406, 29.761642332250204],
[95.0182483756254, 29.76160062131038],
[95.01902596675926, 29.761561177619345],
[95.019801682618, 29.761524238306453],
[95.02057570378254, 29.76149004865798],
[95.02134818878243, 29.761458838563573],
[95.02211921065114, 29.761430763930672],
[95.02288888930805, 29.761405973069504],
[95.02365734467304, 29.761384614290225],
[95.02442469666667, 29.761366835902948],
[95.02519095257038, 29.761352731197437],
[95.02595602043016, 29.761342344990513],
[95.02671980829304, 29.761335722099044],
[95.02748221022355, 29.76133286485609],
[95.02824308111781, 29.761333684556867],
[95.02900227020879, 29.761338090875398],
[95.02975960629327, 29.76134599002589],
[95.03051480114966, 29.76135728786741],
[95.03126749207338, 29.761371921751238],
[95.03201723576402, 29.761389829130785],
[95.03276363398774, 29.761410893146387],
[95.03350632277824, 29.761434973117545],
[95.03424521675927, 29.76146183394706],
[95.03498037607913, 29.761491215377145],
[95.035711917426, 29.761522866578012],
[95.03643998610582, 29.761556530192124],
[95.03716475930274, 29.761591886465347],
[95.03788653744915, 29.761628566185358],
[95.03860571883365, 29.76166618469001],
[95.03932258585421, 29.76170436456548],
[95.04003738892985, 29.76174273062808],
[95.04075025083816, 29.76178094997509],
[95.0414610559133, 29.76181870813213],
[95.04216961904287, 29.761855638407084],
[95.04287591713418, 29.761891331889718],
[95.04357986124926, 29.761925397709096],
[95.04428137757625, 29.761957488479126],
[95.04498041854592, 29.76198725048925],
[95.04567682817188, 29.762014317811357],
[95.04637032968634, 29.762038376323694],
[95.04706059416637, 29.762059099770216],
[95.04774726432963, 29.762076162373777],
[95.0484300050609, 29.762089241174117],
[95.04910871445378, 29.762098042845082],
[95.04978328153793, 29.762102280852183],
[95.05045359964508, 29.76210167552205],
[95.05111966253706, 29.76209602004179],
[95.05178153406797, 29.762085106891018],
[95.05243927503516, 29.762068731547185],
[95.05309287649075, 29.762046750046103],
[95.0537422946664, 29.762019099359485],
[95.05438748396233, 29.761985721609257],
[95.05502839823626, 29.76194655859307],
[95.05566488201883, 29.761901557942874],
[95.05629656919591, 29.761850805510885],
[95.05692313234233, 29.761794428680094],
[95.0575442743842, 29.761732580860688],
[95.0581596433054, 29.761665348138973],
[95.05876888708963, 29.761592816601354],
[95.05937164047754, 29.761515065256333],
[95.05996759228947, 29.761432144038746],
[95.06055643134583, 29.761344102883623],
[95.06113793491645, 29.761251010034922],
[95.06171214230999, 29.761153046649387],
[95.06227907948092, 29.761050389948267],
[95.06283877238438, 29.760943217152814],
[95.06339127381165, 29.760831691663462],
[95.06393669678933, 29.760716002374487],
[95.06447520396281, 29.76059636241658],
[95.06500695797821, 29.760472984920362],
[95.06553212847378, 29.760346104258282],
[95.0660508986937, 29.760216015040477],
[95.06656344194985, 29.760083025494016],
[95.06707002648291, 29.75994741412334],
[95.06757112978441, 29.759809440866206],
[95.06806722979658, 29.759669344181514],
[95.06855878142073, 29.759527354008487],
[95.06904617625263, 29.759383697903765],
[95.06952978875404, 29.75923861533451],
[95.07000993080214, 29.759092371156385],
[95.07048679175114, 29.758945227682446],
[95.07096044787545, 29.75879742836973],
[95.07143096114058, 29.758649219939148],
[95.0718984144853, 29.758500858117706],
[95.07236278188816, 29.7583525823887],
[95.07282412506945, 29.758204644682806],
[95.07328275575983, 29.75805728963777],
[95.07373905641253, 29.757910760813612],
[95.07419366476313, 29.757765217208473],
[95.07464735762416, 29.757620800921966],
[95.07510085345471, 29.757477677507868],
[95.07555467766437, 29.757336168750566],
[95.07600940811365, 29.757196598118206],
[95.07646537399586, 29.75705931280275],
[95.0769228207089, 29.756924677394206],
[95.07738220221682, 29.756793075496777],
[95.07784401968648, 29.75666487927172],
[95.07830874492939, 29.756540496044202],
[95.07877690647402, 29.756420332181715],
[95.07924898851259, 29.75630478841808],
[95.07972503722137, 29.756194196891926],
[95.08020511490892, 29.756088882533362],
[95.0806891403115, 29.75598918720403],
[95.08117690920173, 29.75589531641115],
[95.08166835685006, 29.755807414283098],
[95.08216335868525, 29.75572566824862],
[95.08266177736347, 29.755650250535062],
[95.08316349491537, 29.75558120546187],
[95.08366839703267, 29.755518567048284],
[95.08417639041532, 29.75546236367944],
[95.08468755179344, 29.755412571989652],
[95.08520236790308, 29.755369015997044],
[95.08572134650697, 29.75533148313236],
[95.0862450098908, 29.755299740305322],
[95.08677390781156, 29.75527358808779],
[95.08730861289804, 29.755252808551163],
[95.08784970300886, 29.755237175354623],
[95.08839755885471, 29.755226476280296],
[95.08895256438073, 29.755220494902016],
[95.089514993805, 29.755219019351184],
[95.09008489086116, 29.7552217729881],
[95.09066229928165, 29.75522847917293],
[95.09124726342738, 29.755238870403907],
[95.09183976966378, 29.755252726598844],
[95.09243978867215, 29.75526985018834],
[95.09304723439128, 29.755290065097345],
[95.09366184648091, 29.75531326126932],
[95.09428337414857, 29.755339326723966],
[95.09491171647382, 29.755368102171502],
[95.09554680939114, 29.75539940595205],
[95.09618846028675, 29.755433126230766],
[95.09683632409143, 29.75546923335011],
[95.09749004228215, 29.75550775219342],
[95.09814924669702, 29.755548754655436],
[95.0988136358514, 29.755592374644042],
[95.09948290826172, 29.755638746066982],
[95.1001568080277, 29.75568798494813],
[95.10083517891061, 29.75574017449467],
[95.10151797539464, 29.75579531974171],
[95.1022051519645, 29.755853425724286],
[95.10289664375364, 29.755914488290983],
[95.10359258928374, 29.755978570204803],
[95.10429306658138, 29.756045742645284],
[95.10499798654989, 29.756116067070348],
[95.10570717321444, 29.75618958016383],
[95.10642032295875, 29.75626636089044],
[95.10713711848761, 29.756346492746122],
[95.10785729025027, 29.75643004436662],
[95.10858043622838, 29.75651688160893],
[95.10930611534633, 29.75660684892344],
[95.11003407093642, 29.756699686157475],
[95.11076414467685, 29.756795123665622],
[95.1114960914754, 29.756892893838618],
[95.11222971288655, 29.756992700238396],
[95.1129649452222, 29.75709420093414],
[95.11370170201873, 29.75719709582746],
[95.11443986012442, 29.757301125099488],
[95.11517935555177, 29.7574061249826],
[95.11592009761476, 29.757511940391662],
[95.11666232015585, 29.75761835330566],
[95.11740624233691, 29.757725222810585],
[95.11815181076378, 29.75783253698326],
[95.11889913308254, 29.757940178669305],
[95.11964841222917, 29.758048000558695],
[95.12039993276164, 29.758155854652614],
[95.12115399312397, 29.75826357999786],
[95.1219108497459, 29.75837102690515],
[95.1226706293697, 29.758478081829693],
[95.12343325561484, 29.758584686897258],
[95.12419857627997, 29.758690818430438],
[95.12496638982559, 29.75879646083105],
[95.12573649209446, 29.7589015818837],
[95.12650863318528, 29.75900618637391],
[95.12728253192327, 29.759110282431784],
[95.12805798566751, 29.75921386162033],
[95.12883477913662, 29.759316919371038],
[95.12961273528985, 29.759419445656786],
[95.13039166301432, 29.759521458216526],
[95.13117134363408, 29.759622963323043],
[95.13195155721434, 29.759723948972997],
[95.13273211930314, 29.759824349786275],
[95.13351286507051, 29.759924061406032],
[95.13428648274362, 29.76002351015658],
[95.1350533298064, 29.760122544307915],
[95.1358137532818, 29.760221010686855],
[95.13656784419986, 29.760318809952974],
[95.13731567503761, 29.76041584848236],
[95.1380573752464, 29.760511990985425],
[95.13879310931623, 29.76060704815151],
[95.13952306473344, 29.760700794123938],
[95.14024751710419, 29.7607929520901],
[95.14096671686433, 29.760883206230545],
[95.14168092332761, 29.76097123202995],
[95.14239029599311, 29.761056732470486],
[95.14309486625548, 29.761139484506582],
[95.14379454089128, 29.761219462160696],
[95.14448922306659, 29.76129664046317],
[95.14517882147311, 29.76137100941381],
[95.14586312132259, 29.761442532574584],
[95.14654188135431, 29.761511168945386],
[95.14721489938523, 29.761576901934955],
[95.14788204807155, 29.761639772673718],
[95.14854319868547, 29.76169982923195],
[95.14919822095698, 29.761757127412878],
[95.14984699116548, 29.76181173598209],
[95.15048960377052, 29.761863851723042],
[95.15112617637942, 29.761913696049017],
[95.15175681293137, 29.761961561816054],
[95.15238148064795, 29.762007691856205],
[95.15300009096235, 29.762052309028526],
[95.1533539374323, 29.762086082676035],
[95.15370650809008, 29.762118928860186],
[95.15405759583123, 29.762150875711065],
[95.15440704121897, 29.76218191319173],
[95.1547548321985, 29.762211967913583],
[95.15510097264206, 29.762240955288657],
[95.15544525415498, 29.762268829619945],
[95.15578751039571, 29.76229553455492],
[95.15612769998111, 29.762320941845203],
[95.15646565712565, 29.762344985615734],
[95.156801142589, 29.76236761294652],
[95.1571338046714, 29.76238883654028],
[95.15746323934037, 29.76240869370805],
[95.15778903282519, 29.762427217766984],
[95.15811077257146, 29.762444433666733],
[95.15842800577592, 29.762460368791256],
[95.15874028837707, 29.76247502889767],
[95.15904716834646, 29.76248842410003],
[95.1593481499996, 29.762500590502704],
[95.15964271930007, 29.76251153961251],
[95.1599303416933, 29.76252129267978],
[95.16021045104225, 29.762529892932115],
[95.16048243037355, 29.762537389252305],
[95.16074559672008, 29.762543832974302],
[95.1609993202866, 29.762549265122786],
[95.16124294069552, 29.762553753099137],
[95.16147571071544, 29.762557390653928],
[95.16169677155058, 29.762560323342807]
]
@@ -0,0 +1,12 @@
[
[-74.0135, 40.7047],
[-74.0102, 40.7104],
[-74.0074, 40.7167],
[-74.0047, 40.7232],
[-74.0017, 40.7297],
[-73.9988, 40.7362],
[-73.9954, 40.7429],
[-73.9917, 40.7496],
[-73.9882, 40.7563],
[-73.9848, 40.7631]
]
@@ -0,0 +1,39 @@
import React from 'react';
import {Composition} from 'remotion';
import {CesiumFlythrough, type CesiumFlythroughProps} from './CesiumFlythrough';
import cityPath from './city-path.json';
export const RemotionRoot: React.FC = () => (
<>
<Composition
id="LandscapeFlyover"
component={CesiumFlythrough}
defaultProps={{mode: 'landscape'} satisfies CesiumFlythroughProps}
durationInFrames={24 * 30}
fps={30}
width={1920}
height={1080}
/>
<Composition
id="CityFlyover"
component={CesiumFlythrough}
defaultProps={
{
mode: 'city',
path: cityPath as [number, number][],
altitudeStart: 700,
altitudeEnd: 500,
lookAheadKm: 0.7,
travelKm: 4.5,
pitchFromNadir: 72,
verticalExaggeration: 1,
maximumScreenSpaceError: 6,
} satisfies CesiumFlythroughProps
}
durationInFrames={18 * 30}
fps={30}
width={1920}
height={1080}
/>
</>
);
@@ -0,0 +1,35 @@
export type LngLat = [number, number];
// Chaikin corner cutting turns a sparse route into a continuous curve. Repeated passes round
// direction changes into deliberate swerves instead of left-right heading bumps.
export const smoothFlightPath = (source: LngLat[], passes = 3): LngLat[] => {
if (source.length < 2)
throw new Error('Flyover path needs at least two points');
// Keep adjacent longitudes continuous for routes that cross the antimeridian.
const unwrapped: LngLat[] = [source[0]];
for (let index = 1; index < source.length; index++) {
const [lng, lat] = source[index];
const previous = unwrapped[index - 1][0];
let adjusted = lng;
while (adjusted - previous > 180) adjusted -= 360;
while (adjusted - previous < -180) adjusted += 360;
unwrapped.push([adjusted, lat]);
}
let curve = unwrapped;
for (let pass = 0; pass < Math.max(0, passes); pass++) {
const next: LngLat[] = [curve[0]];
for (let i = 0; i < curve.length - 1; i++) {
const a = curve[i];
const b = curve[i + 1];
next.push(
[a[0] * 0.75 + b[0] * 0.25, a[1] * 0.75 + b[1] * 0.25],
[a[0] * 0.25 + b[0] * 0.75, a[1] * 0.25 + b[1] * 0.75],
);
}
next.push(curve[curve.length - 1]);
curve = next;
}
return curve;
};
@@ -0,0 +1,215 @@
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {"name": "Yarlung Tsangpo (OSM, gorge way)"},
"geometry": {
"type": "LineString",
"coordinates": [
[94.8794306, 29.5453548],
[94.880058, 29.5500259],
[94.8804545, 29.5529773],
[94.8872806, 29.559007],
[94.8902386, 29.5645817],
[94.8945618, 29.5651505],
[94.8985437, 29.5703839],
[94.8978611, 29.5758448],
[94.9077237, 29.5843086],
[94.9107706, 29.5872382],
[94.9136749, 29.5879042],
[94.9208424, 29.5864253],
[94.9243692, 29.5890419],
[94.9243692, 29.5945028],
[94.9268508, 29.597544],
[94.9331942, 29.6000111],
[94.9350913, 29.6024165],
[94.9359736, 29.6042869],
[94.9354367, 29.6064452],
[94.9350634, 29.608155],
[94.9343462, 29.6094434],
[94.9295695, 29.6113703],
[94.9288061, 29.6124782],
[94.9268721, 29.6194181],
[94.9273809, 29.6276608],
[94.9274409, 29.6286334],
[94.9263783, 29.6303683],
[94.9250518, 29.6304537],
[94.922744, 29.6299103],
[94.9170292, 29.6256472],
[94.9144757, 29.6243553],
[94.9115439, 29.6243318],
[94.9008302, 29.6273853],
[94.8972905, 29.6276085],
[94.8897112, 29.6252949],
[94.8827398, 29.6253184],
[94.8806998, 29.6268686],
[94.8777241, 29.6317051],
[94.8768493, 29.6342436],
[94.8768641, 29.6346894],
[94.8769277, 29.6365972],
[94.8775677, 29.6391137],
[94.8804943, 29.6418187],
[94.8821969, 29.6438716],
[94.8877263, 29.6456718],
[94.8903366, 29.6468971],
[94.8959561, 29.6481151],
[94.9032857, 29.6528729],
[94.9113869, 29.6568592],
[94.9162734, 29.6587881],
[94.9182022, 29.6625172],
[94.9203882, 29.670104],
[94.9197453, 29.6744761],
[94.9192644, 29.6754058],
[94.9178164, 29.6782052],
[94.915116, 29.6816771],
[94.9094581, 29.6810342],
[94.9043145, 29.6796197],
[94.9018712, 29.6806484],
[94.8976278, 29.6812914],
[94.8909411, 29.6856634],
[94.8896552, 29.6890068],
[94.892227, 29.6990368],
[94.8978849, 29.704952],
[94.9017426, 29.7100956],
[94.9061206, 29.7112788],
[94.9065005, 29.7113815],
[94.9225743, 29.7192255],
[94.9264023, 29.7212652],
[94.9267629, 29.7213678],
[94.9341474, 29.723469],
[94.9360823, 29.7245705],
[94.9357152, 29.7273333],
[94.9344915, 29.7289804],
[94.9323502, 29.7303263],
[94.9298621, 29.73339],
[94.9294542, 29.734665],
[94.9298213, 29.7382244],
[94.9306755, 29.739157],
[94.9340021, 29.7439794],
[94.9355331, 29.7464083],
[94.9364901, 29.7488664],
[94.9372115, 29.749423],
[94.937775, 29.7498579],
[94.939019, 29.7500527],
[94.9407117, 29.7501944],
[94.9429346, 29.7513629],
[94.947778, 29.7543307],
[94.9541921, 29.755984],
[94.9574551, 29.7555414],
[94.9603715, 29.7553644],
[94.9629516, 29.755488],
[94.9676317, 29.757489],
[94.9709355, 29.7587814],
[94.9737019, 29.7603278],
[94.9738572, 29.7603796],
[94.9844009, 29.7638941],
[94.9852725, 29.7649069],
[94.9861359, 29.7675568],
[94.9898232, 29.7694497],
[94.9917263, 29.7691954],
[94.9966421, 29.7688099],
[95.0005707, 29.7670075],
[95.0007566, 29.7668999],
[95.0028203, 29.7657056],
[95.0080406, 29.7614995],
[95.0092963, 29.7609572],
[95.0138378, 29.7589957],
[95.0160159, 29.7574362],
[95.0194618, 29.7553902],
[95.0222306, 29.7524106],
[95.024336, 29.7498815],
[95.0273932, 29.7487547],
[95.0324981, 29.7493557],
[95.0339552, 29.7496724],
[95.0346257, 29.7499916],
[95.035882, 29.7507411],
[95.0375964, 29.7530966],
[95.0386203, 29.754818],
[95.0383895, 29.7567272],
[95.038166, 29.7590432],
[95.0395792, 29.7618349],
[95.0411334, 29.7631575],
[95.0420532, 29.7639402],
[95.0467825, 29.7661735],
[95.0495101, 29.7673453],
[95.0510063, 29.7681933],
[95.0516577, 29.7683919],
[95.0560621, 29.7692331],
[95.0577259, 29.7690047],
[95.0588453, 29.7684695],
[95.0650773, 29.767807],
[95.0705737, 29.761782],
[95.0720258, 29.7599696],
[95.0736121, 29.7540856],
[95.0754147, 29.751782],
[95.0758224, 29.751042],
[95.0760619, 29.750314],
[95.0759483, 29.7495284],
[95.0751984, 29.7480135],
[95.0728911, 29.7466988],
[95.0724585, 29.7445327],
[95.0732804, 29.7429802],
[95.0734102, 29.740789],
[95.0731362, 29.7386729],
[95.0744382, 29.7374902],
[95.075294, 29.7367129],
[95.079535, 29.7361346],
[95.081745, 29.736994],
[95.083005, 29.737484],
[95.0833905, 29.7430745],
[95.0844421, 29.7452715],
[95.0871532, 29.7473749],
[95.0918361, 29.7499864],
[95.0931923, 29.7511573],
[95.0943202, 29.7514189],
[95.0965266, 29.7510558],
[95.0991656, 29.7494408],
[95.1029871, 29.7472998],
[95.1038668, 29.7453591],
[95.1063039, 29.7417406],
[95.1084814, 29.741365],
[95.111726, 29.7426671],
[95.1138489, 29.7452914],
[95.1152135, 29.7465013],
[95.1154175, 29.7473556],
[95.1158661, 29.7480506],
[95.1167533, 29.7482586],
[95.1179004, 29.7491572],
[95.119434, 29.7515181],
[95.1195997, 29.7517319],
[95.1214023, 29.7535598],
[95.1239692, 29.754987],
[95.1260602, 29.7556005],
[95.1284973, 29.7561388],
[95.1311663, 29.7559002],
[95.1323477, 29.7559385],
[95.1344757, 29.7564723],
[95.1350155, 29.7583672],
[95.1382746, 29.7617848],
[95.1400377, 29.7646815],
[95.1410982, 29.7654339],
[95.1437689, 29.7649144],
[95.1469991, 29.7640131],
[95.1503617, 29.7622343],
[95.1506908, 29.7620602],
[95.1532721, 29.7600322],
[95.1565312, 29.7590432],
[95.1584059, 29.7594063],
[95.1589059, 29.7599317],
[95.1595043, 29.7612399],
[95.1611602, 29.7632119],
[95.1635565, 29.7646136],
[95.1700434, 29.7649144],
[95.1730828, 29.7644136],
[95.1748094, 29.7636814],
[95.1765005, 29.7628339],
[95.1794885, 29.7589784],
[95.1830673, 29.7576787],
[95.1848212, 29.7580682],
[95.1869103, 29.7594603]
]
}
}
]
}
@@ -0,0 +1,43 @@
# Flyover data sources
## Landscape
Use MapTiler for both layers:
- `terrain-quantized-mesh-v2`: elevation encoded as Cesium quantized-mesh terrain.
- `satellite-v2`: raster satellite imagery draped on that mesh.
CesiumJS renders the layers; it does not supply the data. Set `REMOTION_MAPTILER_KEY`.
Create a key:
- https://cloud.maptiler.com/account/keys/
Official documentation:
- https://docs.maptiler.com/cesium/
- https://docs.maptiler.com/schema-raster/terrain-3d/
## City
Use Google Photorealistic 3D Tiles. Google supplies one high-resolution 3D mesh already textured
with imagery. Disable the Cesium globe and do not add MapTiler terrain or satellite beneath it. Set
`REMOTION_GOOGLE_MAPS_API_KEY`.
Create and configure a key:
- https://developers.google.com/maps/documentation/tile/get-api-key
Enable the Map Tiles API in a billing-enabled Google Cloud project and restrict the key to that API.
The application restriction must permit the local headless Remotion request.
Official documentation:
- https://developers.google.com/maps/documentation/tile/3d-tiles
- https://developers.google.com/maps/documentation/tile/policies
## Why not extruded buildings
OSM-, Overture- and vector-tile building products primarily provide footprints, approximate heights
and optional roof attributes. They are useful for analytical or stylized maps, but they do not
provide the textured architecture required for a cinematic city flyover.
@@ -0,0 +1,128 @@
# 3D Flyover — architecture reference
Deep detail behind `TECHNIQUE.md`: provider loading, the camera-path pipeline, per-frame camera math, and
the proven terrain values. Both landscape and city modes have been forward-tested through Remotion.
## 1. Provider initialization
Create the Viewer with `baseLayer: false`, UI widgets disabled, and
`contextOptions.webgl.preserveDrawingBuffer: true`. Never hide the credit display.
### Landscape
Add MapTiler `satellite-v2` with `UrlTemplateImageryProvider`, then load
`terrain-quantized-mesh-v2` with `CesiumTerrainProvider.fromUrl({requestVertexNormals: true})`.
MapTiler supplies both datasets; no Cesium ion token is required.
### City
Do not add MapTiler. Hide the globe, then add:
```ts
viewer.scene.globe.show = false;
const tileset = await Cesium.Cesium3DTileset.fromUrl(
`https://tile.googleapis.com/v1/3dtiles/root.json?key=${GOOGLE_KEY}`,
{showCreditsOnScreen: true, maximumScreenSpaceError: 4},
);
viewer.scene.primitives.add(tileset);
```
Lower `maximumScreenSpaceError` improves refinement at substantial download/render cost. Start at
`4` for a hero landmark and `68` for wider urban shots. Enforce the Google 30-second promotional
video ceiling in the component.
Load Cesium from the CDN after setting `window.CESIUM_BASE_URL`; the tested version is `1.143`.
## 2. The camera path — structure & generation
Four properties matter, in order:
1. **Continuous curvature** — the camera must flow through curves, never "fly straight, snap to a new
heading, fly straight." The component applies three passes of **Chaikin corner cutting** to every
supplied path. Each pass replaces a segment with quarter and three-quarter points, rounding a
corner into a curve. Three passes are the default; four is softer, two is tighter.
2. **Constant ground speed** — precompute cumulative distance along the rounded curve and interpolate
by arc length. Do not animate by source-point index; unequal source spacing creates speed bumps.
3. **Minimized amplitude** — for detailed landscape centerlines, dampen the prepared path toward its
straight start→end chord by a fixed fraction `DAMP` (0 = dead straight, 1 = full river). This is
the single swerve-amplitude knob.
4. **Enough length**`PATHKM ≥ TRAVEL_KM + 2·LOOK_AHEAD_KM` so the look-ahead aim never clamps.
`../scripts/prep-cesium-path.mjs` does: clip a window of the source centerline → resample to even
arc-length spacing (0.1 km) → moving-average smooth (±2.8 km window, 2 passes) → dampen toward the chord
(`DAMP=0.45`). Validate with the heading-delta probe it prints — deltas should be small and change
_gradually_ (water-wars: `3,0,-1,-3,-4,1,7,9,5,-5,-12,-7,…`). Big jumps = corners = bad.
The component then applies Chaikin smoothing to this prepared route, or directly to a short
hand-authored city route. Keep city control points sparse and intentional; smoothing cannot rescue a
zig-zagging route that crosses the subject repeatedly.
**Source data:** OSM via Overpass (~0.3 km vertex spacing) — Natural Earth is too coarse for inner
gorges. overpass-api.de is often busy → mirror `overpass.kumi.systems`.
## 3. Camera animation per frame (position, heading, pitch, bank)
Walk the path by **arc length** (precompute cumulative distances once). Every frame:
- **Position** = the point at `dCam` km along the path, altitude `lerp(ALT_START, ALT_END, prog)`.
- **Heading** = bearing from the camera point to a **real point `LOOK_AHEAD_KM` further along the same
path**. A far aim averages wiggle → smooth heading; on a curved path it leads into the bend, so the
heading turns gently with the path. (A local-tangent aim spins the camera at every kink — don't.)
- **Pitch** = constant. We keep a MapLibre-style param `PITCH_FROM_NADIR` (90 = horizon), then convert:
**Cesium pitch = `-(90 - PITCH_FROM_NADIR)`** (Cesium: 0 = horizon, -90 = straight down). 76° → -14°.
- **Bank (roll)** = lean _into_ the turn — the helicopter tell. Measure turn rate as the bearing change
between `aim` and a point `2·LOOK_AHEAD_KM` ahead; `roll = clamp(dH · BANK_GAIN, ±MAX_BANK)`. Because
the path is smooth, `dH` changes gradually → the bank eases in and out, never jerks.
```ts
const setCamera = (C, viewer, prog) => {
const dCam = Math.min(TRAVEL_KM, PATHKM - LOOK_AHEAD_KM * 2) * prog;
const cam = alongPath(dCam); // arc-length point
const aim = alongPath(dCam + LOOK_AHEAD_KM); // heading target (real point on the path)
const aim2 = alongPath(dCam + LOOK_AHEAD_KM * 2); // turn-rate probe → bank
const heading = bearing(cam, aim);
let dH = bearing(aim, aim2) - heading; while (dH > Math.PI) dH -= 2*Math.PI; while (dH < -Math.PI) dH += 2*Math.PI;
viewer.camera.setView({
destination: C.Cartesian3.fromDegrees(cam[0], cam[1], lerp(ALT_START, ALT_END, prog)),
orientation: { heading, pitch: C.Math.toRadians(-(90 - PITCH_FROM_NADIR)), roll: clamp(dH * BANK_GAIN, -MAX_BANK, MAX_BANK) },
});
};
```
> **Cesium vs MapLibre conventions (gotcha):** Cesium heading is radians, 0 = north, clockwise. Pitch
> 0 = horizon, negative = down (MapLibre is the inverse). Roll positive = bank right; tune the sign by eye.
## 4. The feel — proven water-wars values
| Param | Value | Meaning |
| ------------------------ | ---------------------- | ------------------------------------------------------------------------------------ |
| `TRAVEL_KM` | 13 | How far the camera travels. **Speed = `TRAVEL_KM / durationSeconds`.** |
| duration | 24 s (720 f @30) | 13 km / 24 s ≈ **0.54 km/s** — a slow, peaceful glide. 8 s felt "extremely rushed". |
| `ALT_START → ALT_END` | 4600 → 4300 m ASL | Absolute (terrain-independent). Inside the corridor walls → fly _through_, not over. |
| `LOOK_AHEAD_KM` | 1.5 | Heading smoothness vs responsiveness. |
| `PITCH_FROM_NADIR` | 76° | Stare-ahead down the corridor (90 = level). → Cesium -14°. |
| `MAX_BANK` / `BANK_GAIN` | 0.13 rad (~7.5°) / 0.6 | Helicopter lean into turns. |
| `verticalExaggeration` | 1.1 | Subtle terrain drama. |
| `DAMP` (prep) | 0.45 | Swerve amount: higher = weavier, lower = straighter. |
**A slow camera renders fast.** At 0.54 km/s the camera moves ~18 m/frame, so tiles stay cached and each
`settle()` returns almost immediately; the 720-frame render completed in one pass (no chunk-rendering).
## 5. The complete component
The full, runnable component is `../assets/CesiumFlythrough.tsx` — read it directly. Its shape:
- `loadCesium()` — inject `CESIUM_BASE_URL` + the CDN `Cesium.js`, resolve when loaded.
- init effect — build the Viewer (§1), `setCamera(…, 0)`, `await settle(viewer)`, `continueRender`.
- `settle(viewer)` — loop `viewer.render()` until `globe.tilesLoaded` for landscapes or
`tileset.tilesLoaded` for cities is stable for ~8 ticks (cap ~600).
- per-frame effect — `delayRender({timeoutInMilliseconds: 60000})``setCamera(prog)``settle()``continueRender`.
## 6. Render
```bash
bunx remotion still src/index.ts <Comp> out.png --frame=N --gl=angle --timeout=180000 # validate framing/bank first
bunx remotion render src/index.ts <Comp> out.mp4 --gl=angle --concurrency=1 --timeout=180000
```
`--gl=angle` is mandatory. Use `--concurrency=1`; `settle()` already serializes tile loading.
@@ -0,0 +1,57 @@
# 3D Flyover — troubleshooting
## The headless dead-end (why we render through Remotion)
Cesium's globe **will not draw in a standalone headless Playwright/Chromium** harness. Verified on
Apple M4 (ANGLE Metal active, WebGL working): the skybox/stars render, but the globe surface produces
**zero draw commands** (`scene.frameState.commandList.length === 0`), `globe.tilesLoaded` never goes
true, and frames come back as the black starfield. No network failures; `sampleTerrainMostDetailed`
succeeds (terrain data is reachable). Dead-ends tried, all failed:
- default render loop, manual `scene.render()`, manual `viewer.render()`, headed mode (context-destroyed).
**What works:** render Cesium **through Remotion** — same headless Chrome, but driven by Remotion's frame
loop with these four non-negotiables:
1. `useDefaultRenderLoop = false` — drive frames by hand.
2. Per frame call **`viewer.render()`**, NOT `scene.render()`. `viewer.render()` does the full frame
(`initializeFrame` → tile streaming → render); `scene.render()` skips frame-init, so tiles never
advance and the globe never appears. **This is the single most important line.**
3. `contextOptions: { webgl: { preserveDrawingBuffer: true } }` so Remotion's screenshot captures pixels.
4. Gate init + every frame with `delayRender(…, {timeoutInMilliseconds})` — tile loading can exceed Remotion's
default; use 60120 s.
The standalone `flythrough.html` / `render.mjs` / `probe.mjs` from the original spike are kept only as the
record of this dead-end. The canonical render path is the Remotion component
(`../assets/CesiumFlythrough.tsx`).
## Symptom → fix
| Symptom | Cause | Fix |
| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| Frames are black with stars | Globe not drawing (headless Playwright, or `scene.render()` used) | Render through Remotion; use `viewer.render()`; `useDefaultRenderLoop=false`. |
| Screenshots blank/transparent | No `preserveDrawingBuffer` | `contextOptions:{ webgl:{ preserveDrawingBuffer:true } }`. |
| "delayRender timed out" | Cold tiles exceed the default | `delayRender(…, {timeoutInMilliseconds: 120000})` + `--timeout=180000`. |
| Globe is a dark/navy sphere | Imagery layer didn't attach | `baseLayer:false` then `viewer.imageryLayers.addImageryProvider(...)`. |
| High-pitch frame shows a void/starfield above the horizon | No atmosphere | `viewer.scene.skyAtmosphere.show = true`. |
| 403 on tiles in headless | Domain-locked MapTiler key | Use an **unrestricted** key. |
| Google root tileset returns 403 | Map Tiles API disabled, billing absent, wrong key, or application restriction blocks local headless rendering | Enable Map Tiles API and billing; restrict the key to that API while allowing the Remotion request. |
| Google scene shows a duplicate/competing surface | MapTiler or the Cesium globe is still enabled | Do not add MapTiler; set `viewer.scene.globe.show=false`. |
| Google mesh remains coarse | Screen-space error is too high or the capture starts before refinement | Lower `maximumScreenSpaceError`; settle on `tileset.tilesLoaded`. |
| WebGL unavailable / software renderer | Missing GL flag | Render with `--gl=angle`. |
| Camera looks at sky / ground, not terrain | Pitch sign / convention | Cesium pitch 0 = horizon, negative = down (inverse of MapLibre); `-(90 - PITCH_FROM_NADIR)`. |
| Aim/turn-probe clamps near the end | Path too short | `PATHKM ≥ TRAVEL_KM + 2·LOOK_AHEAD_KM`; raise `WINDOW_KM` in prep. |
| Path feels like straight-then-corner | Douglas-Peucker simplification | Use resample → moving-average smooth (see architecture §2), not `turf.simplify`. |
| Camera bumps left and right instead of swerving | Sparse route vertices are still being followed as straight segments | Keep `pathSmoothingPasses={3}`; use sparse intentional control points and arc-length movement. |
## Gotchas checklist
- **`viewer.render()`, never `scene.render()`** per frame. The single biggest trap.
- Cesium loads from **CDN**; set `window.CESIUM_BASE_URL` _before_ injecting the script.
- `preserveDrawingBuffer: true` or screenshots are blank.
- `delayRender` uses `timeoutInMilliseconds`; set it to at least 60,000.
- Terrain: `baseLayer:false`, then add MapTiler imagery.
- Google: no MapTiler, hide the globe, retain `showCreditsOnScreen:true`.
- Always `skyAtmosphere.show = true`.
- Validate the path's heading-delta probe and render one **still** (framing + bank) before the full mp4.
- Cesium pitch/roll conventions are inverted vs MapLibre.
@@ -0,0 +1,161 @@
// Camera-path generator for the Cesium flythrough. Turns a river/route centerline GeoJSON into a LONG,
// CONTINUOUSLY-curving camera path so the camera banks through smooth flowing curves (no
// straight-then-corner). Method: clip → resample to even spacing → moving-average smooth (inherently
// continuous curvature) → dampen lateral deviation toward the straight chord (dials swerve amplitude).
// No Douglas-Peucker (that concentrates curvature at sparse control points → corners).
//
// RUN (out of the box, against the shipped sample):
// node prep-cesium-path.mjs
// → reads assets/sample-river.geojson (override: node prep-cesium-path.mjs <input.geojson> <output.json>)
// → writes assets/cesium-path.json (then import that JSON in CesiumFlythrough.tsx, or copy it
// into your Remotion project's src/geo/ and adjust the import)
//
// ADAPT for a new location: change START (a point ON your centerline where the corridor opens),
// WINDOW_KM, and DAMP/SMOOTH below. Input must be a single LineString feature (features[0].geometry).
import {readFileSync, writeFileSync, mkdirSync} from 'fs';
import {dirname, resolve} from 'path';
import {fileURLToPath} from 'url';
const __dir = dirname(fileURLToPath(import.meta.url));
const IN = process.argv[2] || resolve(__dir, '../assets/sample-river.geojson');
const OUT = process.argv[3] || resolve(__dir, '../assets/cesium-path.json');
const havKm = (a, b) => {
const R = 6371,
r = Math.PI / 180,
dLat = (b[1] - a[1]) * r,
dLng = (b[0] - a[0]) * r;
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(a[1] * r) * Math.cos(b[1] * r) * Math.sin(dLng / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(h));
};
const gorge = JSON.parse(readFileSync(IN, 'utf8')).features[0].geometry
.coordinates;
// ADAPT: clip ~24 km of river from the reach where the flythrough opens. START must be a point ON the
// centerline (the script snaps to the nearest vertex). The sample's opening is the Yarlung gorge:
const START = [94.968, 29.757];
let s0 = 0,
best = Infinity;
gorge.forEach((p, i) => {
const d = havKm(p, START);
if (d < best) {
best = d;
s0 = i;
}
});
const WINDOW_KM = 30; // clip to ~end of gorge data; smoothing+dampening shrink it to the usable corridor
const clip = [];
for (let i = s0, acc = 0; i < gorge.length; i++) {
if (i > s0) acc += havKm(gorge[i - 1], gorge[i]);
if (acc > WINDOW_KM) break;
clip.push(gorge[i]);
}
// Resample to even arc-length spacing so curvature is distributed evenly along the path.
const STEP_KM = 0.1;
const resample = (coords) => {
const out = [coords[0].slice()];
let carry = 0,
from = coords[0];
for (let i = 1; i < coords.length; i++) {
let segLen = havKm(from, coords[i]);
while (carry + segLen >= STEP_KM) {
const t = (STEP_KM - carry) / segLen;
const np = [
from[0] + (coords[i][0] - from[0]) * t,
from[1] + (coords[i][1] - from[1]) * t,
];
out.push(np);
from = np;
segLen = havKm(from, coords[i]);
carry = 0;
}
carry += segLen;
from = coords[i];
}
return out;
};
// Moving-average smoothing — inherently continuous (no kinks). Window in points; repeat for extra glass.
const smoothMA = (coords, w, passes) => {
let c = coords;
for (let p = 0; p < passes; p++) {
c = c.map((_, i) => {
let sx = 0,
sy = 0,
n = 0;
for (
let j = Math.max(0, i - w);
j <= Math.min(c.length - 1, i + w);
j++
) {
sx += c[j][0];
sy += c[j][1];
n++;
}
return [sx / n, sy / n];
});
}
return c;
};
const SMOOTH_W = 28; // ±2.8 km window — turns meanders into smooth flowing curves
const SMOOTH_PASSES = 2;
const DAMP = 0.45; // keep 45% of the (already-smooth) deviation → gentle, continuous swerve
const even = resample(clip);
const sm = smoothMA(even, SMOOTH_W, SMOOTH_PASSES);
const lat0 = (sm[0][1] * Math.PI) / 180;
const kx = 111.32 * Math.cos(lat0),
ky = 110.57;
const toXY = (p) => [(p[0] - sm[0][0]) * kx, (p[1] - sm[0][1]) * ky];
const toLL = (xy) => [sm[0][0] + xy[0] / kx, sm[0][1] + xy[1] / ky];
const A = toXY(sm[0]),
B = toXY(sm[sm.length - 1]);
const AB = [B[0] - A[0], B[1] - A[1]],
len2 = AB[0] ** 2 + AB[1] ** 2;
const path = sm.map((p) => {
const P = toXY(p);
const t = ((P[0] - A[0]) * AB[0] + (P[1] - A[1]) * AB[1]) / len2;
const proj = [A[0] + t * AB[0], A[1] + t * AB[1]];
return toLL([
proj[0] + (P[0] - proj[0]) * DAMP,
proj[1] + (P[1] - proj[1]) * DAMP,
]);
});
mkdirSync(dirname(OUT), {recursive: true});
writeFileSync(OUT, JSON.stringify(path));
let len = 0;
for (let i = 1; i < path.length; i++) len += havKm(path[i - 1], path[i]);
console.log(
`cesium-path: clip ${clip.length} → resample ${even.length} → smooth → ${path.length} pts · ${len.toFixed(1)} km`,
);
const bear = (a, b) => {
const r = Math.PI / 180;
const y = Math.sin((b[0] - a[0]) * r) * Math.cos(b[1] * r);
const x =
Math.cos(a[1] * r) * Math.sin(b[1] * r) -
Math.sin(a[1] * r) * Math.cos(b[1] * r) * Math.cos((b[0] - a[0]) * r);
return (Math.atan2(y, x) * 180) / Math.PI;
};
// heading sampled every ~1.5 km — should change gradually & continuously (no big jumps = no corners)
const stepPts = Math.round(1.5 / STEP_KM);
let prev = null,
hs = [];
for (let i = 0; i + stepPts < path.length; i += stepPts) {
const h = bear(path[i], path[i + stepPts]);
if (prev !== null) {
let d = h - prev;
while (d > 180) d -= 360;
while (d < -180) d += 360;
hs.push(d.toFixed(0));
}
prev = h;
}
console.log(` heading deltas every 1.5km (deg): ${hs.join(', ')}`);
@@ -0,0 +1,449 @@
---
name: maps-mapbox
description: Make deterministic Remotion 2D map animations with Mapbox GL JS and Turf. Use when the user chooses Mapbox for animated routes, map markers, labels, camera movement, or Mapbox styles.
metadata:
tags: map, map animation, mapbox, turf, geojson, route animation
---
Use Mapbox GL JS for rendering maps in Remotion when the user wants Mapbox styles or higher-fidelity map visuals and has a Mapbox access token. Use Turf for geospatial operations such as great-circle routes, distances, slicing lines, and positions along routes.
Use this technique only when the user has a Mapbox access token and wants Mapbox styles or data.
## Core rules
- Prefer `@turf/turf` for geospatial work. Do not hand-roll distance, great-circle, route slicing, or coordinate interpolation unless the user explicitly needs a custom non-geodesic effect.
- Use GeoJSON sources and Mapbox layers for lines, markers, and labels. Avoid DOM `Marker` elements unless the user specifically asks for HTML markers.
- Keep the live map camera static by default. Before moving it on every frame, read [moving-map stability](references/render-stability.md). Prefer a fixed map plate for satellite imagery, hillshade, or a modest 2D reframe.
- Use a live per-frame camera only after rendering a short MP4 and checking for shimmer. This 2D technique does not provide genuine terrain, pitch, bearing, or banking.
- Disable non-deterministic map behavior: `interactive: false`, `fadeDuration: 0`.
- Drive animation from `useCurrentFrame()`; do not use CSS transitions or browser-timed animation.
- Use `delayRender()` / `continueRender()` around map loading and per-frame map updates.
- Set `preserveDrawingBuffer: true` and render WebGL with `bunx remotion ... --gl=angle`.
- Before continuing the initial render, add sources/layers, apply the frame-0 camera with `jumpTo()` or `setFreeCameraOptions()`, then wait for `idle`.
- Do not add a `mapInstance.remove()` cleanup function; it can interfere with Remotion's render lifecycle.
- Use Mapbox style URLs such as `mapbox://styles/mapbox/standard` or a user-provided custom style.
- Do not install `@types/mapbox-gl`; Mapbox GL JS ships its own types.
- Keep required provider attribution visible and verify current provider terms before rendering.
- Record the source and effective date of custom or disputed geography.
- Inspect rendered pixels, not only Studio playback, at every required aspect ratio.
Coordinates in Mapbox, Turf, and GeoJSON are `[longitude, latitude]`.
```ts
const zurich: [number, number] = [8.5417, 47.3769];
const newYork: [number, number] = [-74.006, 40.7128];
```
## Prerequisites
Install Mapbox GL JS and Turf with the project's package manager.
```bash
npm i mapbox-gl @turf/turf
```
```bash
bun i mapbox-gl @turf/turf
```
```bash
yarn add mapbox-gl @turf/turf
```
```bash
pnpm i mapbox-gl @turf/turf
```
Import the Mapbox CSS once in the component or an app-level stylesheet:
```ts
import 'mapbox-gl/dist/mapbox-gl.css';
```
Mapbox requires a public access token. Prefer passing it as an input prop or reading it from an environment variable that is available to the bundled Remotion code.
```ts
const mapboxAccessToken = process.env.REMOTION_MAPBOX_TOKEN;
if (!mapboxAccessToken) {
throw new Error('Set REMOTION_MAPBOX_TOKEN to render Mapbox maps.');
}
```
## Basic map example
```tsx
import {useEffect, useRef, useState} from 'react';
import {AbsoluteFill, useDelayRender, useVideoConfig} from 'remotion';
import mapboxgl from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
const zurich: [number, number] = [8.5417, 47.3769];
const mapboxAccessToken = process.env.REMOTION_MAPBOX_TOKEN;
if (!mapboxAccessToken) {
throw new Error('Set REMOTION_MAPBOX_TOKEN to render Mapbox maps.');
}
export const MyComposition = () => {
const containerRef = useRef<HTMLDivElement>(null);
const {delayRender, continueRender} = useDelayRender();
const {width, height} = useVideoConfig();
const [loadingHandle] = useState(() => delayRender('Loading Mapbox map'));
useEffect(() => {
if (!containerRef.current) {
return;
}
const mapInstance = new mapboxgl.Map({
accessToken: mapboxAccessToken,
container: containerRef.current,
style: 'mapbox://styles/mapbox/standard',
center: zurich,
zoom: 7,
interactive: false,
attributionControl: false,
fadeDuration: 0,
canvasContextAttributes: {
preserveDrawingBuffer: true,
},
});
mapInstance.on('load', () => {
mapInstance.jumpTo({center: zurich, zoom: 7});
mapInstance.once('idle', () => {
continueRender(loadingHandle);
});
});
}, [continueRender, loadingHandle]);
return (
<AbsoluteFill>
<div ref={containerRef} style={{width, height, position: 'absolute'}} />
</AbsoluteFill>
);
};
```
Animated examples should keep the loaded map in React state and skip per-frame updates until that state is set.
## Animated flight route example
This example shows the recommended pattern for route animations:
- Turf creates the route and markers.
- Turf slices the route for line reveal animation.
- Mapbox renders the route with GeoJSON sources and layers.
- The camera uses `jumpTo()` with animated center, zoom, bearing, and pitch.
- Frame 0 is prepared before `continueRender()`.
```tsx
import * as turf from '@turf/turf';
import {useEffect, useRef, useState} from 'react';
import {
AbsoluteFill,
Easing,
interpolate,
useCurrentFrame,
useDelayRender,
useVideoConfig,
} from 'remotion';
import mapboxgl, {type GeoJSONSource, type Map} from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
const zurich: [number, number] = [8.5417, 47.3769];
const newYork: [number, number] = [-74.006, 40.7128];
const mapboxAccessToken = process.env.REMOTION_MAPBOX_TOKEN;
if (!mapboxAccessToken) {
throw new Error('Set REMOTION_MAPBOX_TOKEN to render Mapbox maps.');
}
const greatCircleLine = (from: [number, number], to: [number, number]) => {
const route = turf.greatCircle(from, to, {npoints: 100});
if (route.geometry.type === 'LineString') {
return turf.lineString(route.geometry.coordinates);
}
// Great-circle routes crossing the antimeridian can become MultiLineString.
// Keep the example valid by choosing the longest segment.
const longestSegment = route.geometry.coordinates.reduce((longest, segment) => {
return segment.length > longest.length ? segment : longest;
});
return turf.lineString(longestSegment);
};
const targetRoute = greatCircleLine(zurich, newYork);
const targetRouteDistance = turf.length(targetRoute);
const cityMarkers = turf.featureCollection([
turf.point(zurich, {name: 'Zurich'}),
turf.point(newYork, {name: 'New York'}),
]);
const clampProgress = (progress: number) => Math.min(1, Math.max(0, progress));
const distanceAlong = (totalDistance: number, progress: number) => {
// Keep the route non-empty at progress 0; Turf can error on zero-length slices.
return Math.max(0.001, totalDistance * clampProgress(progress));
};
const getPartialTargetRoute = (progress: number) => {
return turf.lineSliceAlong(
targetRoute,
0,
distanceAlong(targetRouteDistance, progress),
);
};
const getCameraOptions = (progress: number) => {
const target = turf.along(
targetRoute,
distanceAlong(targetRouteDistance, progress),
).geometry.coordinates as [number, number];
return {
center: target,
zoom: interpolate(progress, [0, 0.5, 1], [7, 2.4, 8], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
easing: Easing.bezier(0.645, 0.045, 0.355, 1),
}),
bearing: interpolate(progress, [0, 1], [-20, 35], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
}),
pitch: interpolate(progress, [0, 0.25, 0.75, 1], [25, 55, 55, 30], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
easing: Easing.bezier(0.645, 0.045, 0.355, 1),
}),
};
};
export const MyComposition = () => {
const containerRef = useRef<HTMLDivElement>(null);
const frame = useCurrentFrame();
const {delayRender, continueRender} = useDelayRender();
const {durationInFrames, height, width} = useVideoConfig();
const [map, setMap] = useState<Map | null>(null);
const [loadingHandle] = useState(() => delayRender('Loading Mapbox map'));
useEffect(() => {
if (!containerRef.current) {
return;
}
const mapInstance = new mapboxgl.Map({
accessToken: mapboxAccessToken,
container: containerRef.current,
style: 'mapbox://styles/mapbox/standard',
center: zurich,
zoom: 7,
interactive: false,
attributionControl: false,
fadeDuration: 0,
canvasContextAttributes: {
preserveDrawingBuffer: true,
},
});
mapInstance.on('load', () => {
mapInstance.addSource('trace', {
type: 'geojson',
data: getPartialTargetRoute(0),
});
mapInstance.addLayer({
id: 'trace-line',
type: 'line',
source: 'trace',
layout: {
'line-cap': 'round',
'line-join': 'round',
},
paint: {
'line-color': '#111111',
'line-width': 7,
},
});
mapInstance.addSource('city-markers', {
type: 'geojson',
data: cityMarkers,
});
mapInstance.addLayer({
id: 'city-marker-dots',
type: 'circle',
source: 'city-markers',
paint: {
'circle-color': '#f03b20',
'circle-radius': 12,
'circle-stroke-color': '#ffffff',
'circle-stroke-width': 4,
},
});
mapInstance.addLayer({
id: 'city-marker-labels',
type: 'symbol',
source: 'city-markers',
layout: {
'text-allow-overlap': true,
'text-anchor': 'top',
'text-field': ['get', 'name'],
'text-offset': [0, 0.9],
'text-size': 28,
},
paint: {
'text-color': '#111111',
'text-halo-color': '#ffffff',
'text-halo-width': 3,
},
});
mapInstance.jumpTo(getCameraOptions(0));
mapInstance.once('idle', () => {
setMap(mapInstance);
continueRender(loadingHandle);
});
});
}, [continueRender, loadingHandle]);
useEffect(() => {
if (!map) {
return;
}
const handle = delayRender('Rendering Mapbox frame');
const timelineProgress = interpolate(frame, [0, durationInFrames - 1], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
const travelProgress = interpolate(timelineProgress, [0.2, 0.82], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
easing: Easing.bezier(0.645, 0.045, 0.355, 1),
});
const trace = map.getSource('trace') as GeoJSONSource | undefined;
trace?.setData(getPartialTargetRoute(travelProgress));
map.jumpTo(getCameraOptions(travelProgress));
map.once('idle', () => continueRender(handle));
// Force an idle event even if the camera parameters are unchanged from the previous frame.
map.triggerRepaint();
}, [continueRender, delayRender, durationInFrames, frame, map]);
return (
<AbsoluteFill style={{backgroundColor: '#e8eef3'}}>
<div ref={containerRef} style={{height, position: 'absolute', width}} />
</AbsoluteFill>
);
};
```
## Camera guidance
For a validated live-camera route animation, animate `center`, `zoom`, `bearing`, and `pitch` with `jumpTo()`:
```ts
map.jumpTo({
center,
zoom,
bearing,
pitch,
});
```
Keep route progress and camera progress separate if the camera needs to lead, lag, zoom out, or zoom back in. For cinematic 3D camera moves, load the 3D flyover branch from the parent skill.
## Lines
Use GeoJSON sources for lines. Unless the user asks, do not add glow effects or extra decorative points.
For geodesic flight routes, use Turf:
```ts
const line = greatCircleLine(start, end);
const distance = turf.length(line);
const partialLine = turf.lineSliceAlong(
line,
0,
// Keep the route non-empty at progress 0.
Math.max(0.001, distance * progress),
);
```
For a visually straight line on the map, use a simple GeoJSON `LineString` between the two points instead of `greatCircle()`.
## Markers and labels
Use map-native GeoJSON layers for markers and labels:
```tsx
mapInstance.addSource('markers', {
type: 'geojson',
data: turf.featureCollection([
turf.point([-118.2437, 34.0522], {name: 'Los Angeles'}),
]),
});
mapInstance.addLayer({
id: 'marker-dots',
type: 'circle',
source: 'markers',
paint: {
'circle-color': '#f03b20',
'circle-radius': 12,
'circle-stroke-color': '#ffffff',
'circle-stroke-width': 4,
},
});
mapInstance.addLayer({
id: 'marker-labels',
type: 'symbol',
source: 'markers',
layout: {
'text-allow-overlap': true,
'text-anchor': 'top',
'text-field': ['get', 'name'],
'text-offset': [0, 0.9],
'text-size': 28,
},
paint: {
'text-color': '#111111',
'text-halo-color': '#ffffff',
'text-halo-width': 3,
},
});
```
Make marker sizes and label font sizes large enough for the composition resolution.
## Styles
Default to Mapbox Standard:
```ts
style: 'mapbox://styles/mapbox/standard'
```
If the user requests another style, use any valid Mapbox style URL.
## Rendering
For WebGL map renders, prefer single concurrency and ANGLE:
```bash
bunx remotion render [composition-id] out/video.mp4 --gl=angle --concurrency=1
```
Use the equivalent package runner for the project. In npm projects, use `npx`; in Bun projects, use `bunx`.
@@ -0,0 +1,64 @@
# Moving Map Render Stability
Read this reference before building a moving 2D MapTiler scene or diagnosing a wavering Remotion render.
## Symptom and cause
If basemap detail shimmers or jitters during a pan/zoom, the likely cause is per-frame `map.jumpTo()`.
Headless MapTiler capture can resample both vector hillshade and satellite imagery differently frame to
frame. Tile retries, easing changes, and label changes do not solve that renderer effect.
## Required pattern: fixed map plate
For any 2D pan/zoom:
1. Render the MapTiler canvas once at the largest required zoom in an oversized container. Size it from the camera route and keep each dimension below the browser's reliable WebGL render-buffer limit (commonly 4096 px). Do **not** blindly use 3×: a 1920×1080 composition becomes 5760 px wide and Chromium may silently downsample it, causing visible pixelation during the CSS zoom.
2. Keep the map's camera static.
3. For each frame, calculate the approved target centre/zoom, then move the canvas with CSS `translate` + `scale`.
4. Apply the same transform to every projected HTML overlay.
5. Continue to animate GeoJSON data and paint properties imperatively; only the renderer camera is frozen.
Keep pitch and bearing constant. Use a 3D engine such as Cesium for genuine changing pitch/bearing or a terrain flythrough.
```ts
const baseZoom = Math.max(start.zoom, end.zoom);
const map = new maptilersdk.Map({
container,
style,
center: end.center,
zoom: baseZoom,
pitch: end.pitch ?? 0,
bearing: end.bearing ?? 0,
interactive: false,
fadeDuration: 0,
canvasContextAttributes: {preserveDrawingBuffer: true},
});
// Per Remotion frame. `camera` is the approved centre/zoom interpolation.
const projected = map.project(camera.center);
const scale = 2 ** (camera.zoom - baseZoom);
const plate = {
transform: `translate(${width / 2 - projected.x * scale}px, ${height / 2 - projected.y * scale}px) scale(${scale})`,
transformOrigin: "0 0",
};
// Convert label projection with exactly the same plate transform.
const labelX = labelPoint.x * scale + width / 2 - projected.x * scale;
const labelY = labelPoint.y * scale + height / 2 - projected.y * scale;
```
### Plate sizing and sharpness
- Render at the maximum zoom reached by **any** camera waypoint, including intermediate or hold cameras. The CSS scale should never exceed `1`; otherwise the plate is being enlarged.
- Centre the frozen map on the midpoint of the camera route's geographic extent, not automatically on the final camera. This minimizes required overscan.
- Keep the largest canvas dimension at or below 4096 px unless the actual render environment has been tested with a larger `MAX_RENDERBUFFER_SIZE`.
- For 1920×1080, a 3840×2160 plate is a safe default. For 1080×1920, use approximately 2700×3840 when the route needs extra horizontal pan room.
- If the route cannot fit within that plate at the required zoom, split the shot into two fixed plates with a deliberate editorial transition. Do not trade sharpness for one enormous canvas.
- Distinguish failure modes: repeating shimmer means the live renderer is moving; steadily soft tiles during a CSS push means the fixed plate is underspecified, internally downsampled, or being scaled above `1`.
## Verification
- Render a short MP4, not only a Studio preview.
- Inspect static terrain texture and satellite detail while the camera moves.
- If any underlying map detail wavers, use the fixed map plate. Do not approve it as a minor preview artefact.
- Render WebGL with `--gl=angle`, `preserveDrawingBuffer:true`, and conservative concurrency (`1`) while validating.
@@ -0,0 +1,465 @@
---
name: maps-maplibre
description: Make deterministic Remotion 2D map animations with MapLibre GL JS and Turf. Use when the user chooses MapLibre for animated routes, map markers, labels, and camera movement.
metadata:
tags: map, map animation, maplibre, turf, geojson, route animation
---
Use MapLibre GL JS for rendering maps in Remotion. Use Turf for geospatial operations such as great-circle routes, distances, slicing lines, and positions along routes.
## Core rules
- Prefer `@turf/turf` for geospatial work. Do not hand-roll distance, great-circle, route slicing, or coordinate interpolation unless the user explicitly needs a custom non-geodesic effect.
- Use GeoJSON sources and MapLibre layers for lines, markers, and labels. Avoid DOM `Marker` elements unless the user specifically asks for HTML markers.
- Keep the live map camera static by default. Before moving it on every frame, read [moving-map stability](references/render-stability.md). Prefer a fixed map plate for satellite imagery, hillshade, or a modest 2D reframe.
- Use a live per-frame camera only after rendering a short MP4 and checking for shimmer. This 2D technique does not provide genuine terrain, pitch, bearing, or banking.
- Disable non-deterministic map behavior: `interactive: false`, `fadeDuration: 0`.
- Drive animation from `useCurrentFrame()`; do not use CSS transitions or browser-timed animation.
- Use `delayRender()` / `continueRender()` around map loading and per-frame map updates.
- Set `preserveDrawingBuffer: true` and render WebGL with `bunx remotion ... --gl=angle`.
- Before continuing the initial render, add sources/layers, apply the frame-0 camera with `jumpTo()`, then wait for `idle`.
- Do not add a `mapInstance.remove()` cleanup function; it can interfere with Remotion's render lifecycle.
- Use standard MapLibre style JSON URLs and layer/source APIs.
- Do not install `@types/maplibre-gl`; MapLibre ships its own types.
- Keep required provider attribution visible and verify the current terms of the chosen style and tile providers before rendering.
- Record the source and effective date of custom or disputed geography.
- Inspect rendered pixels, not only Studio playback, at every required aspect ratio.
Coordinates in MapLibre, Turf, and GeoJSON are `[longitude, latitude]`.
```ts
const zurich: [number, number] = [8.5417, 47.3769];
const newYork: [number, number] = [-74.006, 40.7128];
```
## Prerequisites
Install MapLibre and Turf with the project's package manager.
```bash
npm i maplibre-gl @turf/turf
```
```bash
bun i maplibre-gl @turf/turf
```
```bash
yarn add maplibre-gl @turf/turf
```
```bash
pnpm i maplibre-gl @turf/turf
```
Import the MapLibre CSS once in the component or an app-level stylesheet:
```ts
import 'maplibre-gl/dist/maplibre-gl.css';
```
## Basic map example
```tsx
import {useEffect, useRef, useState} from 'react';
import {AbsoluteFill, useDelayRender, useVideoConfig} from 'remotion';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
const zurich: [number, number] = [8.5417, 47.3769];
export const MyComposition = () => {
const containerRef = useRef<HTMLDivElement>(null);
const {delayRender, continueRender} = useDelayRender();
const {width, height} = useVideoConfig();
const [loadingHandle] = useState(() => delayRender('Loading map'));
useEffect(() => {
if (!containerRef.current) {
return;
}
const mapInstance = new maplibregl.Map({
container: containerRef.current,
style: 'https://demotiles.maplibre.org/style.json',
center: zurich,
zoom: 7,
interactive: false,
attributionControl: false,
fadeDuration: 0,
canvasContextAttributes: {
preserveDrawingBuffer: true,
},
});
mapInstance.on('load', () => {
mapInstance.jumpTo({center: zurich, zoom: 7});
mapInstance.once('idle', () => {
continueRender(loadingHandle);
});
});
}, [continueRender, loadingHandle]);
return (
<AbsoluteFill>
<div ref={containerRef} style={{width, height, position: 'absolute'}} />
</AbsoluteFill>
);
};
```
Animated examples should keep the loaded map in React state and skip per-frame updates until that state is set.
## Animated flight route example
This example shows the recommended pattern for route animations:
- Turf creates the route and markers.
- Turf slices the route for line reveal animation.
- The camera has a separate route from the target route.
- MapLibre's `calculateCameraOptionsFromTo()` is used for camera movement.
- Frame 0 is prepared before `continueRender()`.
```tsx
import * as turf from '@turf/turf';
import {useEffect, useRef, useState} from 'react';
import {
AbsoluteFill,
Easing,
interpolate,
useCurrentFrame,
useDelayRender,
useVideoConfig,
} from 'remotion';
import maplibregl, {type GeoJSONSource, type Map} from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
const zurich: [number, number] = [8.5417, 47.3769];
const newYork: [number, number] = [-74.006, 40.7128];
const greatCircleLine = (from: [number, number], to: [number, number]) => {
const route = turf.greatCircle(from, to, {npoints: 100});
if (route.geometry.type === 'LineString') {
return turf.lineString(route.geometry.coordinates);
}
// Great-circle routes crossing the antimeridian can become MultiLineString.
// Keep the example valid by choosing the longest segment.
const longestSegment = route.geometry.coordinates.reduce((longest, segment) => {
return segment.length > longest.length ? segment : longest;
});
return turf.lineString(longestSegment);
};
const targetRoute = greatCircleLine(zurich, newYork);
const targetRouteDistance = turf.length(targetRoute);
const cameraRoute = greatCircleLine(zurich, newYork);
const cameraRouteDistance = turf.length(cameraRoute);
const cityMarkers = turf.featureCollection([
turf.point(zurich, {name: 'Zurich'}),
turf.point(newYork, {name: 'New York'}),
]);
const clampProgress = (progress: number) => Math.min(1, Math.max(0, progress));
const distanceAlong = (totalDistance: number, progress: number) => {
// Keep the route non-empty at progress 0; Turf can error on zero-length slices.
return Math.max(0.001, totalDistance * clampProgress(progress));
};
const getPartialTargetRoute = (progress: number) => {
return turf.lineSliceAlong(
targetRoute,
0,
distanceAlong(targetRouteDistance, progress),
);
};
const getCameraOptions = (
map: Map,
progress: number,
cameraAltitudeMeters: number,
cameraLatitudeOffset: number,
) => {
const target = turf.along(
targetRoute,
distanceAlong(targetRouteDistance, progress),
).geometry.coordinates;
const camera = turf.along(
cameraRoute,
distanceAlong(cameraRouteDistance, progress),
).geometry.coordinates;
return map.calculateCameraOptionsFromTo(
new maplibregl.LngLat(camera[0], camera[1] - cameraLatitudeOffset),
cameraAltitudeMeters,
new maplibregl.LngLat(target[0], target[1]),
);
};
export const MyComposition = () => {
const containerRef = useRef<HTMLDivElement>(null);
const frame = useCurrentFrame();
const {delayRender, continueRender} = useDelayRender();
const {durationInFrames, height, width} = useVideoConfig();
const [map, setMap] = useState<Map | null>(null);
const [loadingHandle] = useState(() => delayRender('Loading MapLibre map'));
useEffect(() => {
if (!containerRef.current) {
return;
}
const mapInstance = new maplibregl.Map({
container: containerRef.current,
style: 'https://demotiles.maplibre.org/style.json',
center: zurich,
zoom: 7,
interactive: false,
attributionControl: false,
fadeDuration: 0,
canvasContextAttributes: {
preserveDrawingBuffer: true,
},
});
mapInstance.on('load', () => {
mapInstance.addSource('trace', {
type: 'geojson',
data: getPartialTargetRoute(0),
});
mapInstance.addLayer({
id: 'trace-line',
type: 'line',
source: 'trace',
layout: {
'line-cap': 'round',
'line-join': 'round',
},
paint: {
'line-color': '#111111',
'line-width': 7,
},
});
mapInstance.addSource('city-markers', {
type: 'geojson',
data: cityMarkers,
});
mapInstance.addLayer({
id: 'city-marker-dots',
type: 'circle',
source: 'city-markers',
paint: {
'circle-color': '#f03b20',
'circle-radius': 12,
'circle-stroke-color': '#ffffff',
'circle-stroke-width': 4,
},
});
mapInstance.addLayer({
id: 'city-marker-labels',
type: 'symbol',
source: 'city-markers',
layout: {
'text-allow-overlap': true,
'text-anchor': 'top',
'text-field': ['get', 'name'],
'text-offset': [0, 0.9],
'text-size': 28,
},
paint: {
'text-color': '#111111',
'text-halo-color': '#ffffff',
'text-halo-width': 3,
},
});
mapInstance.jumpTo(getCameraOptions(mapInstance, 0, 180000, 1.1));
mapInstance.once('idle', () => {
setMap(mapInstance);
continueRender(loadingHandle);
});
});
}, [continueRender, loadingHandle]);
useEffect(() => {
if (!map) {
return;
}
const handle = delayRender('Rendering MapLibre frame');
const timelineProgress = interpolate(frame, [0, durationInFrames - 1], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
const travelProgress = interpolate(timelineProgress, [0.2, 0.82], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
easing: Easing.bezier(0.645, 0.045, 0.355, 1),
});
const cameraAltitudeMeters = interpolate(
timelineProgress,
[0, 0.28, 0.74, 1],
[180000, 2200000, 2200000, 180000],
{
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
easing: Easing.bezier(0.645, 0.045, 0.355, 1),
},
);
const cameraLatitudeOffset = interpolate(
timelineProgress,
[0, 0.28, 0.74, 1],
[1.1, 8, 8, 1.1],
{
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
easing: Easing.bezier(0.645, 0.045, 0.355, 1),
},
);
const trace = map.getSource('trace') as GeoJSONSource | undefined;
trace?.setData(getPartialTargetRoute(travelProgress));
map.jumpTo(
getCameraOptions(
map,
travelProgress,
cameraAltitudeMeters,
cameraLatitudeOffset,
),
);
map.once('idle', () => continueRender(handle));
// Force an idle event even if the camera parameters are unchanged from the previous frame.
map.triggerRepaint();
}, [continueRender, delayRender, durationInFrames, frame, map]);
return (
<AbsoluteFill style={{backgroundColor: '#e8eef3'}}>
<div ref={containerRef} style={{height, position: 'absolute', width}} />
</AbsoluteFill>
);
};
```
## Camera guidance
Use MapLibre's camera helper for camera movement:
```ts
map.calculateCameraOptionsFromTo(cameraLngLat, cameraAltitudeMeters, targetLngLat);
```
A good pattern is to keep two concepts separate:
- `targetRoute`: where the animated line is and where the camera looks.
- `cameraRoute`: where the camera moves.
Then use Turf to read positions from both routes for the same progress value:
```ts
const target = turf.along(targetRoute, targetDistance * progress).geometry.coordinates;
const camera = turf.along(cameraRoute, cameraDistance * progress).geometry.coordinates;
map.jumpTo(
map.calculateCameraOptionsFromTo(
new maplibregl.LngLat(camera[0], camera[1]),
cameraAltitudeMeters,
new maplibregl.LngLat(target[0], target[1]),
),
);
```
For zoom-out / travel / zoom-in animations, animate travel progress separately from camera altitude. Camera altitude is measured in meters. This avoids heavy custom camera math.
## Lines
Use GeoJSON sources for lines. Unless the user asks, do not add glow effects or extra decorative points.
For geodesic flight routes, use Turf:
```ts
const line = greatCircleLine(start, end);
const distance = turf.length(line);
const partialLine = turf.lineSliceAlong(
line,
0,
// Keep the route non-empty at progress 0.
Math.max(0.001, distance * progress),
);
```
For a visually straight line on the map, use a simple GeoJSON `LineString` between the two points instead of `greatCircle()`.
## Markers and labels
Use map-native GeoJSON layers for markers and labels:
```tsx
mapInstance.addSource('markers', {
type: 'geojson',
data: turf.featureCollection([
turf.point([-118.2437, 34.0522], {name: 'Los Angeles'}),
]),
});
mapInstance.addLayer({
id: 'marker-dots',
type: 'circle',
source: 'markers',
paint: {
'circle-color': '#f03b20',
'circle-radius': 12,
'circle-stroke-color': '#ffffff',
'circle-stroke-width': 4,
},
});
mapInstance.addLayer({
id: 'marker-labels',
type: 'symbol',
source: 'markers',
layout: {
'text-allow-overlap': true,
'text-anchor': 'top',
'text-field': ['get', 'name'],
'text-offset': [0, 0.9],
'text-size': 28,
},
paint: {
'text-color': '#111111',
'text-halo-color': '#ffffff',
'text-halo-width': 3,
},
});
```
Make marker sizes and label font sizes large enough for the composition resolution.
## Styles
Default to the stock MapLibre demo style:
```ts
style: 'https://demotiles.maplibre.org/style.json'
```
If the user requests another style, use any valid MapLibre style JSON URL.
## Rendering
For WebGL map renders, prefer single concurrency and ANGLE:
```bash
bunx remotion render [composition-id] out/video.mp4 --gl=angle --concurrency=1
```
Use the equivalent package runner for the project. In npm projects, use `npx`; in Bun projects, use `bunx`.
@@ -0,0 +1,64 @@
# Moving Map Render Stability
Read this reference before building a moving 2D MapTiler scene or diagnosing a wavering Remotion render.
## Symptom and cause
If basemap detail shimmers or jitters during a pan/zoom, the likely cause is per-frame `map.jumpTo()`.
Headless MapTiler capture can resample both vector hillshade and satellite imagery differently frame to
frame. Tile retries, easing changes, and label changes do not solve that renderer effect.
## Required pattern: fixed map plate
For any 2D pan/zoom:
1. Render the MapTiler canvas once at the largest required zoom in an oversized container. Size it from the camera route and keep each dimension below the browser's reliable WebGL render-buffer limit (commonly 4096 px). Do **not** blindly use 3×: a 1920×1080 composition becomes 5760 px wide and Chromium may silently downsample it, causing visible pixelation during the CSS zoom.
2. Keep the map's camera static.
3. For each frame, calculate the approved target centre/zoom, then move the canvas with CSS `translate` + `scale`.
4. Apply the same transform to every projected HTML overlay.
5. Continue to animate GeoJSON data and paint properties imperatively; only the renderer camera is frozen.
Keep pitch and bearing constant. Use a 3D engine such as Cesium for genuine changing pitch/bearing or a terrain flythrough.
```ts
const baseZoom = Math.max(start.zoom, end.zoom);
const map = new maptilersdk.Map({
container,
style,
center: end.center,
zoom: baseZoom,
pitch: end.pitch ?? 0,
bearing: end.bearing ?? 0,
interactive: false,
fadeDuration: 0,
canvasContextAttributes: {preserveDrawingBuffer: true},
});
// Per Remotion frame. `camera` is the approved centre/zoom interpolation.
const projected = map.project(camera.center);
const scale = 2 ** (camera.zoom - baseZoom);
const plate = {
transform: `translate(${width / 2 - projected.x * scale}px, ${height / 2 - projected.y * scale}px) scale(${scale})`,
transformOrigin: "0 0",
};
// Convert label projection with exactly the same plate transform.
const labelX = labelPoint.x * scale + width / 2 - projected.x * scale;
const labelY = labelPoint.y * scale + height / 2 - projected.y * scale;
```
### Plate sizing and sharpness
- Render at the maximum zoom reached by **any** camera waypoint, including intermediate or hold cameras. The CSS scale should never exceed `1`; otherwise the plate is being enlarged.
- Centre the frozen map on the midpoint of the camera route's geographic extent, not automatically on the final camera. This minimizes required overscan.
- Keep the largest canvas dimension at or below 4096 px unless the actual render environment has been tested with a larger `MAX_RENDERBUFFER_SIZE`.
- For 1920×1080, a 3840×2160 plate is a safe default. For 1080×1920, use approximately 2700×3840 when the route needs extra horizontal pan room.
- If the route cannot fit within that plate at the required zoom, split the shot into two fixed plates with a deliberate editorial transition. Do not trade sharpness for one enormous canvas.
- Distinguish failure modes: repeating shimmer means the live renderer is moving; steadily soft tiles during a CSS push means the fixed plate is underspecified, internally downsampled, or being scaled above `1`.
## Verification
- Render a short MP4, not only a Studio preview.
- Inspect static terrain texture and satellite detail while the camera moves.
- If any underlying map detail wavers, use the fixed map plate. Do not approve it as a minor preview artefact.
- Render WebGL with `--gl=angle`, `preserveDrawingBuffer:true`, and conservative concurrency (`1`) while validating.
@@ -0,0 +1,80 @@
# MapTiler maps in Remotion
MapTiler is a good solution for map animations where geographics features should be drawn as annotations on top of the map: Country borders, rivers, labels for POIs.
## MapTiler SDK (`@maptiler/sdk`)
Draw the basemap plus MapTiler Planet vector layers and custom GeoJSON into a WebGL canvas. Default styled-vector starting point: `MapStyle.BASIC`; satellite is an equally valid choice.
## Remotion
Imperatively update `setData`/`setPaintProperty`.
Use `jumpTo` only for a static shot, or a fixed map plate for any pan/zoom.
Gate with [`delayRender`](https://www.remotion.dev/docs/delay-render.md) until `map.once('idle')`.
Use `preserveDrawingBuffer:true`.
Render labels as positioned [`<Interactive.Div>`](https://www.remotion.dev/docs/interactive.md) elements.
Env `REMOTION_MAPTILER_KEY` (unrestricted). Init the map once (ref guard); update imperatively per frame.
When constructing MapLibre/MapTiler layer objects, omit optional properties that are absent.
In particular, use `...(layer.filter ? {filter: layer.filter} : {})`; do not pass `filter: undefined`. An undefined filter can suppress the layer while separately created halo or border layers continue rendering, producing missing country fills and dark marker halos with no coloured cores.
Drive animation from `useCurrentFrame()` rather than CSS transitions or browser timers.
## Choose the source for each map element
Do not begin by manufacturing GeoJSON. First check whether MapTiler Planet already exposes the element as filtered vector data.
### MapTiler vector
Use it when the feature exists in a provider `source-layer`, its attributes support an exact filter, and provider geometry is editorially acceptable.
### Hybrid
Use it when ordinary geographic context can come from MapTiler while the claim depends on custom
evidence.
Animate each layer according to its source and meaning.
MapTiler vector features remain split across tiles. Do not use them for a semantic start-to-end line draw; extract, verify, order, and bake that element to GeoJSON first. Read **`references/map-data-sources.md`** and reuse **`assets/MapTilerVectorElement.ts`** for provider-layer setup and per-frame paint updates.
## Motion stability
**Do not call `map.jumpTo()` on every Remotion frame when the camera moves.** In headless capture it can make both MapTiler hillshade **and satellite imagery** shimmer/jitter, even when the source tiles load correctly. This is renderer resampling, not a data, network, or label problem.
For the implementation, read **`references/render-stability.md`** before building or debugging any moving map. It contains the fixed-map-plate recipe, diagnostics, and render checks.
- Use the live MapTiler camera only for a static shot.
- Keep pitch and bearing constant for a fixed plate. This technique does not implement a genuine changing 3D camera.
- Verify the moving preview and a short rendered MP4 before approving a beat. If any basemap detail wavers, switch to the fixed-plate pattern; do not try to solve it with tile retries or camera easing.
## Drawing rivers
Use `turf.lineSliceAlong(line, 0, lineKm*reveal)` to draw rivers.
## Source selection
Use MapTiler vector layers for suitable provider features and custom GeoJSON for story-specific or ordered geometry. If the beat needs country-entry triggers or a progressive line draw, run `scripts/prep-geo.mjs` to bake `country-meta.json`, `borders.geojson`, and the ordered line. Details → `references/map-data-sources.md` and `references/map-geo-prep.md`.
## Keep it minmal
Strip clutter on `load`: remove `symbol` layers (place labels) and `/other border/i` (admin-1 inner borders); hide the logo via CSS. Keep country + disputed borders.
## Files
Use as reference:
- `assets/RiverReveal.tsx` — the main component.
- `assets/MapTilerVectorElement.ts` — filtered MapTiler Planet elements.
- `assets/CountryLabel.tsx` — reusable example label.
- `assets/tokens.ts` — example palette and durations.
- `assets/example-Root.tsx` — minimal composition scaffold.
- `assets/sample-data/` — example route and generated country metadata.
- `scripts/prep-geo.mjs` — geo pipeline.
- `references/map-explainer-architecture.md` — timing model and implementation.
- `references/map-data-sources.md` — provider vector versus custom GeoJSON selection.
- `references/map-geo-prep.md` — basemap stripping and geo preparation.
- `references/render-stability.md` — camera motion and stable headless renders.
@@ -0,0 +1,70 @@
import React from 'react';
import {Easing, interpolate} from 'remotion';
// Reusable country label. Supply typography and final values from the consuming project; the CSS custom
// properties below provide neutral fallbacks. Positioned by its centre (x,y in screen px).
export const CountryLabel: React.FC<{
name: string;
color: string;
reveal: number;
x: number;
y: number;
}> = ({name, color, reveal, x, y}) => {
const e = interpolate(reveal, [0, 1], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
easing: Easing.bezier(0.3333333333333333, 1, 0.6666666666666666, 1),
});
return (
<div
style={{
position: 'absolute',
left: x,
top: y,
transform: 'translate(-50%, -50%)',
pointerEvents: 'none',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
opacity: e,
}}
>
<div
style={{
transform: `translateY(${(1 - e) * 16}px)`,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
{/* accent rule — draws out from the centre in the country's colour */}
<div
style={{
width: 64,
height: 3,
borderRadius: 2,
background: color,
transform: `scaleX(${e})`,
boxShadow: `0 0 10px ${color}`,
}}
/>
<div
style={{
fontFamily: 'var(--map-label-font, system-ui, sans-serif)',
fontWeight: 'var(--map-label-weight, 600)',
fontSize: 'var(--map-label-size, 34px)',
letterSpacing: 'var(--map-label-tracking, 0.16em)',
textTransform: 'uppercase',
color: 'var(--map-label-color, #ffffff)',
textShadow: 'var(--map-label-shadow, 0 2px 18px rgba(0,0,0,0.9))',
marginTop: 13,
paddingLeft: 'var(--map-label-tracking, 0.16em)',
whiteSpace: 'nowrap',
}}
>
{name}
</div>
</div>
</div>
);
};
@@ -0,0 +1,80 @@
// Surface an existing MapTiler Planet feature without maintaining duplicate GeoJSON.
// Paint animation is deterministic; geometry slicing is not. For a source-to-end line draw,
// bake the selected feature to ordered GeoJSON and use RiverReveal.tsx instead.
type VectorLayerType = 'fill' | 'line' | 'circle' | 'symbol';
export type MapTilerVectorElement = {
id: string;
sourceLayer: string;
type: VectorLayerType;
filter?: unknown[];
minzoom?: number;
maxzoom?: number;
layout?: Record<string, unknown>;
paint: Record<string, unknown>;
};
const SOURCE_ID = 'maptiler-planet';
export const addMapTilerVectorElement = (
map: any,
apiKey: string,
element: MapTilerVectorElement,
beforeId?: string,
) => {
if (!map.getSource(SOURCE_ID)) {
map.addSource(SOURCE_ID, {
type: 'vector',
url: `https://api.maptiler.com/tiles/v3/tiles.json?key=${apiKey}`,
});
}
if (map.getLayer(element.id)) return;
map.addLayer(
{
id: element.id,
type: element.type,
source: SOURCE_ID,
'source-layer': element.sourceLayer,
...(element.filter ? {filter: element.filter} : {}),
...(element.minzoom === undefined ? {} : {minzoom: element.minzoom}),
...(element.maxzoom === undefined ? {} : {maxzoom: element.maxzoom}),
...(element.layout ? {layout: element.layout} : {}),
paint: element.paint,
},
beforeId,
);
};
export const setVectorElementPaint = (
map: any,
layerId: string,
paint: Record<string, unknown>,
) => {
for (const [property, value] of Object.entries(paint)) {
map.setPaintProperty(layerId, property, value);
}
};
// Example:
//
// addMapTilerVectorElement(map, process.env.REMOTION_MAPTILER_KEY!, {
// id: "story-river",
// sourceLayer: "waterway",
// type: "line",
// filter: [
// "all",
// ["==", ["get", "class"], "river"],
// ["==", ["coalesce", ["get", "name_en"], ["get", "name"]], "Yarlung Tsangpo"],
// ],
// layout: {"line-cap": "round", "line-join": "round"},
// paint: {"line-color": "#E8F7FF", "line-width": 3, "line-opacity": 0},
// });
//
// Per Remotion frame:
// setVectorElementPaint(map, "story-river", {
// "line-opacity": reveal,
// "line-width": 2 + reveal * 2,
// });
@@ -0,0 +1,363 @@
import * as maptilersdk from '@maptiler/sdk';
import * as turf from '@turf/turf';
import React, {useEffect, useRef, useState} from 'react';
import '@maptiler/sdk/dist/maptiler-sdk.css';
import {
AbsoluteFill,
Easing,
continueRender,
delayRender,
interpolate,
useCurrentFrame,
useVideoConfig,
} from 'remotion';
import {CountryLabel} from './CountryLabel';
import countryMeta from './sample-data/country-meta.json';
import flowCoords from './sample-data/yarlung-flow.json';
import {COLORS, COUNTRY, COUNTRY_DARK, FILL_OPACITY} from './tokens';
// Sample route reveal. Replace the imported sample geometry, names, timing, and visual tokens in the
// consuming production. The renderer stays static; approved centre/zoom motion is a CSS plate transform.
maptilersdk.config.apiKey = process.env.REMOTION_MAPTILER_KEY as string;
const line = turf.lineString(flowCoords as [number, number][]);
const lineKm = turf.length(line);
const START = {
center: [89.6, 27.7] as [number, number],
zoom: 4.75,
pitch: 0,
bearing: 0,
};
const END = {
center: [90.2, 27.0] as [number, number],
zoom: 5.05,
pitch: 0,
bearing: 0,
};
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
const clamp01 = (v: number) => Math.max(0, Math.min(1, v));
const ORDER = ['china', 'india', 'bangladesh'] as const;
type Country = (typeof ORDER)[number];
const META = countryMeta as Record<
Country,
{stop: number; anchor: [number, number]; border: [number, number][][]}
>;
const countryPolygons = {
type: 'FeatureCollection' as const,
features: ORDER.map((country) => ({
type: 'Feature' as const,
properties: {country},
geometry: {
type: 'MultiPolygon' as const,
coordinates: META[country].border.map((ring) => [ring]),
},
})),
};
// Pre-build each country's border as ordered segments with cumulative lengths (for the multi-segment draw).
const DRAW = Object.fromEntries(
ORDER.map((c) => {
const segLines = META[c].border.map((s) => turf.lineString(s));
const segLen = segLines.map((l) => turf.length(l));
const cum: number[] = [];
let acc = 0;
for (const L of segLen) {
cum.push(acc);
acc += L;
}
return [c, {segLines, segLen, cum, total: acc}];
}),
) as Record<
Country,
{segLines: any[]; segLen: number[]; cum: number[]; total: number}
>;
// Reveal the portion of the border between fromKm and toKm as a MultiLineString (no joins across gaps).
const sliceBorder = (
d: (typeof DRAW)[Country],
fromKm: number,
toKm: number,
) => {
const out: number[][][] = [];
for (let i = 0; i < d.segLines.length; i++) {
const start = d.cum[i],
end = start + d.segLen[i];
const a = Math.max(fromKm, start),
b = Math.min(toKm, end);
if (b - a <= 0.0008) continue;
out.push(
turf.lineSliceAlong(d.segLines[i], a - start, b - start).geometry
.coordinates,
);
}
return {
type: 'Feature' as const,
properties: {},
geometry: {type: 'MultiLineString' as const, coordinates: out},
};
};
const EMPTY = {
type: 'Feature' as const,
properties: {},
geometry: {type: 'MultiLineString' as const, coordinates: [] as number[][][]},
};
// --- Timing (seconds). River draws over [RIVER_START, RIVER_END]; each country triggers when the river
// reaches it (stop · span), then runs border → fill → label. Beat length is derived from these. ---
const RIVER_START = 0.3;
const RIVER_END = 8.0;
const BORDER_S = 2.5;
const FILL_S = 1.0;
const LABEL_S = 0.7;
const trigger = (c: Country) =>
RIVER_START + META[c].stop * (RIVER_END - RIVER_START);
export const RiverReveal: React.FC = () => {
const ref = useRef<HTMLDivElement>(null);
const started = useRef(false);
const frame = useCurrentFrame();
const {fps, durationInFrames, width, height} = useVideoConfig();
const [map, setMap] = useState<any>(null);
const [labels, setLabels] = useState<
Record<string, {x: number; y: number; reveal: number}>
>({});
const [plate, setPlate] = useState({x: 0, y: 0, scale: 1});
const [handle] = useState(() => delayRender('maptiler init A'));
useEffect(() => {
if (!ref.current || started.current) return;
started.current = true;
const m = new maptilersdk.Map({
container: ref.current,
style: maptilersdk.MapStyle.BASIC,
center: END.center,
zoom: Math.max(START.zoom, END.zoom),
pitch: END.pitch,
bearing: END.bearing,
interactive: false,
attributionControl: true,
navigationControl: false,
geolocateControl: false,
maptilerLogo: true,
fadeDuration: 0,
canvasContextAttributes: {preserveDrawingBuffer: true},
} as any);
m.on('load', () => {
// Strip basemap labels (symbols) AND the inner admin-1 borders ('Other border[ dash]',
// admin_level 310) to cut basemap clutter. Keep country + disputed borders.
for (const l of m.getStyle().layers as any[])
if (l.type === 'symbol' || /other border/i.test(l.id))
m.removeLayer(l.id);
m.addSource('countries', {type: 'geojson', data: countryPolygons});
for (const c of ORDER) {
m.addLayer({
id: `fill-${c}`,
type: 'fill',
source: 'countries',
filter: ['==', ['get', 'country'], c],
paint: {'fill-color': COUNTRY[c], 'fill-opacity': 0},
});
}
// Per country: just the border that draws on, settled to a darker shade of the country colour
// (the electricity now lives on the river, not the borders).
for (const c of ORDER) {
m.addSource(`trail-${c}`, {type: 'geojson', data: EMPTY});
m.addLayer({
id: `trail-${c}`,
type: 'line',
source: `trail-${c}`,
layout: {'line-cap': 'round', 'line-join': 'round'},
paint: {
'line-color': COUNTRY_DARK[c],
'line-width': 2,
'line-opacity': 0.95,
},
});
}
const seed = turf.lineSliceAlong(
line,
0,
Math.max(0.001, lineKm * 0.001),
);
m.addSource('river', {type: 'geojson', data: seed});
m.addSource('river-head', {type: 'geojson', data: seed});
// Electric water: soft blue glow → icy core → white-hot leading head with its own glow.
m.addLayer({
id: 'river-glow',
type: 'line',
source: 'river',
layout: {'line-cap': 'round', 'line-join': 'round'},
paint: {
'line-color': '#49C6FF',
'line-width': 11,
'line-opacity': 0.32,
'line-blur': 6,
},
});
m.addLayer({
id: 'river-line',
type: 'line',
source: 'river',
layout: {'line-cap': 'round', 'line-join': 'round'},
paint: {'line-color': COLORS.river, 'line-width': 3},
});
m.addLayer({
id: 'river-headglow',
type: 'line',
source: 'river-head',
layout: {'line-cap': 'round', 'line-join': 'round'},
paint: {
'line-color': COLORS.riverHeadGlow,
'line-width': 16,
'line-opacity': 0,
'line-blur': 9,
},
});
m.addLayer({
id: 'river-head',
type: 'line',
source: 'river-head',
layout: {'line-cap': 'round', 'line-join': 'round'},
paint: {
'line-color': COLORS.riverHead,
'line-width': 4.5,
'line-opacity': 0,
},
});
m.once('idle', () => {
setMap(m);
continueRender(handle);
});
});
}, [handle]);
useEffect(() => {
if (!map) return;
const h = delayRender(`frame A ${frame}`);
const t = frame / fps; // seconds
const tt = interpolate(frame, [0, durationInFrames - 1], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
// River draw
const reveal = interpolate(t, [RIVER_START, RIVER_END], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
easing: Easing.bezier(0.645, 0.045, 0.355, 1),
});
const riverDrawnKm = lineKm * reveal;
(map.getSource('river') as any)?.setData(
turf.lineSliceAlong(line, 0, Math.max(0.001, riverDrawnKm)),
);
// Electric draw-head leading the river: white-hot core + glow, fading out once the river completes.
const riverHeadKm = lineKm * 0.03;
(map.getSource('river-head') as any)?.setData(
turf.lineSliceAlong(
line,
Math.max(0, riverDrawnKm - riverHeadKm),
Math.max(0.001, riverDrawnKm),
),
);
let riverHeadFade = 0;
if (reveal > 0.002 && reveal < 0.999) riverHeadFade = 1;
else if (reveal >= 0.999)
riverHeadFade = 1 - clamp01((t - RIVER_END) / 0.5);
map.setPaintProperty(
'river-headglow',
'line-opacity',
0.85 * riverHeadFade,
);
map.setPaintProperty('river-head', 'line-opacity', riverHeadFade);
const camera = {
center: [
lerp(START.center[0], END.center[0], tt),
lerp(START.center[1], END.center[1], tt),
] as [number, number],
zoom: lerp(START.zoom, END.zoom, tt),
};
const cameraPoint = map.project(camera.center);
const plateScale = 2 ** (camera.zoom - Math.max(START.zoom, END.zoom));
const plateX = width / 2 - cameraPoint.x * plateScale;
const plateY = height / 2 - cameraPoint.y * plateScale;
const pos: Record<string, {x: number; y: number; reveal: number}> = {};
for (const c of ORDER) {
const d = DRAW[c];
const lt = t - trigger(c); // local seconds since this country triggered
// 1) border draws on (constant duration), settling to a darker shade — no electric head
const bp = interpolate(clamp01(lt / BORDER_S), [0, 1], [0, 1], {
easing: Easing.bezier(0.645, 0.045, 0.355, 1),
});
(map.getSource(`trail-${c}`) as any)?.setData(
bp <= 0 ? EMPTY : sliceBorder(d, 0, d.total * bp),
);
// 2) fill blooms in (overshoot then settle) after the border completes
const fp = clamp01((lt - BORDER_S) / FILL_S);
const fo = interpolate(
fp,
[0, 0.6, 1],
[0, FILL_OPACITY * 1.25, FILL_OPACITY],
{
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
easing: Easing.bezier(0.3333333333333333, 1, 0.6666666666666666, 1),
},
);
map.setPaintProperty(`fill-${c}`, 'fill-opacity', fp <= 0 ? 0 : fo);
// 3) label rises in after the fill
const lp = clamp01((lt - BORDER_S - FILL_S) / LABEL_S);
const p = map.project(META[c].anchor);
pos[c] = {
x: p.x * plateScale + plateX,
y: p.y * plateScale + plateY,
reveal: lp,
};
}
setLabels(pos);
setPlate({x: plateX, y: plateY, scale: plateScale});
map.once('idle', () => continueRender(h));
map.triggerRepaint();
}, [map, frame, fps, durationInFrames, width, height]);
return (
<AbsoluteFill style={{backgroundColor: COLORS.bg}}>
<div
ref={ref}
style={{
width: width * 2,
height: height * 2,
position: 'absolute',
transform: `translate(${plate.x}px, ${plate.y}px) scale(${plate.scale})`,
transformOrigin: '0 0',
}}
/>
<AbsoluteFill style={{pointerEvents: 'none'}}>
{ORDER.map((c) =>
labels[c] ? (
<CountryLabel
key={c}
name={c.toUpperCase()}
color={COUNTRY[c]}
reveal={labels[c].reveal}
x={labels[c].x}
y={labels[c].y}
/>
) : null,
)}
</AbsoluteFill>
</AbsoluteFill>
);
};
@@ -0,0 +1,22 @@
// Minimal Remotion scaffold for the map explainer. In a Remotion project (`bunx create-video@latest`,
// blank template), this is `src/Root.tsx`; `src/index.ts` is just `registerRoot(RemotionRoot)`.
//
// The Composition sets DURATION (the beat) and dimensions. The component reads durationInFrames / width /
// height from useVideoConfig(). The beat must be long enough for the last country's full sequence after
// the river reaches it. See references/map-explainer-architecture.md §2.
// Render with: bunx remotion render src/index.ts MapExplainer out.mp4 --gl=angle --concurrency=1 --timeout=120000
import React from 'react';
import {Composition} from 'remotion';
import {RiverReveal} from './RiverReveal'; // → src/components/RiverReveal.tsx in your project
export const RemotionRoot: React.FC = () => (
<Composition
id="MapExplainer"
component={RiverReveal}
durationInFrames={12 * 30} // 12 s @ 30 fps — raise if a later country needs more room after the river arrives
fps={30}
width={1920}
height={1080}
/>
);
@@ -0,0 +1,605 @@
[
[84.144, 29.565],
[84.171, 29.577],
[84.22500000000001, 29.559],
[84.246, 29.541],
[84.297, 29.544],
[84.321, 29.532],
[84.411, 29.544],
[84.426, 29.529],
[84.45, 29.535],
[84.468, 29.523],
[84.492, 29.523],
[84.531, 29.490000000000002],
[84.507, 29.466],
[84.501, 29.445],
[84.513, 29.385],
[84.495, 29.379],
[84.483, 29.361],
[84.486, 29.34],
[84.519, 29.316],
[84.51, 29.292],
[84.522, 29.286],
[84.525, 29.265],
[84.57600000000001, 29.253],
[84.58800000000001, 29.259],
[84.60300000000001, 29.247],
[84.615, 29.265],
[84.63, 29.268],
[84.624, 29.283],
[84.651, 29.286],
[84.669, 29.259],
[84.732, 29.25],
[84.741, 29.235],
[84.771, 29.25],
[84.777, 29.241],
[84.843, 29.223],
[84.864, 29.232],
[84.879, 29.202],
[84.9, 29.199],
[84.909, 29.181],
[84.933, 29.184],
[84.96900000000001, 29.163],
[85.023, 29.187],
[85.071, 29.259],
[85.122, 29.274],
[85.167, 29.322],
[85.185, 29.310000000000002],
[85.26, 29.319],
[85.284, 29.301000000000002],
[85.34400000000001, 29.289],
[85.35600000000001, 29.265],
[85.413, 29.262],
[85.431, 29.271],
[85.449, 29.247],
[85.464, 29.244],
[85.521, 29.25],
[85.551, 29.265],
[85.611, 29.235],
[85.617, 29.193],
[85.662, 29.172],
[85.686, 29.172],
[85.69500000000001, 29.184],
[85.71600000000001, 29.172],
[85.72800000000001, 29.187],
[85.764, 29.181],
[85.761, 29.193],
[85.776, 29.202],
[85.788, 29.196],
[85.797, 29.22],
[85.809, 29.211000000000002],
[85.848, 29.211000000000002],
[85.863, 29.193],
[85.881, 29.193],
[85.917, 29.172],
[85.968, 29.175],
[86.004, 29.163],
[86.09400000000001, 29.175],
[86.10000000000001, 29.166],
[86.133, 29.172],
[86.148, 29.166],
[86.193, 29.178],
[86.223, 29.202],
[86.268, 29.193],
[86.295, 29.208000000000002],
[86.34, 29.205000000000002],
[86.412, 29.217000000000002],
[86.427, 29.211000000000002],
[86.43, 29.199],
[86.47800000000001, 29.196],
[86.496, 29.22],
[86.514, 29.211000000000002],
[86.52, 29.235],
[86.529, 29.238],
[86.535, 29.217000000000002],
[86.559, 29.196],
[86.607, 29.202],
[86.616, 29.184],
[86.658, 29.193],
[86.67, 29.205000000000002],
[86.682, 29.199],
[86.7, 29.208000000000002],
[86.736, 29.205000000000002],
[86.778, 29.187],
[86.805, 29.196],
[86.82300000000001, 29.184],
[86.84100000000001, 29.19],
[86.901, 29.181],
[86.949, 29.163],
[87.027, 29.172],
[87.039, 29.148],
[87.063, 29.136],
[87.084, 29.142],
[87.12, 29.175],
[87.162, 29.142],
[87.21000000000001, 29.142],
[87.237, 29.157],
[87.249, 29.136],
[87.297, 29.127],
[87.303, 29.115000000000002],
[87.366, 29.121000000000002],
[87.459, 29.097],
[87.486, 29.115000000000002],
[87.546, 29.109],
[87.56700000000001, 29.121000000000002],
[87.59400000000001, 29.121000000000002],
[87.60600000000001, 29.133],
[87.654, 29.139],
[87.666, 29.13],
[87.684, 29.133],
[87.687, 29.157],
[87.669, 29.181],
[87.675, 29.205000000000002],
[87.705, 29.217000000000002],
[87.714, 29.235],
[87.732, 29.238],
[87.732, 29.253],
[87.756, 29.283],
[87.753, 29.301000000000002],
[87.789, 29.304000000000002],
[87.795, 29.328],
[87.81, 29.337],
[87.849, 29.331],
[87.87, 29.349],
[87.906, 29.343],
[87.924, 29.349],
[87.97800000000001, 29.388],
[87.996, 29.376],
[88.014, 29.379],
[88.017, 29.37],
[88.125, 29.367],
[88.137, 29.337],
[88.161, 29.331],
[88.176, 29.337],
[88.188, 29.325],
[88.2, 29.328],
[88.221, 29.367],
[88.245, 29.367],
[88.287, 29.349],
[88.299, 29.358],
[88.365, 29.316],
[88.41, 29.322],
[88.422, 29.316],
[88.443, 29.325],
[88.458, 29.349],
[88.503, 29.361],
[88.536, 29.334],
[88.563, 29.328],
[88.596, 29.349],
[88.629, 29.346],
[88.641, 29.334],
[88.671, 29.34],
[88.71000000000001, 29.331],
[88.72800000000001, 29.343],
[88.791, 29.337],
[88.818, 29.352],
[88.86, 29.319],
[88.884, 29.334],
[88.908, 29.316],
[88.923, 29.331],
[88.962, 29.328],
[88.971, 29.349],
[89.007, 29.361],
[89.019, 29.352],
[89.034, 29.358],
[89.08500000000001, 29.322],
[89.136, 29.316],
[89.16, 29.343],
[89.181, 29.337],
[89.211, 29.349],
[89.235, 29.379],
[89.253, 29.373],
[89.265, 29.385],
[89.289, 29.385],
[89.307, 29.376],
[89.349, 29.379],
[89.397, 29.358],
[89.45100000000001, 29.355],
[89.46000000000001, 29.334],
[89.58, 29.358],
[89.595, 29.346],
[89.61, 29.355],
[89.631, 29.346],
[89.661, 29.349],
[89.679, 29.364],
[89.757, 29.295],
[89.787, 29.292],
[89.808, 29.310000000000002],
[89.85300000000001, 29.322],
[89.931, 29.319],
[89.985, 29.343],
[90.015, 29.337],
[90.072, 29.352],
[90.156, 29.355],
[90.168, 29.346],
[90.20100000000001, 29.349],
[90.22500000000001, 29.331],
[90.273, 29.337],
[90.276, 29.328],
[90.342, 29.313],
[90.378, 29.295],
[90.435, 29.241],
[90.477, 29.256],
[90.492, 29.25],
[90.51, 29.256],
[90.522, 29.247],
[90.54, 29.262],
[90.621, 29.277],
[90.642, 29.301000000000002],
[90.663, 29.298000000000002],
[90.681, 29.313],
[90.684, 29.328],
[90.705, 29.34],
[90.729, 29.328],
[90.747, 29.337],
[90.765, 29.328],
[90.768, 29.310000000000002],
[90.756, 29.295],
[90.771, 29.277],
[90.855, 29.283],
[90.897, 29.319],
[90.933, 29.310000000000002],
[90.95100000000001, 29.295],
[90.993, 29.322],
[91.035, 29.295],
[91.065, 29.316],
[91.116, 29.325],
[91.131, 29.313],
[91.173, 29.325],
[91.194, 29.286],
[91.233, 29.274],
[91.296, 29.289],
[91.308, 29.277],
[91.34700000000001, 29.28],
[91.374, 29.292],
[91.395, 29.28],
[91.449, 29.286],
[91.479, 29.268],
[91.512, 29.28],
[91.533, 29.271],
[91.554, 29.289],
[91.587, 29.292],
[91.602, 29.271],
[91.62, 29.265],
[91.659, 29.271],
[91.674, 29.259],
[91.71000000000001, 29.268],
[91.74, 29.259],
[91.782, 29.274],
[91.839, 29.268],
[91.869, 29.283],
[91.98, 29.262],
[92.007, 29.232],
[92.07000000000001, 29.289],
[92.115, 29.283],
[92.154, 29.289],
[92.196, 29.244],
[92.22, 29.253],
[92.235, 29.244],
[92.277, 29.244],
[92.295, 29.229],
[92.319, 29.226],
[92.376, 29.226],
[92.397, 29.241],
[92.406, 29.262],
[92.433, 29.25],
[92.46600000000001, 29.253],
[92.529, 29.172],
[92.529, 29.133],
[92.553, 29.148],
[92.598, 29.145],
[92.589, 29.121000000000002],
[92.61, 29.097],
[92.619, 29.115000000000002],
[92.658, 29.121000000000002],
[92.685, 29.139],
[92.697, 29.127],
[92.676, 29.112000000000002],
[92.679, 29.103],
[92.709, 29.109],
[92.739, 29.067],
[92.772, 29.091],
[92.775, 29.073],
[92.796, 29.082],
[92.808, 29.061],
[92.82000000000001, 29.067],
[92.82300000000001, 29.082],
[92.82900000000001, 29.076],
[92.85300000000001, 29.082],
[92.85600000000001, 29.073],
[92.88, 29.073],
[92.904, 29.058],
[92.934, 29.067],
[92.952, 29.049],
[92.973, 29.061],
[92.988, 29.043],
[92.997, 29.049],
[93.066, 29.043],
[93.081, 29.094],
[93.117, 29.115000000000002],
[93.123, 29.136],
[93.138, 29.139],
[93.15, 29.127],
[93.147, 29.094],
[93.165, 29.064],
[93.165, 29.043],
[93.153, 29.037],
[93.153, 29.025000000000002],
[93.162, 29.016000000000002],
[93.21300000000001, 29.025000000000002],
[93.22800000000001, 29.019000000000002],
[93.23100000000001, 29.001],
[93.261, 28.992],
[93.285, 29.001],
[93.312, 28.998],
[93.315, 29.016000000000002],
[93.348, 29.046],
[93.375, 29.052],
[93.393, 29.094],
[93.429, 29.109],
[93.447, 29.106],
[93.438, 29.13],
[93.48, 29.175],
[93.492, 29.163],
[93.54, 29.178],
[93.57000000000001, 29.166],
[93.627, 29.172],
[93.63, 29.151],
[93.645, 29.145],
[93.675, 29.151],
[93.681, 29.163],
[93.699, 29.16],
[93.702, 29.142],
[93.735, 29.127],
[93.75, 29.136],
[93.75, 29.148],
[93.78, 29.154],
[93.789, 29.124000000000002],
[93.825, 29.118000000000002],
[93.834, 29.124000000000002],
[93.831, 29.142],
[93.894, 29.13],
[93.912, 29.145],
[93.903, 29.166],
[93.909, 29.178],
[93.94500000000001, 29.184],
[93.95400000000001, 29.196],
[94.017, 29.193],
[94.035, 29.205000000000002],
[94.176, 29.196],
[94.251, 29.262],
[94.305, 29.271],
[94.302, 29.292],
[94.33200000000001, 29.316],
[94.35000000000001, 29.316],
[94.389, 29.337],
[94.401, 29.361],
[94.419, 29.37],
[94.434, 29.406000000000002],
[94.542, 29.448],
[94.581, 29.481],
[94.656, 29.490000000000002],
[94.70700000000001, 29.463],
[94.818, 29.493000000000002],
[94.875, 29.541],
[94.923, 29.61],
[94.926, 29.622],
[94.887, 29.634],
[94.917, 29.67],
[94.893, 29.697],
[94.905, 29.715],
[94.956, 29.757],
[95.124, 29.763],
[95.124, 29.781000000000002],
[95.09100000000001, 29.814],
[95.11200000000001, 29.868000000000002],
[95.13, 29.88],
[95.175, 29.895],
[95.196, 29.892],
[95.223, 29.868000000000002],
[95.286, 29.868000000000002],
[95.304, 29.856],
[95.307, 29.838],
[95.283, 29.82],
[95.289, 29.811],
[95.385, 29.772000000000002],
[95.379, 29.718],
[95.397, 29.688000000000002],
[95.388, 29.595],
[95.403, 29.562],
[95.427, 29.538],
[95.43900000000001, 29.478],
[95.43, 29.451],
[95.313, 29.331],
[95.256, 29.289],
[95.202, 29.28],
[95.04, 29.175],
[95.001, 29.169],
[95.004, 29.139],
[94.908, 29.052],
[94.902, 29.016000000000002],
[94.869, 28.998],
[94.839, 28.956],
[94.788, 28.935000000000002],
[94.773, 28.854],
[94.794, 28.824],
[94.914, 28.821],
[94.923, 28.812],
[94.911, 28.794],
[94.92, 28.752],
[94.977, 28.713],
[94.983, 28.674],
[94.998, 28.683],
[95.031, 28.617],
[95.09700000000001, 28.539],
[95.10300000000001, 28.506],
[95.09100000000001, 28.455000000000002],
[95.09700000000001, 28.419],
[95.013, 28.326],
[94.992, 28.287],
[94.992, 28.242],
[95.019, 28.215],
[95.031, 28.173000000000002],
[95.06700000000001, 28.155],
[95.145, 28.149],
[95.211, 28.179000000000002],
[95.277, 28.155],
[95.292, 28.116],
[95.316, 28.089000000000002],
[95.355, 28.071],
[95.379, 28.071],
[95.385, 28.059],
[95.382, 27.951],
[95.4, 27.936],
[95.412, 27.882],
[95.379, 27.843],
[95.349, 27.837],
[95.325, 27.810000000000002],
[95.325, 27.78],
[95.298, 27.762],
[95.289, 27.717000000000002],
[95.253, 27.657],
[95.211, 27.666],
[95.151, 27.624000000000002],
[95.139, 27.63],
[95.10300000000001, 27.609],
[94.992, 27.6],
[94.944, 27.57],
[94.917, 27.573],
[94.869, 27.504],
[94.803, 27.495],
[94.803, 27.48],
[94.785, 27.471],
[94.794, 27.456],
[94.767, 27.435000000000002],
[94.767, 27.414],
[94.73100000000001, 27.402],
[94.71900000000001, 27.372],
[94.69800000000001, 27.36],
[94.69800000000001, 27.333000000000002],
[94.683, 27.324],
[94.677, 27.294],
[94.635, 27.291],
[94.629, 27.273],
[94.602, 27.255],
[94.596, 27.231],
[94.584, 27.228],
[94.587, 27.177],
[94.596, 27.162],
[94.584, 27.147000000000002],
[94.566, 27.150000000000002],
[94.554, 27.102],
[94.518, 27.105],
[94.449, 27.060000000000002],
[94.44, 27.018],
[94.407, 26.985],
[94.35900000000001, 26.97],
[94.287, 26.919],
[94.197, 26.925],
[94.164, 26.898],
[94.146, 26.865000000000002],
[94.125, 26.856],
[94.113, 26.868000000000002],
[94.134, 26.907],
[94.113, 26.913],
[94.077, 26.886],
[93.927, 26.832],
[93.864, 26.766000000000002],
[93.753, 26.733],
[93.657, 26.715],
[93.618, 26.724],
[93.56700000000001, 26.751],
[93.441, 26.769000000000002],
[93.411, 26.76],
[93.369, 26.718],
[93.288, 26.742],
[93.249, 26.724],
[93.183, 26.673000000000002],
[93.12, 26.646],
[93.087, 26.655],
[93.018, 26.646],
[92.898, 26.655],
[92.883, 26.652],
[92.874, 26.625],
[92.83500000000001, 26.607],
[92.733, 26.613],
[92.676, 26.601],
[92.658, 26.592000000000002],
[92.616, 26.523],
[92.595, 26.517],
[92.508, 26.517],
[92.43, 26.535],
[92.391, 26.532],
[92.244, 26.472],
[92.229, 26.442],
[92.202, 26.454],
[92.172, 26.442],
[92.124, 26.403000000000002],
[92.06700000000001, 26.373],
[92.07000000000001, 26.325],
[92.058, 26.304000000000002],
[91.971, 26.271],
[91.932, 26.271],
[91.899, 26.241],
[91.878, 26.235],
[91.854, 26.241],
[91.665, 26.166],
[91.608, 26.172],
[91.542, 26.145],
[91.497, 26.139],
[91.377, 26.172],
[91.287, 26.166],
[91.197, 26.202],
[91.134, 26.214000000000002],
[91.062, 26.187],
[90.96300000000001, 26.175],
[90.888, 26.136],
[90.849, 26.13],
[90.807, 26.154],
[90.741, 26.163],
[90.681, 26.19],
[90.58200000000001, 26.205000000000002],
[90.507, 26.229],
[90.474, 26.226],
[90.435, 26.178],
[90.402, 26.16],
[90.345, 26.148],
[90.309, 26.124],
[90.22500000000001, 26.106],
[90.177, 26.076],
[89.955, 26.01],
[89.904, 25.941],
[89.84700000000001, 25.89],
[89.81400000000001, 25.815],
[89.736, 25.692],
[89.724, 25.632],
[89.697, 25.572],
[89.709, 25.482],
[89.697, 25.398],
[89.67, 25.323],
[89.7, 25.266000000000002],
[89.703, 25.242],
[89.685, 25.2],
[89.661, 25.173000000000002],
[89.625, 25.074],
[89.613, 24.96],
[89.613, 24.936],
[89.658, 24.87],
[89.673, 24.792],
[89.706, 24.75],
[89.769, 24.549],
[89.748, 24.372],
[89.754, 24.282],
[89.733, 24.225],
[89.742, 24.201],
[89.739, 24.123],
[89.697, 24.015],
[89.7, 23.958000000000002],
[89.727, 23.883],
[89.787, 23.796],
[89.85600000000001, 23.748],
[89.919, 23.664],
[89.985, 23.643],
[90.144, 23.544],
[90.249, 23.463]
]
@@ -0,0 +1,34 @@
// Example tokens only. Replace every visual value with production-local tokens.
export const COLORS = {
bg: '#101315',
// Electric water — a near-white icy core with a blue glow and a white-hot draw-head (the "electricity"
// travels along the river as it draws on). No dark casing.
river: '#E8F7FF', // bright icy core
riverGlow: 'rgba(73,198,255,0.5)', // electric-blue glow
riverHead: '#FFFFFF', // white-hot leading head
riverHeadGlow: 'rgba(120,225,255,0.95)',
border: '#f5f2ed', // neutral cream country borders/labels over the colored fills
cream: '#f5f0eb',
} as const;
// Example progressive fill tokens. Rename these keys and replace values for each production.
export const COUNTRY = {
china: '#D4A853',
india: '#5B8A8A',
bangladesh: '#C07B57',
} as const;
// Darker shade of each country colour — the settled border line (the bright COUNTRY colour is the
// glowing draw-head that leads the animation).
export const COUNTRY_DARK = {
china: '#9A7530',
india: '#3C5C5C',
bangladesh: '#855239',
} as const;
export const FILL_OPACITY = 0.5;
export const VIDEO = {width: 1920, height: 1080, fps: 30} as const;
// Beat durations (seconds → frames at VIDEO.fps)
export const DUR = {
mapExplainer: 12 * VIDEO.fps,
} as const;
@@ -0,0 +1,91 @@
# Map element data sources
Choose the source independently for every story element. A single map can—and often should—mix
provider vectors with custom geodata.
## Decision rule
Use a MapTiler Planet vector layer when the feature already exists there, its attributes support an
editorially precise filter, and provider geometry is acceptable for the claim. Use custom GeoJSON when
the feature is absent, proposed, historical, disputed, corrected, privately sourced, or needs ordered
geometry for a deterministic draw.
| Requirement | MapTiler vector layer | Custom GeoJSON |
| --------------------------------------------------------------------------- | --------------------- | ------------------------------------------- |
| Roads, waterways, water, boundaries, land cover, or other standard context | Prefer | Use only when provider data is insufficient |
| Basemap-consistent geometry without a duplicate local dataset | Prefer | No |
| Proposed, historical, classified, corrected, or production-specific element | No | Prefer |
| Fade, colour, width, radius, blur, or fill-opacity animation | Yes | Yes |
| Feature-state highlight when a stable feature ID exists | Yes | Yes |
| Deterministic source-to-end line draw or perimeter draw | Bake first | Prefer |
| Geometry editing, morphing, clipping, or exact sequencing | No | Prefer |
Provider alignment is not proof of correctness. Inspect the attributes and geometry against the
editorial source before presenting a provider feature as evidence.
## MapTiler vector mode
MapTiler Planet is a vector tile source. Add it once, then build story layers with a
`source-layer` and an exact attribute filter. Read the current MapTiler Planet schema before choosing
layer names or fields.
Common layer categories include `waterway`, `water`, `transportation`, `boundary`, `landcover`, and
`poi`; availability, fields, and zoom ranges vary by schema version.
```ts
import {addMapTilerVectorElement, setVectorElementPaint} from "./MapTilerVectorElement";
addMapTilerVectorElement(map, process.env.REMOTION_MAPTILER_KEY!, {
id: "story-river",
sourceLayer: "waterway",
type: "line",
filter: [
"all",
["==", ["get", "class"], "river"],
["==", ["coalesce", ["get", "name_en"], ["get", "name"]], "Yarlung Tsangpo"],
],
layout: {"line-cap": "round", "line-join": "round"},
paint: {
"line-color": "#E8F7FF",
"line-width": 3,
"line-opacity": 0,
},
});
setVectorElementPaint(map, "story-river", {
"line-opacity": reveal,
"line-width": 2 + reveal * 2,
});
```
Animate provider features by changing paint properties from the Remotion frame: opacity, colour,
width, blur, fill opacity, circle radius, or symbol opacity. Use feature state only when the source
provides stable IDs and the selection remains deterministic across tiles.
Do not treat a tiled line as one ordered path. Vector tiles split features at tile boundaries, so a
source-to-mouth or start-to-end draw has no reliable global order. If that motion carries meaning,
extract and verify the complete feature, order it once, save it as GeoJSON, and use the custom mode.
## Custom geodata mode
Use the bundled `../assets/RiverReveal.tsx` and `../scripts/prep-geo.mjs` pattern for custom GeoJSON. This mode owns
the exact geometry and can slice it by distance, calculate entry triggers, draw complete borders, and
produce deterministic sequences.
Custom mode is mandatory when:
- the feature is not in the provider dataset;
- the story uses a proposed route, planned tunnel, historical boundary, disputed interpretation, or
non-public dataset;
- provider geometry was editorially corrected;
- motion must travel through the geometry in a verified order;
- a complete off-screen boundary matters and a viewport query would silently crop it.
## Hybrid mode
Use provider vectors for ordinary contextual features and custom GeoJSON for the specific claim. For
example: MapTiler waterways and roads as aligned context; a custom proposed tunnel, dam site, disputed
boundary, or verified evacuation area as the highlighted evidence.
Keep provider and custom layers visually distinct when they carry different evidentiary weight. Record
the source and effective date of every custom layer in the production notes.
@@ -0,0 +1,129 @@
# Map Explainer — architecture reference
Deep detail behind `TECHNIQUE.md`: the timing model, the river reveal + electric head, the per-country
sequence, and label projection. The supplied values are examples, not a production style system.
The custom-geometry example is `../assets/RiverReveal.tsx` +
`../assets/CountryLabel.tsx` +
`../assets/tokens.ts`. Provider-vector setup is in
`../assets/MapTilerVectorElement.ts`; choose between
the two modes with `data-sources.md`.
## 1. The render harness (per frame)
Init the MapTiler map once (ref guard). On `load`: strip clutter (see `geo-prep.md`), add sources/layers,
wait for `once('idle') → continueRender`. Per frame:
```
delayRender → setData/setPaintProperty → map.once('idle', continueRender) → triggerRepaint
```
`preserveDrawingBuffer:true` so Remotion's screenshot captures the canvas. Render `--gl=angle`.
For an animated camera, read `render-stability.md`: the MapTiler renderer remains static and a CSS plate
transform supplies the camera choreography.
## 2. Timing model — time-based; beat length derived from the sequences
Everything keys off **seconds** (`t = frame / fps`), not reveal-units. The river draws over a window;
each country **triggers when the river reaches it** and runs a fixed sequence. The beat is exactly as
long as the sequences need.
```ts
const RIVER_START = 0.3, RIVER_END = 8.0; // river draws over this window
const BORDER_S = 2.5, FILL_S = 1.0, LABEL_S = 0.7; // per-country sequence (constant durations)
const trigger = (c) => RIVER_START + META[c].stop * (RIVER_END - RIVER_START); // river-arrival time
// beat length = max over c of (trigger(c) + BORDER_S + FILL_S + LABEL_S) + tail
const reveal = interpolate(t, [RIVER_START, RIVER_END], [0,1], { ...clamp, easing: Easing.bezier(0.645, 0.045, 0.355, 1) });
```
**Constant durations matter:** drive the border draw by _time since trigger_, not a slice of the reveal —
otherwise complex or long borders flash by in a fraction of a second.
## 3. Provider-vector animation
MapTiler Planet elements can be animated directly in place. Filter an exact `source-layer` feature,
initialize its paint in the hidden or neutral state, then update paint properties from the Remotion frame.
This works for line, fill, circle, and symbol layers without copying provider geometry into the project.
Do not assume tiled geometry has a global order. Provider features are split at tile boundaries: opacity,
colour, width, blur, radius, fill, and feature-state changes are reliable; semantic start-to-end line
draws are not. Bake ordered GeoJSON when the direction of the draw carries meaning.
## 4. Custom line animation — reveal + electric draw-head
The "electricity" is a **white-hot head** leading the draw — the last few % of the drawn line in its own
bright + glow layers, faded out once the river completes.
```ts
const riverDrawnKm = lineKm * reveal;
map.getSource("river").setData(turf.lineSliceAlong(line, 0, Math.max(0.001, riverDrawnKm)));
const headKm = lineKm * 0.03;
map.getSource("river-head").setData(turf.lineSliceAlong(line, Math.max(0, riverDrawnKm - headKm), Math.max(0.001, riverDrawnKm)));
let headFade = 0;
if (reveal > 0.002 && reveal < 0.999) headFade = 1;
else if (reveal >= 0.999) headFade = 1 - clamp01((t - RIVER_END) / 0.5); // fade out at the mouth
map.setPaintProperty("river-headglow", "line-opacity", 0.85 * headFade);
map.setPaintProperty("river-head", "line-opacity", headFade);
```
Layers, bottom→top: `river-glow` (electric blue `#49C6FF`, w11, op0.32, blur6) → `river-line`
(icy core `#E8F7FF`, w3) → `river-headglow` (`rgba(120,225,255,.95)`, w16, blur9) → `river-head`
(white `#FFFFFF`, w4.5). **No dark casing** — the bright icy core reads over every fill on its own.
## 5. Country animation — border draws → fill blooms → label rises
Triggered by river arrival, each country runs three sequential phases. The border is a **darker shade**
of the country colour (the electricity is on the river, not here).
```ts
const lt = t - trigger(c); // local seconds since trigger
// 1) complete source border draws on over a constant BORDER_S, multi-segment-safe
const bp = interpolate(clamp01(lt / BORDER_S), [0,1], [0,1], { easing: Easing.bezier(0.645, 0.045, 0.355, 1) });
map.getSource(`trail-${c}`).setData(sliceBorder(DRAW[c], 0, DRAW[c].total * bp)); // COUNTRY_DARK line
// 2) fill blooms in (opacity overshoots, then settles) after the border completes
const fp = clamp01((lt - BORDER_S) / FILL_S);
const fo = interpolate(fp, [0, 0.6, 1], [0, FILL_OPACITY * 1.25, FILL_OPACITY], { ...clamp, easing: Easing.bezier(0.3333333333333333, 1, 0.6666666666666666, 1) });
map.setPaintProperty(`fill-${c}`, "fill-opacity", fp <= 0 ? 0 : fo);
// 3) label rises in after the fill
const lp = clamp01((lt - BORDER_S - FILL_S) / LABEL_S);
```
`sliceBorder(d, fromKm, toKm)` reveals a portion of a complete (possibly multi-segment) border as a
MultiLineString, slicing each segment by cumulative length — no joins across gaps and no viewport crop:
```ts
const sliceBorder = (d, fromKm, toKm) => {
const out = [];
for (let i = 0; i < d.segLines.length; i++) {
const start = d.cum[i], end = start + d.segLen[i];
const a = Math.max(fromKm, start), b = Math.min(toKm, end);
if (b - a <= 0.0008) continue;
out.push(turf.lineSliceAlong(d.segLines[i], a - start, b - start).geometry.coordinates);
}
return { type:"Feature", properties:{}, geometry:{ type:"MultiLineString", coordinates: out } };
};
```
Choose fill, border, and river colours in the production's local token file. The bundled token values are
examples only; do not carry a source project's palette into another production.
## 6. Labels — HTML overlay, projected each frame
Labels are React, not map symbols (full typography control). `CountryLabel` is an example accent-rule,
rise-and-fade treatment; select the typeface and final values in the production.
Positioned by projecting the anchor to screen pixels **every frame**, stored in state:
```ts
const p = map.project(META[c].anchor); // lngLat → screen px (respects the live camera)
pos[c] = { x: p.x, y: p.y, reveal: lp };
setLabels(pos); // re-render the overlay; effect deps exclude `labels`
```
`CountryLabel` shows the mechanics: uppercase region name, short accent divider, rise/fade entrance,
and `pointerEvents:none`. Select font, weight, size, spacing, contrast, and colour from the production's
own type and palette system.
## 7. Camera — fixed map plate for any movement
Read `render-stability.md`. Do not use per-frame `map.jumpTo()` for a moving 2D shot; it can shimmer in
headless renders even on satellite imagery. Interpolate the intended camera for the CSS plate transform,
while keeping the MapTiler renderer static.
@@ -0,0 +1,76 @@
# Map Explainer — basemap & geo prep
How the basemap is cleaned and how `../scripts/prep-geo.mjs` bakes the per-country data the component reads.
## Basemap styling — strip the clutter
On `load`, remove the basemap's labels and inner admin borders so only your geography reads:
```ts
for (const l of m.getStyle().layers as any[])
if (l.type === "symbol" || /other border/i.test(l.id)) m.removeLayer(l.id);
```
- `type === "symbol"` → every place/water/road **label** (the "MapTiler labels"). Gone.
- Inner admin-border layer IDs vary by style. Inspect the loaded style, remove state/province/district
layers as needed, and retain only the context borders the production requires.
- Logo/attribution: `maptilerLogo:false` + `attributionControl:false` aren't always enough — also hide
via CSS in the component:
```tsx
<style>{`.maplibregl-ctrl-bottom-left,.maplibregl-ctrl-bottom-right,.maplibregl-ctrl-attrib,.maptiler-logo{display:none!important}`}</style>
```
## `../scripts/prep-geo.mjs` → outputs
Reads a routed river GeoJSON + country polygon GeoJSONs; writes:
- **River line** — simplified for a smooth draw. For a braided river, route one source→mouth path through
the network first: greedy endpoint-chaining bounces between parallel channels. → `src/geo/river-flow.json`.
- **`public/geo/borders.geojson`** — each country's polygon tagged `{country: name}` (one source,
filtered per country for the fills).
- **`src/geo/country-meta.json`** — per country `{ stop, anchor, border }`.
### `stop` — when a country lights up
Walk the river points; first point inside a country (`turf.booleanPointInPolygon`) = the arc-length
fraction where the river **enters** it. Drives the trigger time. The headwaters country = 0.
### `anchor` — label centre via pole of inaccessibility
The most-interior point of the country (clipped to a per-country **story bbox** so a big country
centres in the relevant region, not its far bulge), then a small operator **nudge**. Pole = grid-sample
inside the polygon, keep the point with max distance to the boundary. **Centroids get pulled to edges —
don't use them.**
```js
const pole = (poly) => {
const bb = turf.bbox(poly), edge = turf.polygonToLine(poly), N = 46;
let best = null, bestD = -1;
for (let i = 0; i <= N; i++) for (let j = 0; j <= N; j++) {
const p = turf.point([bb[0]+(bb[2]-bb[0])*i/N, bb[1]+(bb[3]-bb[1])*j/N]);
if (!turf.booleanPointInPolygon(p, poly)) continue;
const d = turf.pointToLineDistance(p, edge);
if (d > bestD) { bestD = d; best = p.geometry.coordinates; }
}
return best;
};
const ANCHOR_BBOX = { china:[82,27,96,32], india:[76,14,99,31], bangladesh:[86,20,93,27] }; // story regions
const NUDGE = { china:[0,0.6], india:[-1.0,0], bangladesh:[0,-0.6] }; // operator-directed
```
### `border` — complete source geometry
Preserve every exterior ring from the named country source. Never clip a country or bilateral border to
the framed bbox and never discard an off-screen segment: the geometry may leave the frame naturally.
The renderer handles a MultiLineString by cumulative length, so it remains one timed reveal without
inventing joins across gaps.
## Tuning the geo prep for a new scenario
| Want | Knob |
| --------------------------------- | -------------------------------------------------------------------- |
| Which countries | the country list in `prep-geo.mjs` (+ supply their polygon GeoJSONs) |
| Label centred in the right region | `ANCHOR_BBOX[country]` (the story bbox) |
| Nudge a label | `NUDGE[country]` (lng, lat offset) |
| What border is drawn | the complete named source geometry; never the visible extent |
| When each lights up | derived from `stop` — depends on the river geometry |
@@ -0,0 +1,64 @@
# Moving Map Render Stability
Read this reference before building a moving 2D MapTiler scene or diagnosing a wavering Remotion render.
## Symptom and cause
If basemap detail shimmers or jitters during a pan/zoom, the likely cause is per-frame `map.jumpTo()`.
Headless MapTiler capture can resample both vector hillshade and satellite imagery differently frame to
frame. Tile retries, easing changes, and label changes do not solve that renderer effect.
## Required pattern: fixed map plate
For any 2D pan/zoom:
1. Render the MapTiler canvas once at the largest required zoom in an oversized container. Size it from the camera route and keep each dimension below the browser's reliable WebGL render-buffer limit (commonly 4096 px). Do **not** blindly use 3×: a 1920×1080 composition becomes 5760 px wide and Chromium may silently downsample it, causing visible pixelation during the CSS zoom.
2. Keep the map's camera static.
3. For each frame, calculate the approved target centre/zoom, then move the canvas with CSS `translate` + `scale`.
4. Apply the same transform to every projected HTML overlay.
5. Continue to animate GeoJSON data and paint properties imperatively; only the renderer camera is frozen.
Keep pitch and bearing constant. Use a 3D engine such as Cesium for genuine changing pitch/bearing or a terrain flythrough.
```ts
const baseZoom = Math.max(start.zoom, end.zoom);
const map = new maptilersdk.Map({
container,
style,
center: end.center,
zoom: baseZoom,
pitch: end.pitch ?? 0,
bearing: end.bearing ?? 0,
interactive: false,
fadeDuration: 0,
canvasContextAttributes: {preserveDrawingBuffer: true},
});
// Per Remotion frame. `camera` is the approved centre/zoom interpolation.
const projected = map.project(camera.center);
const scale = 2 ** (camera.zoom - baseZoom);
const plate = {
transform: `translate(${width / 2 - projected.x * scale}px, ${height / 2 - projected.y * scale}px) scale(${scale})`,
transformOrigin: "0 0",
};
// Convert label projection with exactly the same plate transform.
const labelX = labelPoint.x * scale + width / 2 - projected.x * scale;
const labelY = labelPoint.y * scale + height / 2 - projected.y * scale;
```
### Plate sizing and sharpness
- Render at the maximum zoom reached by **any** camera waypoint, including intermediate or hold cameras. The CSS scale should never exceed `1`; otherwise the plate is being enlarged.
- Centre the frozen map on the midpoint of the camera route's geographic extent, not automatically on the final camera. This minimizes required overscan.
- Keep the largest canvas dimension at or below 4096 px unless the actual render environment has been tested with a larger `MAX_RENDERBUFFER_SIZE`.
- For 1920×1080, a 3840×2160 plate is a safe default. For 1080×1920, use approximately 2700×3840 when the route needs extra horizontal pan room.
- If the route cannot fit within that plate at the required zoom, split the shot into two fixed plates with a deliberate editorial transition. Do not trade sharpness for one enormous canvas.
- Distinguish failure modes: repeating shimmer means the live renderer is moving; steadily soft tiles during a CSS push means the fixed plate is underspecified, internally downsampled, or being scaled above `1`.
## Verification
- Render a short MP4, not only a Studio preview.
- Inspect static terrain texture and satellite detail while the camera moves.
- If any underlying map detail wavers, use the fixed map plate. Do not approve it as a minor preview artefact.
- Render WebGL with `--gl=angle`, `preserveDrawingBuffer:true`, and conservative concurrency (`1`) while validating.
@@ -0,0 +1,202 @@
// Geo prep for the map-explainer. Bakes the data the component reads:
// out/river-flow.json -> the river draw-on line (→ copy to your Remotion project's src/geo/)
// out/country-meta.json -> per-country { stop, anchor, border } (→ src/geo/)
// out/borders.geojson -> country polygons, each tagged {country} (→ public/geo/ — loaded via staticFile)
//
// RUN: node prep-geo.mjs (needs YOUR geodata — see CONFIG; the source polygons are too large to ship,
// so the skill ships the OUTPUTS in assets/sample-data/ instead.)
//
// RIVER INPUT MUST BE ONE CLEAN LINESTRING, source → mouth (features[0].geometry is a LineString). If your
// OSM river comes as many ways / braided channels, ROUTE it into a single line FIRST — a graph
// shortest-path from source node to mouth node. Do NOT greedily chain by nearest endpoint: it bounces
// between parallel channels. (That routing step is a prerequisite, not part of this script.)
import {readFileSync, writeFileSync, mkdirSync} from 'fs';
import {dirname, resolve} from 'path';
import {fileURLToPath} from 'url';
if (process.argv.includes('--help')) {
console.log(
'Configure COUNTRIES, RIVER, BORDER, label bounds, and output paths in this script, then run: bun scripts/prep-geo.mjs',
);
process.exit(0);
}
const turf = await import('@turf/turf');
const __dir = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dir, '..');
const geo = resolve(root, '../geodata'); // ADAPT: where your input GeoJSON lives
const read = (p) => JSON.parse(readFileSync(p, 'utf8'));
// ===== CONFIG — edit for your river + countries =====
const COUNTRIES = ['china', 'india', 'bangladesh']; // ORDERED headwaters → mouth; first = source (stop 0)
const RIVER = resolve(geo, 'focus-rivers/yarlung-brahmaputra-full-osm.geojson'); // a single clean source→mouth LineString
const BORDER = (name) => resolve(geo, `project-borders/${name}.geojson`); // one polygon file per country, named <country>.geojson
const FRAME_BBOX = [76, 14, 104, 33.5]; // [W,S,E,N] visible extent — fallback for label anchoring only
const ANCHOR_BBOX = {
china: [82, 27, 96, 32],
india: [76, 14, 99, 31],
bangladesh: [86, 20, 93, 27],
}; // [W,S,E,N] per-country label "story region"
const NUDGE = {china: [0, 0.6], india: [-1.0, 0], bangladesh: [0, -0.6]}; // [lng,lat] label nudge
const RIVER_SIMPLIFY_TOL = 0.006; // degrees — smooths the draw-on (bigger = simpler)
const OUT_RIVER = resolve(root, 'out/river-flow.json');
const OUT_META = resolve(root, 'out/country-meta.json');
const OUT_BORDERS = resolve(root, 'out/borders.geojson');
// =====================================================
const havKm = (a, b) => {
const R = 6371,
r = Math.PI / 180;
const dLat = (b[1] - a[1]) * r,
dLng = (b[0] - a[0]) * r;
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(a[1] * r) * Math.cos(b[1] * r) * Math.sin(dLng / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(h));
};
// --- River draw-on line: take the clean routed LineString, strip a dangling final hop, simplify so the
// wide-zoom draw-on reads as one smooth thread (no bezier — meander overshoot on a long river). ---
const routed = read(RIVER).features[0].geometry.coordinates;
let end = routed.length;
while (end > 2 && havKm(routed[end - 2], routed[end - 1]) > 15) end--; // drop a final cross-braid jump if present
const flow = turf.simplify(turf.lineString(routed.slice(0, end)), {
tolerance: RIVER_SIMPLIFY_TOL,
highQuality: true,
}).geometry.coordinates;
// --- Borders + country fills (one source, filtered per country in the component) ---
const borders = {type: 'FeatureCollection', features: []};
const polys = {};
for (const name of COUNTRIES) {
const fc = read(BORDER(name));
polys[name] = fc;
for (const f of fc.features) {
f.properties = {...(f.properties || {}), country: name};
borders.features.push(f);
}
}
// --- Reveal stops: arc-length fraction of `flow` where the river first ENTERS each country. The first
// country (headwaters) is the source, so its stop is 0; the rest are computed. ---
const flowKm = turf.length(turf.lineString(flow));
const insideCountry = (pt, name) =>
polys[name].features.some((f) => turf.booleanPointInPolygon(pt, f));
const stops = {};
COUNTRIES.forEach((c, i) => {
stops[c] = i === 0 ? 0 : 1;
}); // 0 = headwaters; 1 = sentinel until entered
let acc = 0;
for (let i = 0; i < flow.length; i++) {
if (i > 0) acc += havKm(flow[i - 1], flow[i]);
const frac = acc / (flowKm || 1);
const pt = turf.point(flow[i]);
for (const c of COUNTRIES)
if (stops[c] === 1 && insideCountry(pt, c)) stops[c] = frac;
}
// --- Per-country meta: anchor = pole of inaccessibility of the visible landmass (centred, away from
// borders/edges) within the country's story region + a NUDGE; border = every exterior ring from the
// complete named source. Never crop a country or bilateral boundary to the viewport. ---
const biggestPoly = (geom) => {
const rings =
geom.type === 'MultiPolygon' ? geom.coordinates : [geom.coordinates];
let best = null,
bestA = -1;
for (const c of rings) {
const p = turf.polygon(c);
const a = turf.area(p);
if (a > bestA) {
bestA = a;
best = p;
}
}
return best;
};
const largestPolygon = (fc) => {
let best = null,
bestA = -1;
for (const f of fc.features) {
const p = biggestPoly(f.geometry),
a = turf.area(p);
if (a > bestA) {
bestA = a;
best = p;
}
}
return best;
};
const completeExteriorSegments = (fc) => {
const segments = [];
for (const feature of fc.features) {
const polygons =
feature.geometry.type === 'MultiPolygon'
? feature.geometry.coordinates
: [feature.geometry.coordinates];
for (const polygon of polygons)
if (polygon[0]?.length > 1) segments.push(polygon[0]);
}
return segments;
};
const poleOfInaccessibility = (poly) => {
const bb = turf.bbox(poly),
boundary = turf.polygonToLine(poly),
N = 46;
let best = null,
bestD = -1;
for (let i = 0; i <= N; i++)
for (let j = 0; j <= N; j++) {
const lng = bb[0] + ((bb[2] - bb[0]) * i) / N,
lat = bb[1] + ((bb[3] - bb[1]) * j) / N;
const pt = turf.point([lng, lat]);
if (!turf.booleanPointInPolygon(pt, poly)) continue;
const d = turf.pointToLineDistance(pt, boundary);
if (d > bestD) {
bestD = d;
best = [lng, lat];
}
}
return best;
};
const countryMeta = {};
for (const name of COUNTRIES) {
const poly = largestPolygon(polys[name]);
const storyRegion = biggestPoly(
turf.bboxClip(poly, ANCHOR_BBOX[name] || FRAME_BBOX).geometry,
);
const pole = poleOfInaccessibility(storyRegion);
const segs = completeExteriorSegments(polys[name]);
const nudge = NUDGE[name] || [0, 0];
countryMeta[name] = {
stop: stops[name],
anchor: [pole[0] + nudge[0], pole[1] + nudge[1]],
border: segs,
};
}
mkdirSync(dirname(OUT_RIVER), {recursive: true});
writeFileSync(OUT_RIVER, JSON.stringify(flow));
writeFileSync(OUT_META, JSON.stringify(countryMeta));
writeFileSync(OUT_BORDERS, JSON.stringify(borders));
console.log(
'river:',
flow.length,
'pts ·',
flowKm.toFixed(0),
'km · entry stops',
JSON.stringify(stops),
);
for (const n of COUNTRIES) {
const km = countryMeta[n].border.reduce(
(s, seg) => s + turf.length(turf.lineString(seg)),
0,
);
console.log(
` ${n}: anchor ${countryMeta[n].anchor.map((v) => v.toFixed(2)).join(',')} · border ${km.toFixed(0)} km`,
);
}
console.log(
"\nNext: copy out/river-flow.json + out/country-meta.json → your project's src/geo/ ; out/borders.geojson → public/geo/",
);
@@ -0,0 +1,37 @@
---
name: remotion-maps-static
description: Create a deterministic static locator map in Remotion when neither the camera nor geographic data animates.
---
# Static map
Use a static image when the map only provides location context. This is the smallest, fastest, and
most deterministic map technique.
## Build
1. Export or request a map image at the composition's final aspect ratio and at least its rendered
pixel dimensions.
2. Store the image in the Remotion project's `public/` directory.
3. Render it with `CanvasImage` and `staticFile()`.
4. Add labels or markers as ordinary Remotion elements if they remain fixed.
```tsx
import React from 'react';
import {AbsoluteFill, CanvasImage, staticFile} from 'remotion';
export const StaticMap: React.FC = () => {
return (
<>
<CanvasImage
src={staticFile('locator-map.png')}
style={{width: '100%', height: '100%', objectFit: 'cover'}}
/>
</>
);
};
```
## Overlays and Interactivity
Follow [Remotion Interactivity](../../../remotion-interactivity/REFERENCE.md) best practices and [Remotion Markup Best practices](../../../remotion-markup/REFERENCE.md) for elements.