Skill 01 · Remotion Best Practices
Subchapter 1.36
remotion-maps/techniques/maplibre/TECHNIQUE.mdMarkdown14 KBView on GitHub
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.
@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.Marker elements unless the user specifically asks for HTML markers.interactive: false, fadeDuration: 0.useCurrentFrame(); do not use CSS transitions or browser-timed animation.delayRender() / continueRender() around map loading and per-frame map updates.preserveDrawingBuffer: true and render WebGL with bunx remotion ... --gl=angle.jumpTo(), then wait for idle.mapInstance.remove() cleanup function; it can interfere with Remotion’s render lifecycle.@types/maplibre-gl; MapLibre ships its own types.Coordinates in MapLibre, Turf, and GeoJSON are [longitude, latitude].
const zurich: [number, number] = [8.5417, 47.3769];
const newYork: [number, number] = [-74.006, 40.7128];Install MapLibre and Turf with the project’s package manager.
npm i maplibre-gl @turf/turfbun i maplibre-gl @turf/turfyarn add maplibre-gl @turf/turfpnpm i maplibre-gl @turf/turfImport the MapLibre CSS once in the component or an app-level stylesheet:
import 'maplibre-gl/dist/maplibre-gl.css';import {useEffect, useRef, useState} from 'react';
import {AbsoluteFill, useDelayRender, useVideoConfig} from 'remotion';
import * as 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;
}
maplibregl.setWorkerUrl(
URL.createObjectURL(
new Blob(
[`import "https://unpkg.com/maplibre-gl@${maplibregl.getVersion()}/dist/maplibre-gl-worker.mjs";`],
{type: 'text/javascript'},
),
),
);
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.
This example shows the recommended pattern for route animations:
calculateCameraOptionsFromTo() is used for camera movement.continueRender().import * as turf from '@turf/turf';
import {useEffect, useRef, useState} from 'react';
import {
AbsoluteFill,
Easing,
interpolate,
useCurrentFrame,
useDelayRender,
useVideoConfig,
} from 'remotion';
import * as maplibregl from 'maplibre-gl';
import {type GeoJSONSource, type Map}
Use MapLibre’s camera helper for camera movement:
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:
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.
Use GeoJSON sources for lines. Unless the user asks, do not add glow effects or extra decorative points.
For geodesic flight routes, use Turf:
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().
Use map-native GeoJSON layers for markers and labels:
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.
Default to the stock MapLibre demo style:
style: 'https://demotiles.maplibre.org/style.json'If the user requests another style, use any valid MapLibre style JSON URL.
For WebGL map renders, prefer single concurrency and ANGLE:
bunx remotion render [composition-id] out/video.mp4 --gl=angle --concurrency=1Use the equivalent package runner for the project. In npm projects, use npx; in Bun projects, use bunx.