Remotion LabRemotion Lab
視覺設計地圖動畫

地圖動畫

使用 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

  1. 建立免費的 Mapbox 帳號
  2. Mapbox Console 取得 Access Token
  3. 將 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: falsefadeDuration: 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"

參見