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"
);
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="robots" content="noindex">
<title>MapTiler 3D | Globe Travel Example</title>
<link rel="stylesheet" href="./style.css">
</head>
<body>
<div id="map"></div>
<div id="madrid-marker" class="marker"></div>
<div id="rio-marker" class="marker"></div>
<div id="lisboa-marker" class="marker"></div>
<button id="play-button">Go!</button>
<script type="module" src="./main.js"></script>
</body>
</html>
body {
margin: 0;
padding: 0;
}
#map {
position: absolute;
top: 0;
bottom: 0;
width: 100%;
}
@keyframes fade {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.marker {
background-size: cover;
width: 50px;
height: 50px;
border-radius: 50%;
border: 0.5rem solid black;
cursor: pointer;
position: absolute;
top: -100px;
left: -100px;
z-index: 1000;
animation: fade 0.5s ease-in-out reverse;
}
#play-button {
position: absolute;
top: 10px;
left: 10px;
z-index: 1000;
}
#madrid-marker::after,
#rio-marker::after,
#lisboa-marker::after {
position: absolute;
top: calc(100% - 0.5rem);
left: 50%;
transform: translateX(-50%);
font-size: 1rem;
font-weight: bold;
color: white;
text-shadow: 2px 2px 2px black, -2px -2px 2px black, 2px -2px 2px black,
-2px 2px 2px black;
}
#madrid-marker {
background-image: url("../img/madrid.jpg");
}
#madrid-marker::after {
content: "Madrid";
}
#rio-marker {
background-image: url("../img/rio.png");
}
#rio-marker::after {
content: "Rio";
}
#lisboa-marker {
background-image: url("../img/lisboa.jpg");
}
#lisboa-marker::after {
content: "Lisboa";
}
plane a340 by mamont nikita is licensed under Creative Commons Attribution ↩
Model by scailman for Low Poly Small car ↩
Model by pedrosvalero for Main Boat From Over The Seas GGJ'17 ↩
Related examples
Animations and Animated Routes
ExamplesAnimates a path or route on the map based on keyframes or GeoJSON data.