Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Apply best practices for creating programmatic videos with Remotion and React
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
rules/maplibre.md
1---2name: maps3description: Make deterministic Remotion map animations with MapLibre GL JS and Turf. Use for animated routes, flyovers, map markers, labels, and camera movement.4metadata:5tags: map, map animation, maplibre, turf, geojson, route animation6---78Use 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.910## Core rules1112- 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.13- Use GeoJSON sources and MapLibre layers for lines, markers, and labels. Avoid DOM `Marker` elements unless the user specifically asks for HTML markers.14- Disable non-deterministic map behavior: `interactive: false`, `fadeDuration: 0`.15- Use `delayRender()` / `continueRender()` around map loading and per-frame map updates.16- Before continuing the initial render, add sources/layers, apply the frame-0 camera with `jumpTo()`, then wait for `idle`.17- Do not add a `mapInstance.remove()` cleanup function; it can interfere with Remotion's render lifecycle.18- Use standard MapLibre style JSON URLs and layer/source APIs.19- Do not install `@types/maplibre-gl`; MapLibre ships its own types.2021Coordinates in MapLibre, Turf, and GeoJSON are `[longitude, latitude]`.2223```ts24const zurich: [number, number] = [8.5417, 47.3769];25const newYork: [number, number] = [-74.006, 40.7128];26```2728## Prerequisites2930Install MapLibre and Turf with the project's package manager.3132```bash33npm i maplibre-gl @turf/turf34```3536```bash37bun i maplibre-gl @turf/turf38```3940```bash41yarn add maplibre-gl @turf/turf42```4344```bash45pnpm i maplibre-gl @turf/turf46```4748Import the MapLibre CSS once in the component or an app-level stylesheet:4950```ts51import 'maplibre-gl/dist/maplibre-gl.css';52```5354## Basic map example5556```tsx57import {useEffect, useRef, useState} from 'react';58import {AbsoluteFill, useDelayRender, useVideoConfig} from 'remotion';59import maplibregl from 'maplibre-gl';60import 'maplibre-gl/dist/maplibre-gl.css';6162const zurich: [number, number] = [8.5417, 47.3769];6364export const MyComposition = () => {65const containerRef = useRef<HTMLDivElement>(null);66const {delayRender, continueRender} = useDelayRender();67const {width, height} = useVideoConfig();68const [loadingHandle] = useState(() => delayRender('Loading map'));6970useEffect(() => {71if (!containerRef.current) {72return;73}7475const mapInstance = new maplibregl.Map({76container: containerRef.current,77style: 'https://demotiles.maplibre.org/style.json',78center: zurich,79zoom: 7,80interactive: false,81attributionControl: false,82fadeDuration: 0,83canvasContextAttributes: {84preserveDrawingBuffer: true,85},86});8788mapInstance.on('load', () => {89mapInstance.jumpTo({center: zurich, zoom: 7});90mapInstance.once('idle', () => {91continueRender(loadingHandle);92});93});94}, [continueRender, loadingHandle]);9596return (97<AbsoluteFill>98<div ref={containerRef} style={{width, height, position: 'absolute'}} />99</AbsoluteFill>100);101};102```103104Animated examples should keep the loaded map in React state and skip per-frame updates until that state is set.105106## Animated flight route example107108This example shows the recommended pattern for route animations:109110- Turf creates the route and markers.111- Turf slices the route for line reveal animation.112- The camera has a separate route from the target route.113- MapLibre's `calculateCameraOptionsFromTo()` is used for camera movement.114- Frame 0 is prepared before `continueRender()`.115116```tsx117import * as turf from '@turf/turf';118import {useEffect, useRef, useState} from 'react';119import {120AbsoluteFill,121Easing,122interpolate,123useCurrentFrame,124useDelayRender,125useVideoConfig,126} from 'remotion';127import maplibregl, {type GeoJSONSource, type Map} from 'maplibre-gl';128import 'maplibre-gl/dist/maplibre-gl.css';129130const zurich: [number, number] = [8.5417, 47.3769];131const newYork: [number, number] = [-74.006, 40.7128];132133const greatCircleLine = (from: [number, number], to: [number, number]) => {134const route = turf.greatCircle(from, to, {npoints: 100});135136if (route.geometry.type === 'LineString') {137return turf.lineString(route.geometry.coordinates);138}139140// Great-circle routes crossing the antimeridian can become MultiLineString.141// Keep the example valid by choosing the longest segment.142const longestSegment = route.geometry.coordinates.reduce((longest, segment) => {143return segment.length > longest.length ? segment : longest;144});145146return turf.lineString(longestSegment);147};148149const targetRoute = greatCircleLine(zurich, newYork);150const targetRouteDistance = turf.length(targetRoute);151152const cameraRoute = greatCircleLine(zurich, newYork);153const cameraRouteDistance = turf.length(cameraRoute);154155const cityMarkers = turf.featureCollection([156turf.point(zurich, {name: 'Zurich'}),157turf.point(newYork, {name: 'New York'}),158]);159160const clampProgress = (progress: number) => Math.min(1, Math.max(0, progress));161162const distanceAlong = (totalDistance: number, progress: number) => {163// Keep the route non-empty at progress 0; Turf can error on zero-length slices.164return Math.max(0.001, totalDistance * clampProgress(progress));165};166167const getPartialTargetRoute = (progress: number) => {168return turf.lineSliceAlong(169targetRoute,1700,171distanceAlong(targetRouteDistance, progress),172);173};174175const getCameraOptions = (176map: Map,177progress: number,178cameraAltitudeMeters: number,179cameraLatitudeOffset: number,180) => {181const target = turf.along(182targetRoute,183distanceAlong(targetRouteDistance, progress),184).geometry.coordinates;185const camera = turf.along(186cameraRoute,187distanceAlong(cameraRouteDistance, progress),188).geometry.coordinates;189190return map.calculateCameraOptionsFromTo(191new maplibregl.LngLat(camera[0], camera[1] - cameraLatitudeOffset),192cameraAltitudeMeters,193new maplibregl.LngLat(target[0], target[1]),194);195};196197export const MyComposition = () => {198const containerRef = useRef<HTMLDivElement>(null);199const frame = useCurrentFrame();200const {delayRender, continueRender} = useDelayRender();201const {durationInFrames, height, width} = useVideoConfig();202const [map, setMap] = useState<Map | null>(null);203const [loadingHandle] = useState(() => delayRender('Loading MapLibre map'));204205useEffect(() => {206if (!containerRef.current) {207return;208}209210const mapInstance = new maplibregl.Map({211container: containerRef.current,212style: 'https://demotiles.maplibre.org/style.json',213center: zurich,214zoom: 7,215interactive: false,216attributionControl: false,217fadeDuration: 0,218canvasContextAttributes: {219preserveDrawingBuffer: true,220},221});222223mapInstance.on('load', () => {224mapInstance.addSource('trace', {225type: 'geojson',226data: getPartialTargetRoute(0),227});228229mapInstance.addLayer({230id: 'trace-line',231type: 'line',232source: 'trace',233layout: {234'line-cap': 'round',235'line-join': 'round',236},237paint: {238'line-color': '#111111',239'line-width': 7,240},241});242243mapInstance.addSource('city-markers', {244type: 'geojson',245data: cityMarkers,246});247248mapInstance.addLayer({249id: 'city-marker-dots',250type: 'circle',251source: 'city-markers',252paint: {253'circle-color': '#f03b20',254'circle-radius': 12,255'circle-stroke-color': '#ffffff',256'circle-stroke-width': 4,257},258});259260mapInstance.addLayer({261id: 'city-marker-labels',262type: 'symbol',263source: 'city-markers',264layout: {265'text-allow-overlap': true,266'text-anchor': 'top',267'text-field': ['get', 'name'],268'text-offset': [0, 0.9],269'text-size': 28,270},271paint: {272'text-color': '#111111',273'text-halo-color': '#ffffff',274'text-halo-width': 3,275},276});277278mapInstance.jumpTo(getCameraOptions(mapInstance, 0, 180000, 1.1));279mapInstance.once('idle', () => {280setMap(mapInstance);281continueRender(loadingHandle);282});283});284}, [continueRender, loadingHandle]);285286useEffect(() => {287if (!map) {288return;289}290291const handle = delayRender('Rendering MapLibre frame');292const timelineProgress = interpolate(frame, [0, durationInFrames - 1], [0, 1], {293extrapolateLeft: 'clamp',294extrapolateRight: 'clamp',295});296const travelProgress = interpolate(timelineProgress, [0.2, 0.82], [0, 1], {297extrapolateLeft: 'clamp',298extrapolateRight: 'clamp',299easing: Easing.inOut(Easing.cubic),300});301const cameraAltitudeMeters = interpolate(302timelineProgress,303[0, 0.28, 0.74, 1],304[180000, 2200000, 2200000, 180000],305{306extrapolateLeft: 'clamp',307extrapolateRight: 'clamp',308easing: Easing.inOut(Easing.cubic),309},310);311const cameraLatitudeOffset = interpolate(312timelineProgress,313[0, 0.28, 0.74, 1],314[1.1, 8, 8, 1.1],315{316extrapolateLeft: 'clamp',317extrapolateRight: 'clamp',318easing: Easing.inOut(Easing.cubic),319},320);321const trace = map.getSource('trace') as GeoJSONSource | undefined;322323trace?.setData(getPartialTargetRoute(travelProgress));324map.jumpTo(325getCameraOptions(326map,327travelProgress,328cameraAltitudeMeters,329cameraLatitudeOffset,330),331);332333map.once('idle', () => continueRender(handle));334// Force an idle event even if the camera parameters are unchanged from the previous frame.335map.triggerRepaint();336}, [continueRender, delayRender, durationInFrames, frame, map]);337338return (339<AbsoluteFill style={{backgroundColor: '#e8eef3'}}>340<div ref={containerRef} style={{height, position: 'absolute', width}} />341</AbsoluteFill>342);343};344```345346## Camera guidance347348Use MapLibre's camera helper for camera movement:349350```ts351map.calculateCameraOptionsFromTo(cameraLngLat, cameraAltitudeMeters, targetLngLat);352```353354A good pattern is to keep two concepts separate:355356- `targetRoute`: where the animated line is and where the camera looks.357- `cameraRoute`: where the camera moves.358359Then use Turf to read positions from both routes for the same progress value:360361```ts362const target = turf.along(targetRoute, targetDistance * progress).geometry.coordinates;363const camera = turf.along(cameraRoute, cameraDistance * progress).geometry.coordinates;364365map.jumpTo(366map.calculateCameraOptionsFromTo(367new maplibregl.LngLat(camera[0], camera[1]),368cameraAltitudeMeters,369new maplibregl.LngLat(target[0], target[1]),370),371);372```373374For 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.375376## Lines377378Use GeoJSON sources for lines. Unless the user asks, do not add glow effects or extra decorative points.379380For geodesic flight routes, use Turf:381382```ts383const line = greatCircleLine(start, end);384const distance = turf.length(line);385const partialLine = turf.lineSliceAlong(386line,3870,388// Keep the route non-empty at progress 0.389Math.max(0.001, distance * progress),390);391```392393For a visually straight line on the map, use a simple GeoJSON `LineString` between the two points instead of `greatCircle()`.394395## Markers and labels396397Use map-native GeoJSON layers for markers and labels:398399```tsx400mapInstance.addSource('markers', {401type: 'geojson',402data: turf.featureCollection([403turf.point([-118.2437, 34.0522], {name: 'Los Angeles'}),404]),405});406407mapInstance.addLayer({408id: 'marker-dots',409type: 'circle',410source: 'markers',411paint: {412'circle-color': '#f03b20',413'circle-radius': 12,414'circle-stroke-color': '#ffffff',415'circle-stroke-width': 4,416},417});418419mapInstance.addLayer({420id: 'marker-labels',421type: 'symbol',422source: 'markers',423layout: {424'text-allow-overlap': true,425'text-anchor': 'top',426'text-field': ['get', 'name'],427'text-offset': [0, 0.9],428'text-size': 28,429},430paint: {431'text-color': '#111111',432'text-halo-color': '#ffffff',433'text-halo-width': 3,434},435});436```437438Make marker sizes and label font sizes large enough for the composition resolution.439440## Styles441442Default to the stock MapLibre demo style:443444```ts445style: 'https://demotiles.maplibre.org/style.json'446```447448If the user requests another style, use any valid MapLibre style JSON URL.449450## Rendering451452For WebGL map renders, prefer single concurrency and ANGLE:453454```bash455bunx remotion render [composition-id] out/video.mp4 --gl=angle --concurrency=1456```457458Use the equivalent package runner for the project. In npm projects, use `npx`; in Bun projects, use `bunx`.459