地圖動畫
使用 Mapbox GL JS 在 Remotion 中建立地圖動畫,包括地圖載入、樣式設定、路線繪製和動畫播放等功能。
地圖動畫
使用 Mapbox GL JS 在 Remotion 中建立地圖動畫。
前置需求
安裝套件
# npm
npm i --save-exact mapbox-gl @turf/turf @types/mapbox-gl
# pnpm
pnpm i mapbox-gl @turf/turf @types/mapbox-gl
# bun
bun i mapbox-gl @turf/turf @types/mapbox-gl
# yarn
yarn --exact add mapbox-gl @turf/turf @types/mapbox-gl取得 Mapbox Access Token
- 建立免費的 Mapbox 帳號
- 從 Mapbox Console 取得 Access Token
- 將 Token 加入
.env檔案:
REMOTION_MAPBOX_TOKEN=pk.your-mapbox-access-token
加入地圖
使用 useDelayRender() 等待地圖載入完成。容器元素必須有明確的尺寸和 position: "absolute"。
import {
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
AbsoluteFill,
useDelayRender,
useVideoConfig,
} from "remotion";
import mapboxgl, { Map } from "mapbox-gl";
mapboxgl.accessToken = process.env.REMOTION_MAPBOX_TOKEN as string;
export const MapComposition = () => {
const ref = useRef<HTMLDivElement>(null);
const { delayRender, continueRender } = useDelayRender();
const { width, height } = useVideoConfig();
const [handle] = useState(() => delayRender("Loading map..."));
const [map, setMap] = useState<Map | null>(null);
useEffect(() => {
const _map = new Map({
container: ref.current!,
zoom: 11.53,
center: [6.5615, 46.0598], // 經度, 緯度
pitch: 65, // 傾斜角度
bearing: -180, // 方位角
style: "mapbox://styles/mapbox/standard",
interactive: false, // 必須關閉互動
fadeDuration: 0, // 必須設為 0
});
_map.on("load", () => {
continueRender(handle);
setMap(_map);
});
}, [handle, continueRender]);
const style: React.CSSProperties = useMemo(
() => ({ width, height, position: "absolute" }),
[width, height]
);
return <AbsoluteFill ref={ref} style={style} />;
};重要:必須設定
interactive: false和fadeDuration: 0,這樣才能用useCurrentFrame()驅動所有動畫,而不是用戶互動。
設定地圖樣式
使用 Mapbox Standard 樣式時,建議隱藏標籤和特定功能,以獲得更簡潔的外觀:
_map.on("style.load", () => {
const hideFeatures = [
"showRoadsAndTransit",
"showRoadLabels",
"showTransitLabels",
"showPlaceLabels",
"showPointOfInterestLabels",
"showAdminBoundaries",
"show3dObjects",
"show3dBuildings",
];
for (const feature of hideFeatures) {
_map.setConfigProperty("basemap", feature, false);
}
// 將道路設為透明
_map.setConfigProperty("basemap", "colorMotorways", "transparent");
_map.setConfigProperty("basemap", "colorRoads", "transparent");
});繪製路線
加入 GeoJSON 線條來源和圖層:
_map.addSource("route", {
type: "geojson",
data: {
type: "Feature",
properties: {},
geometry: {
type: "LineString",
coordinates: lineCoordinates, // [[經度, 緯度], [經度, 緯度], ...]
},
},
});
_map.addLayer({
type: "line",
source: "route",
id: "line",
paint: {
"line-color": "#000000",
"line-width": 5,
},
layout: {
"line-cap": "round",
"line-join": "round",
},
});路線動畫
使用線性插值為直線路線製作動畫:
import {
useCurrentFrame,
useVideoConfig,
useDelayRender,
interpolate,
Easing,
} from "remotion";
import { useEffect, useState } from "react";
import mapboxgl from "mapbox-gl";
const lineCoordinates = [
[6.5615, 46.0598], // 起點
[6.8965, 45.9237], // 終點
];
export const AnimatedRoute = () => {
const frame = useCurrentFrame();
const { durationInFrames } = useVideoConfig();
const { delayRender, continueRender } = useDelayRender();
const [map, setMap] = useState<mapboxgl.Map | null>(null);
// 計算動畫進度
const progress = interpolate(
frame,
[0, durationInFrames - 1],
[0, 1],
{
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.inOut(Easing.cubic),
}
);
// 計算當前位置
const start = lineCoordinates[0];
const end = lineCoordinates[1];
const currentLng = start[0] + (end[0] - start[0]) * progress;
const currentLat = start[1] + (end[1] - start[1]) * progress;
// 更新地圖上的路線
useEffect(() => {
if (!map) return;
const source = map.getSource("route") as mapboxgl.GeoJSONSource;
if (source) {
source.setData({
type: "Feature",
properties: {},
geometry: {
type: "LineString",
coordinates: [start, [currentLng, currentLat]],
},
});
}
}, [map, progress, currentLng, currentLat]);
// ... 地圖初始化代碼
};相機動畫
使用 useCurrentFrame() 控制相機移動:
import { useCurrentFrame, useVideoConfig, interpolate } from "remotion";
import { useEffect } from "react";
import mapboxgl from "mapbox-gl";
export const CameraAnimation = ({ map }: { map: mapboxgl.Map | null }) => {
const frame = useCurrentFrame();
const { durationInFrames } = useVideoConfig();
useEffect(() => {
if (!map) return;
// 插值計算相機位置
const progress = frame / durationInFrames;
const lng = interpolate(progress, [0, 1], [6.5615, 6.8965]);
const lat = interpolate(progress, [0, 1], [46.0598, 45.9237]);
const zoom = interpolate(progress, [0, 0.5, 1], [10, 12, 14]);
const bearing = interpolate(progress, [0, 1], [0, -45]);
const pitch = interpolate(progress, [0, 1], [0, 60]);
map.jumpTo({
center: [lng, lat],
zoom,
bearing,
pitch,
});
}, [map, frame, durationInFrames]);
return null;
};使用 @turf/turf 進行曲線路徑插值
對於彎曲的路線,使用 @turf/turf 進行更精確的插值:
import * as turf from "@turf/turf";
import { useCurrentFrame, useVideoConfig, interpolate } from "remotion";
// 複雜的路線座標
const routeCoordinates = [
[6.5615, 46.0598],
[6.6200, 46.0100],
[6.7500, 45.9800],
[6.8965, 45.9237],
];
export const useCurvedRoute = () => {
const frame = useCurrentFrame();
const { durationInFrames } = useVideoConfig();
const progress = interpolate(
frame,
[0, durationInFrames - 1],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
// 建立路線的 GeoJSON LineString
const route = turf.lineString(routeCoordinates);
// 計算路線總長度
const totalLength = turf.length(route, { units: "kilometers" });
// 計算當前位置
const currentLength = totalLength * progress;
const currentPoint = turf.along(route, currentLength, { units: "kilometers" });
// 建立已行進的路段
const traveledRoute = turf.lineSliceAlong(route, 0, currentLength, {
units: "kilometers",
});
return {
currentPoint: currentPoint.geometry.coordinates,
traveledRoute,
progress,
};
};完整範例
import { useEffect, useMemo, useRef, useState } from "react";
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
useDelayRender,
interpolate,
} from "remotion";
import mapboxgl from "mapbox-gl";
import "mapbox-gl/dist/mapbox-gl.css";
mapboxgl.accessToken = process.env.REMOTION_MAPBOX_TOKEN as string;
const START: [number, number] = [2.3522, 48.8566]; // 巴黎
const END: [number, number] = [2.2945, 48.8738]; // 艾菲爾鐵塔附近
export const MapRoute = () => {
const ref = useRef<HTMLDivElement>(null);
const frame = useCurrentFrame();
const { width, height, durationInFrames } = useVideoConfig();
const { delayRender, continueRender } = useDelayRender();
const [handle] = useState(() => delayRender("Loading map..."));
const [map, setMap] = useState<mapboxgl.Map | null>(null);
const progress = interpolate(frame, [0, durationInFrames - 1], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
// 初始化地圖
useEffect(() => {
if (!ref.current) return;
const _map = new mapboxgl.Map({
container: ref.current,
style: "mapbox://styles/mapbox/standard",
center: START,
zoom: 13,
interactive: false,
fadeDuration: 0,
});
_map.on("load", () => {
// 加入路線來源
_map.addSource("route", {
type: "geojson",
data: {
type: "Feature",
properties: {},
geometry: {
type: "LineString",
coordinates: [START, END],
},
},
});
// 加入路線圖層
_map.addLayer({
id: "route",
type: "line",
source: "route",
layout: { "line-cap": "round", "line-join": "round" },
paint: { "line-color": "#ff4444", "line-width": 4 },
});
continueRender(handle);
setMap(_map);
});
}, [handle, continueRender]);
// 動畫更新
useEffect(() => {
if (!map) return;
const currentLng = START[0] + (END[0] - START[0]) * progress;
const currentLat = START[1] + (END[1] - START[1]) * progress;
const source = map.getSource("route") as mapboxgl.GeoJSONSource;
source?.setData({
type: "Feature",
properties: {},
geometry: {
type: "LineString",
coordinates: [START, [currentLng, currentLat]],
},
});
}, [map, progress]);
const containerStyle = useMemo(
() => ({ width, height, position: "absolute" as const }),
[width, height]
);
return <AbsoluteFill ref={ref} style={containerStyle} />;
};注意事項
- 必須設定
interactive: false,否則 Remotion 無法控制地圖狀態 - 必須設定
fadeDuration: 0,確保地圖在渲染時完全載入 - 使用
useDelayRender()確保地圖完全載入後才開始渲染幀 - Mapbox 地圖的 CSS 需要引入:
import "mapbox-gl/dist/mapbox-gl.css"