DEV Community

Cover image for Drawing 3D lines in Mapbox with the threebox plugin
Mickaël A
Mickaël A

Posted on

Drawing 3D lines in Mapbox with the threebox plugin

Recently, I encountered a use case where I needed to draw a line in 3D space over a map layout. This line represents the track of a local flight, which is far more engaging to view and explore in 3D than in 2D, as it allows for a better appreciation of the elevation changes.

I used Mapbox for my online map, given its popularity and its use in the Strava application, which I frequently use.

Mapbox offers a convenient method for displaying GeoJSON data files on a map, as detailed in this Mapbox documentation page.

Setting up the scene with Mapbox

I work within a PHP Symfony application and use the NPM package manager. To integrate Mapbox, I installed the mapbox-gl library using the command npm i mapbox-gl.

I also created a Mapbox account and token (help page here, token page here) to use in my code.

Here is how the basic map creation looks in my project:

var mapboxgl = require('mapbox-gl/dist/mapbox-gl.js');

mapboxgl.accessToken = 'pk....' // your full token here
var map = new mapboxgl.Map({
    container: 'map',  // the HTML element where the maps load
    style: 'mapbox://styles/mapbox/outdoors-v12', // the Mapbox style of the map
    center: [6.1229882, 43.24953278], // starting position [lng, lat]
    zoom: 9,
});
Enter fullscreen mode Exit fullscreen mode

For a more detailed setup of your first map in your project, you can refer to this page: Display a map on a web page.

Adding 3D terrain

Since I want to enjoy my map in 3D, adding terrain visualization is essential. This feature is not enabled by default. In Mapbox terminology, this involves using a raster-dem layer. You can find an example of this setup here.

In my context, the code looks like this:

const exaggeration=1.5;

// Add terrain source
map.addSource('mapbox-dem', {
    'type': 'raster-dem',
    'url': 'mapbox://mapbox.mapbox-terrain-dem-v1',
    'tileSize': 512,
    'maxzoom': 14
});
// add the DEM source as a terrain layer with exaggerated height
map.setTerrain({ 'source': 'mapbox-dem', 'exaggeration': exaggeration });
Enter fullscreen mode Exit fullscreen mode

As you can see, it's very close to the sample. Now, when I ctrl+click and move my mouse, I can see the elevation. The exaggeration constant helps to dramatize the terrain a bit, allowing for better appreciation of the relief.

Terrain comarison without and with raster DEM

Adding my GeoJSON track to the map

As I mentioned earlier, Mapbox provides a convenient way to add GeoJSON data to my map, which is documented here. In my case, the GeoJSON data is derived from processing a GPX file on the backend. I have my GeoJSON file stored on disk.

Here is how I load and display the GeoJSON data on the map:

map.on("load", () => {
    map.addSource("air-track", {
        type: "geojson",
        data: geojson,  // geojson contains the path to my local geojson file
    });

    map.addLayer({
        id: "air-track-line",
        type: "line",
        source: "air-track",
        paint: {
            "line-color": "#dd0000", // red
            "line-width": 4,
        },
    });
}
Enter fullscreen mode Exit fullscreen mode

This is enough to display something like this:

GeoJSON flat track in Mapbox

Make this line 3D

Here we are pushing beyond the capabilities of Mapbox. Unfortunately, Mapbox's 3D capabilities with GeoJSON have limitations. It's important to note that my GeoJSON data includes elevation information, as per standard. Here is a sample of my data:

"geometry": {
  "type": "MultiLineString",
  "coordinates": [
    [
      [
        6.12827066,  // lat
        43.24983118, // lng
        76.7122,     // elevation
        "2024-02-29T09:10:14Z"  // time (not used)
      ],
      [
        6.12817452,
        43.24956302,
        81.7742,
        "2024-02-29T09:10:20Z"
      ],
      [
        6.12814958,
        43.24928749,
        80.3541,
        "2024-02-29T09:10:29Z"
      ],

...
Enter fullscreen mode Exit fullscreen mode

In various discussions and Stack Overflow issues, some workarounds have been suggested, but they don't fully meet my requirements.

The flexibility I need can be achieved by importing a Mapbox plugin called threebox, which is a combination of three.js and Mapbox.

Adding threebox to the equation

I installed Threebox and connected it to my map:

// Creating tb related to my Mapbox map
const tb = (window.tb = new Threebox(
    map,  // my mapbox map
    map.getCanvas().getContext('webgl'),
    {
        defaultLights: true
    }
));

// On load, add a custom layer
map.on("load", () => {
    ...

    map.addLayer({
        id: 'custom_layer',
        type: 'custom',
        render: function(gl, matrix){
            tb.update();
        }
    })

})
Enter fullscreen mode Exit fullscreen mode

This is how it works: a new custom layer is added to the scene, allowing us to draw specific shapes in the 3D space of Mapbox. The Threebox plugin is featured in this Mapbox documentation page.

Initially, I was hesitant to delve into more complex tools, but after overcoming some integration challenges, using it turned out to be quite straightforward.

Drawing in 3D

To add a line to the scene, you can use the convenient tb.line() function. It supports the elevation parameter out of the box.

const line_segment = tb.line({
    geometry: [
        [lat1, lng1, elevation1],
        [lat2, lng2, elevation2],
    ],
    color: '#dd0000',
    width: 4,
    opacity: 1
});
tb.add(line_segment);
Enter fullscreen mode Exit fullscreen mode

This would create a line and add it to the scene.

Drawing the GeoJSON data

To connect Threebox with the data needed to draw, the challenge lies in retrieving GeoJSON features from the scene. While Mapbox provides a querySourceFeatures method to fetch features from a source, I didn't manage to use it properly. My array of the features was empty, no matter what. I opted for a disk load, as my data is on a file accessible from this script.

map.on('sourcedata', (e) => {
    if (e.sourceId !== "air-track" && !e.isSourceLoaded) {
        return;
    }

    const data = fetch(geojson).then(function(res) {
        const jres = res.json().then(function(res) {
            const coords = res.features[0].geometry.coordinates[0];
            draw3dLine(coords);
        });
    });
})
Enter fullscreen mode Exit fullscreen mode

I am listening for the sourcedata event. If the event's sourceId matches air-track (which is the source ID I used in map.addSource() above), I then load the GeoJSON data from the file and store the coordinates. Here's an approach to achieve this:

function draw3dLine(coords) {
    let i;
    for (i = 0; i < coords.length; i++) {
        if (i === 0) continue;

        // Draw the segment in space
        const line_segment = tb.line({
            geometry: [
                [coords[i][0], coords[i][1], coords[i][2] * exaggeration],
                [coords[i - 1][0], coords[i - 1][1], coords[i - 1][2] * exaggeration],
            ],
            color: '#dd0000',
            width: 4,
            opacity: 1
        });
        tb.add(line_segment);
    }
}
Enter fullscreen mode Exit fullscreen mode

Note that I reused the exaggeration constant here as I exaggerated the relief. Without it, the proportions would not be respected and the line could cross the terrain.

The core logic is implemented in that function. For each pair of coordinates in the source, it draws a line in space, resulting in something like this:

3D track in Mapbox with threebox

I found it a bit difficult to appreciate the track, so I added a vertical line after each 3D segment. Each of these lines has coordinates identical to the last segment point, set at elevation zero (lat, lng).

// Draw the vertical line at the end of the segment
const line_vertical = tb.line({
    geometry: [
        [coords[i - 1][0], coords[i - 1][1], coords[i - 1][2] * exaggeration],
        [coords[i - 1][0], coords[i - 1][1], coords[i - 1][0]]
    ],
    color: '#dd0000',
    width: 1,
    opacity: .5
})
tb.add(line_vertical);
Enter fullscreen mode Exit fullscreen mode

Which draws as:

Vertical lines to reflect the elevation better

Displaying a shadow

I retained the GeoJSON track feature provided by Mapbox to maintain the visible line on the ground. I simply reduced its width to 1 and changed the color to dark gray, creating the impression of a shadow.

map.addSource("air-track", {
    type: "geojson",
    data: geojson,
});

map.addLayer({
    id: "air-track-line",
    type: "line",
    source: "air-track",
    paint: {
        "line-color": "#111111", // dark gray
        "line-width": 1,         // thin width
    },
});
Enter fullscreen mode Exit fullscreen mode

Full 3D track with vertical lines and a shadow on the ground

Conclusion

It took me more time to find the right tools to solve my problem than to actually code the algorithm itself. In the context of my Symfony project, it also took some time to integrate GeoJSON, Mapbox, and threebox altogether.

I'm very enthusiastic about the result, and I'm sharing it here because there wasn't an obvious way to do so on the web.

Top comments (0)