Animate Point Along an Arc in MapBox

Animate Point Along an Arc in MapBox

·

6 min read

Have you ever wanted to animate a point along an arc in MapBox? In this blog post, I'll show you how to do just that! I'll also provide code examples to get you started. So, whether you're a developer or a student, read on!

This project is inspired by a case study made by Fantasy.co. You can still find the project description at the Fantasy.co website.

flight-interesting-ux.png

The Packages Used For This Project

  • Mapbox GL JS. Mapbox GL JS is a JavaScript library for vector maps on the Web.
  • turf. Turf is an advanced geospatial javascript tool that is simple to use and runs very fast.

File setup

For this simple project, we only need to have three files. An index.html, a style.css and a map.js. The content of index.html and style.css are detailed here. I will talk about the content of map.js in more detail later.

index.html

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width">
  <title>Animated </title>
  <link href="https://api.mapbox.com/mapbox-gl-js/v2.10.0/mapbox-gl.css" rel="stylesheet">
  <script src="https://api.mapbox.com/mapbox-gl-js/v2.10.0/mapbox-gl.js"></script>
  <script src="https://unpkg.com/@turf/turf@6/turf.min.js"></script>
  <link rel="stylesheet" href="style.css">
</head>
<body>
    <div id='map'></div>        
    <script src="map.js"></script>
</body>
</html>

style.css

#map {
    width: 600px;
    height: 600px;
}

Now that we have the index.html and style.css let's move on to the juicy stuff.

Making Semi-Circle Path

First, let's make the semi-circle path. There are many ways to make a path between two points. But, in this article, I created a number of new coordinates between the start and end points. Creating more points is also helpful since we will animate a marker using these new coordinates later. More points will generate a smoother animation.

The idea of the process is as follows:

  1. Get the midpoint between origin and destination. Also, get the bearing and radius of the semi-circle.
  2. Translate that midpoint based on the bearing (modified by some angle) and the radius.
  3. Repeat this process multiple times. In this article, I make 1000 additional coordinates between the origin and destination. More coordinates mean smoother lines and smoother animation.

By the end of this process, we will get a list of new coordinates.

How to make the arc

And the function to generate this is as follows.

// Generate arc from start point to the end point.
// The segments parameter tell us how many
// new point should we generate.
function generateArc(start, end, segments) {
    // Get the mid point between start and end
    let midPoint = turf.midpoint(start, end);

    // Get the bearing 
    let bearing = turf.bearing(end, start);

    // Get half of the distance, because we 
    // start from the midpoint.
    let distance = turf.distance(start, end) / 2;

    // Add the start coordinate
    let arc = [start.geometry.coordinates];

    // We devide 180 angle by segments, and for each angle
    // we transform the mid point by distance and an angle.
    for (let angle = 0; angle < 180; angle += (180/ (segments))) {
        let rotatedPoint = turf.transformTranslate(midPoint,
                                                   distance,
                                                   bearing - angle);
        arc.push(rotatedPoint.geometry.coordinates);
    }

    // Add the last coordinate
    arc.push(end.geometry.coordinates);

    return arc
}

Semi Circle

Animate a Marker

Once we created a bunch of new coordinates. We can start moving a point or a marker between these coordinates.

So for every coordinates pair we created from the previous step, we will:

  1. rotate our marker using turf.bearing .
  2. move the marker using mapbox.getSource().setData()

In the reference, the animation slows down halfway. So I tried using some ease-out functions from easings.net, but the result was not quite what I wanted. So I decided to simply slow down the animation when the marker reached halfway.

function animate(map, counter, route, point, source_id, segments) {

    const start = 
        route.features[0].geometry.coordinates[
            counter >= segments ? counter - 1 : counter
        ];
    const end =
        route.features[0].geometry.coordinates[
            counter >= segments ? counter : counter + 1
        ];

    if (!start || !end) return;

    // Update point geometry to a new position 
    // based on counter denoting the index to access the arc.
    point.features[0].geometry.coordinates =
        route.features[0].geometry.coordinates[counter];

    // Calculate the bearing to ensure the icon is 
    // rotated to match the route arc.
    point.features[0].properties.bearing = turf.bearing(
        turf.point(start),
        turf.point(end)
    );

    // Update the source with this new data
    map.getSource('point-' + source_id).setData(point);

    // Request the next frame of animation 
    if (counter < (segments/2)) {
        // Before halfway, just run the animation 
        // as normal.
        requestAnimationFrame(function() {
            animate(map, counter, route, point, source_id, segments);
        });
    } else {
        // Slow down the animation after half way.
        let fps = 20
        setTimeout(() => {
            requestAnimationFrame(function() {
                animate(map, counter, route, point, source_id, segments);
            });
          }, 1000 / fps);
    }
    counter += 1;
}

Zoom Level

Another thing I like to do is to set a proper zoom level. For this, I utilize the fitBounds functionality. To correctly calculate the bounding box, I should probably add the rotated midpoint. But here, I only use the original city coordinates to get the bounding box. And added some padding when I invoked the fitBounds().

let bbox = turf.bbox(turf.points(cityCoordinates));
map.fitBounds(bbox, {
    linear: true,
    easing: function(x){
        return x * x * x;
    },
    padding: {top: 100, bottom:100, left: 100, right: 300}
});

All together now

With all of the above, we can write our map.js as follow.

map.js

// Set origin, destination, and route
//

// Cities
const cities = {
    "JKT": [106.8227, -6.208763],
    "SG": [103.8198, 1.3521],
    "KL": [101.693207, 3.140853]
}

var cityCoordinates = Object.keys(cities).map(function(key){
    return cities[key];
});

// Route
const routes = [
    [cities["JKT"], cities["SG"]],
    [cities["SG"], cities["KL"]],
];

// Map setup
//
mapboxgl.accessToken = '<USE YOUR TOKEN>';

// Get the center coordinate based on the city coordinates
let center = turf.center(
                turf.points(cityCoordinates)
             ).geometry.coordinates;

// Setup map
var map = new mapboxgl.Map({
    container: 'map',
    style: 'mapbox://styles/mapbox/dark-v10',
    center: center,
    zoom: 5
});

// Add navigation control
map.addControl(new mapboxgl.NavigationControl());

// Load the map
//
map.on('load', () => {

    // Set zoom level.
    let bbox = turf.bbox(turf.points(cityCoordinates));
    map.fitBounds(bbox, {
        linear: true,
        easing: function(x){
            return x * x * x;
        },
        padding: {top: 100, bottom:100, left: 100, right: 300}
    });

    // Number of segment to use in the arc.
    // More segment means a smoothe arc.
    const segments = 1000;

    for (let i = 0; i < routes.length; i++) {
        // Route
        //
        let route = routes[i];

        // Create the line segments based on the route
        let arc = generateArc(turf.point(route[0]), 
                              turf.point(route[1]),
                              segments);

        // Turn the object into feature collection
        let mapRoute = turf.featureCollection([turf.lineString(arc)]);

        // Put the information on to the map
        map.addSource("route-" + i, {
            "type": "geojson",
            "data": mapRoute
        });

        map.addLayer({
            "id": "route-layer-" + i,
            "type": "line",
            "source": "route-" + i,
            "layout": {
                "line-join": "round",
                "line-cap": "round"
            },
            "paint": {
            "line-color": "#fff",
            "line-width": 1
            }
        });

        // Point
        //

        let mapPoint = turf.featureCollection([turf.point(routes[i][0])]);
        map.addSource('point-' + i, {
            'type': 'geojson',
            'data': mapPoint
        });

        map.addLayer({
            'id': 'point-layer-' + i,
            'source': 'point-' + i,
            'type': 'symbol',
            'layout': {
                'icon-image': 'airport-15',
                'icon-size': 1,
                'icon-rotate': ['get', 'bearing'],
                'icon-rotation-alignment': 'map',
                'icon-allow-overlap': true,
                'icon-ignore-placement': true
            }
        });

        // Animate
        animate(map, 0, mapRoute, mapPoint, i, segments);
    }
});

function generateArc(start, end, segments) {
    // See the above
    // ...
}

function animate(map, counter, route, point, source_id, segments) {
    // See the above
    // ...
}

And when you open the index.html in your browser, you will get the following.

demo.gif

Conclusion

In this article, I show how to create a semi-circle arc between two points using MapBox. I also demonstrated how you could move a point or a marker along these routes. I hope you can learn something from this. Thank you for reading!