Animate routes with a dynamic camera

Animate multidimensional routes with a dynamic camera that follows the journey in real-time. Whether it’s a high-altitude flight or a ground-level drive, provide clear spatial and temporal context by syncing 3D transport models with precise path data.

This demonstration showcases how to animate multidimensional routes in a map.

NPM module setup


npm install --save @maptiler/sdk @maptiler/3d @turf/turf
import { Map, MapStyle, config, AnimatedRouteLayer, MaptilerAnimation, LngLat, Marker } from '@maptiler/sdk';
import '@maptiler/sdk/dist/maptiler-sdk.css';
import { Layer3D, AltitudeReference } from '@maptiler/3d';
import * as turf from '@turf/turf';

const animationDuration = 5000; // 20 seconds
const atAirportZoom = 7;
const midAirZoom = 4;

const LISBOA = [-9.166407980002864, 38.70110735141954];
const MADRID = [-3.7001908499172367, 40.41629525891401];
const RIO_DE_JANEIRO = [-43.2096, -22.9133];

config.apiKey = 'YOUR_MAPTILER_API_KEY_HERE';

const KEYFRAMES = [
  {
    delta: 0,
    easing: "QuadraticInOut",
    props: {
      lng: MADRID[0],
      lat: MADRID[1],
    },
    userData: {
      leg: 1,
    },
  },
  {
    delta: 1,
    easing: "QuadraticInOut",
    props: {
      lng: RIO_DE_JANEIRO[0],
      lat: RIO_DE_JANEIRO[1],
    },
    userData: {
      leg: 3,
    },
  },
];

const map = new Map({
  container: 'map',
  style: MapStyle.HYBRID,
  center: MADRID,
  maxPitch: 85,
  halo: true,
  space: {
    color: "#000011",
  },
  terrainControl: true,
  maptilerLogo: true,
  projectionControl: true,
  zoom: 7,
  pitch: 60,
  projection: "globe"
});

// Waiting for the map to be ready
map.on('ready', async () => {

  // Add 3D layer for the plane
  //#region Plane 3D Model
  const layer3D = new Layer3D("3d-layer");

  map.addLayer(layer3D);

  const plane = await layer3D.addMeshFromURL("plane", "https://docs-media.maptiler.com/docs/models/plane_a340.glb", {
    lngLat: MADRID,
    altitude: 0,
    scale: 1,
    altitudeReference: AltitudeReference.GROUND,
    transform: {
      rotation: {
        y: Math.PI / 2,
      },
    },
  });

  plane.setHeading(
    turf.bearing([MADRID[0], MADRID[1]], [RIO_DE_JANEIRO[0], RIO_DE_JANEIRO[1]])
  );

  //#region Boat 3D Model
  const boat = await layer3D.addMeshFromURL("boat", "https://docs-media.maptiler.com/docs/models/boat/scene.gltf", {
    lngLat: RIO_DE_JANEIRO,
    altitude: 0,
    scale: 1,
    altitudeReference: AltitudeReference.MEAN_SEA_LEVEL,
    transform: {
      rotation: {
        y: Math.PI / 2,
      },
    },
  });

  boat.setHeading(
    turf.bearing([MADRID[0], MADRID[1]], [RIO_DE_JANEIRO[0], RIO_DE_JANEIRO[1]])
  );

  //#region Car 3D Model
  const car = await layer3D.addMeshFromURL("car", "https://docs-media.maptiler.com/docs/models/car/scene.gltf", {
    lngLat: LISBOA,
    altitude: 0,
    altitudeReference: AltitudeReference.GROUND,
    scale: 1000,
  });

  car.setHeading(turf.bearing([LISBOA[0], LISBOA[1]], [MADRID[0], MADRID[1]]));
  // Add markers for the airports
  //#region Markers
  const lisboaMarker = new Marker({
    element: document.getElementById("lisboa-marker"),
    subpixelPositioning: true,
  });

  lisboaMarker.setLngLat(LISBOA);
  lisboaMarker.addTo(map);

  const rioMarker = new Marker({
    element: document.getElementById("rio-marker"),
    subpixelPositioning: true,
  });

  rioMarker.setLngLat(RIO_DE_JANEIRO);
  rioMarker.addTo(map);

  const madridMarker = new Marker({
    element: document.getElementById("madrid-marker"),
    subpixelPositioning: true,
  });

  madridMarker.setLngLat(MADRID);
  madridMarker.addTo(map);

  //#region Plane Animation
  const planeAnimation = new MaptilerAnimation({
    iterations: 1,
    keyframes: KEYFRAMES,
    duration: animationDuration,
  });

  //#region fetch data and add layers
  const boatJourneyData = await fetchGeoJSONAndAddAsLayer(
    "boat-route",
    "../data/boat.geojson",
    map
  );
  const carJourneyData = await fetchGeoJSONAndAddAsLayer(
    "car-route",
    "../data/car.geojson",
    map
  );

  //#region Boat and Car AnimatedRouteLayerSetup
  const boatAnimatedRouteLayer = new AnimatedRouteLayer({
    source: {
      id: "boat-route",
      layerID: "boat-route-layer",
    },
    pathStrokeAnimation: {
      activeColor: [0, 255, 255, 0.5],
      inactiveColor: [0, 0, 0, 0],
    },
    cameraAnimation: {
      pathSmoothing: {
        resolution: 1,
        epsilon: 3,
      },
    },
    duration: animationDuration * 4,
    iterations: 1,
    delay: 1000,
  });

  const carAnimatedRouteLayer = new AnimatedRouteLayer({
    source: {
      id: "car-route",
      layerID: "car-route-layer",
    },
    pathStrokeAnimation: {
      activeColor: [0, 255, 255, 0.5],
      inactiveColor: [0, 0, 0, 0],
    },
    cameraAnimation: {
      pathSmoothing: {
        resolution: 0.1,
        epsilon: 5,
      },
    },
    iterations: 1,
    duration: animationDuration * 2,
    delay: 1000,
  });

  let currentAnimation =
    planeAnimation;
  //#region "chaining" of animations
  planeAnimation.addEventListener("animationend", (e) => {
    planeAnimation.pause();
    map.addLayer(boatAnimatedRouteLayer);
    boatAnimatedRouteLayer.play();
    currentAnimation = boatAnimatedRouteLayer;
  });

  boatAnimatedRouteLayer.addEventListener("animationend", (e) => {
    map.removeLayer(boatAnimatedRouteLayer.id);
    boatAnimatedRouteLayer.pause();
    boatAnimatedRouteLayer.animationInstance?.reset();
    map.addLayer(carAnimatedRouteLayer);
    carAnimatedRouteLayer.play();
    currentAnimation = carAnimatedRouteLayer;
  });

  carAnimatedRouteLayer.addEventListener("animationend", (e) => {
    carAnimatedRouteLayer.pause();
    carAnimatedRouteLayer.animationInstance?.reset();
    map.removeLayer(carAnimatedRouteLayer.id);
    planeAnimation.play();
    currentAnimation = planeAnimation;
  });

  carAnimatedRouteLayer.addEventListener("timeupdate", frameCallback);
  boatAnimatedRouteLayer.addEventListener("timeupdate", frameCallback);
  planeAnimation.addEventListener("timeupdate", frameCallback);

  const carJourneyLength = turf.length(carJourneyData, { units: "kilometers" });
  const boatJourneyLength = turf.length(boatJourneyData, {
    units: "kilometers",
  });

  //#region Frame
  function frameCallback(e) {
    const isBoatAnimation =
      e.target === boatAnimatedRouteLayer.animationInstance;
    const isCarAnimation = e.target === carAnimatedRouteLayer.animationInstance;
    const isPlaneAnimation = e.target === planeAnimation;

    map.setCenter(new LngLat(e.props.lng, e.props.lat));

    const previousLngLat = [
      e.previousProps.lng,
      e.previousProps.lat,
    ];
    const nextLngLat = [e.props.lng, e.props.lat];

    const heading = turf.bearing(previousLngLat, nextLngLat);

    if (isPlaneAnimation) {
      const previousStop = [
        e.keyframe?.props.lng,
        e.keyframe?.props.lat,
      ];
      const nextStop = [
        e.nextKeyframe?.props.lng,
        e.nextKeyframe?.props.lat,
      ];

      const legDistance = turf.rhumbDistance(previousStop, nextStop);

      const currentPosition = [e.props.lng, e.props.lat];

      const percentLeftToNextStop =
        turf.rhumbDistance(currentPosition, nextStop) / legDistance;

      const scale = Math.sin(percentLeftToNextStop * Math.PI) * 500;

      const zoomAlpha = Math.sin(percentLeftToNextStop * Math.PI);
      const zoom = lerp(atAirportZoom, midAirZoom, zoomAlpha);

      map.setZoom(zoom || map.getZoom());

      map.setBearing((1 - e.currentDelta) * 360);

      plane.modify({
        heading,
        lngLat: currentPosition,
        scale,
        altitude: 1000 * scale,
      });
    }

    if (isBoatAnimation) {
      map.setCenter(new LngLat(e.props.lng, e.props.lat));
      const boatPosition = turf.along(
        boatJourneyData.features[0],
        boatJourneyLength * e.currentDelta,
        { units: "kilometers" }
      );
      const nextBoatPosition = turf.along(
        boatJourneyData.features[0],
        boatJourneyLength * (e.currentDelta + 0.005),
        { units: "kilometers" }
      );

      const boatHeading =
        turf.bearing(
          [
            boatPosition.geometry.coordinates[0],
            boatPosition.geometry.coordinates[1],
          ],
          [
            nextBoatPosition.geometry.coordinates[0],
            nextBoatPosition.geometry.coordinates[1],
          ]
        ) + 180;

      const scale = Math.sin(e.currentDelta * Math.PI) * 1000;

      boat.modify({
        heading: boatHeading,
        lngLat: [
          boatPosition.geometry.coordinates[0],
          boatPosition.geometry.coordinates[1],
        ],
        scale,
      });
    }

    if (isCarAnimation) {
      map.setCenter(new LngLat(e.props.lng, e.props.lat));

      const carPosition = turf.along(
        carJourneyData.features[0].geometry,
        carJourneyLength * e.currentDelta,
        { units: "kilometers" }
      );
      const nextCarPosition = turf.along(
        carJourneyData.features[0].geometry,
        carJourneyLength * (e.currentDelta + 0.01),
        { units: "kilometers" }
      );

      const carHeading =
        turf.bearing(
          [
            carPosition.geometry.coordinates[0],
            carPosition.geometry.coordinates[1],
          ],
          [
            nextCarPosition.geometry.coordinates[0],
            nextCarPosition.geometry.coordinates[1],
          ]
        ) + 180;

      const scale = Math.sin(e.currentDelta * Math.PI) * 1000;

      car.modify({
        heading: carHeading, // because the car model is 180
        lngLat: [
          carPosition.geometry.coordinates[0],
          carPosition.geometry.coordinates[1],
        ],
        scale,
      });
    }
  }

  let isPlaying = false;
  document.getElementById("play-button")?.addEventListener("click", () => {
    if (isPlaying) {
      currentAnimation?.pause();
      isPlaying = false;
    } else {
      currentAnimation?.play();
      isPlaying = true;
    }
  });

});

//#region Helper Functions / types
function lerp(a, b, t) {
  return a + (b - a) * t;
}

async function fetchGeoJSONAndAddAsLayer(id, url, map) {
  try {
    const data = await fetch(url).then((response) => response.json());
    addSourceAndLayer(map, id, {
      type: "geojson",
      data,
      lineMetrics: true,
    });
    return data;
  } catch (error) {
    throw error;
  }
}

function addSourceAndLayer(
  map,
  id,
  source
) {
  map.addSource(id, source);
  map.addLayer(
    {
      source: id,
      id: `${id}-layer`,
      type: "line",
      layout: {
        visibility: "visible",
        "line-sort-key": 1,
      },
      paint: {
        "line-color": "transparent",
        "line-width": 10,
      },
    },
    "3d-layer"
  );
}
Animations and Animated Routes

Animations and Animated Routes

Examples

Animates a path or route on the map based on keyframes or GeoJSON data.

Import and play GLTF animations from GLTF files

Play GLTF animations

Examples

Play animations in GLTF models simulating a plane flight.

Add events on 3D models

3D model events

Examples

Listen for mouse events on 3D models in maps.

Animate a 3D plane flight

Animate a 3D plane flight

Examples

Simulate and animate a 3D plane flight between cities.

Was this helpful?