Drawing 3D lines in Mapbox with the threebox plugin
Published: 2024-06-18 05:33:22

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](https://www.mapbox.com/) 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](https://docs.mapbox.com/mapbox-gl-js/example/geojson-line/).

## 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](https://www.npmjs.com/package/mapbox-gl) library using the command `npm i mapbox-gl`. 

I also **created a Mapbox account and token** ([help page here](https://docs.mapbox.com/help/getting-started/access-tokens/), [token page here](https://docs.mapbox.com/help/getting-started/access-tokens/)) to use in my code.

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

```javascript
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,
});
```

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](https://docs.mapbox.com/mapbox-gl-js/example/simple-map/).

## 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](https://docs.mapbox.com/mapbox-gl-js/example/add-terrain/).

In my context, the code looks like this:

```javascript
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 });
```

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/do3yonhil1xr1n2o2agb.png)

## 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](https://docs.mapbox.com/mapbox-gl-js/example/simple-map/). 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:

```javascript
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,
        },
    });
}
```

This is enough to display something like this:

![GeoJSON flat track in Mapbox](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/lvr7wpasqatzk11oxixr.png)

## 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:

```json
"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"
      ],

...
```

In various discussions and [Stack Overflow issues](https://stackoverflow.com/questions/47283304/mapbox-extruding-lines), 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](https://github.com/peterqliu/threebox), which is a combination of [three.js](https://threejs.org/) and Mapbox.

## Adding threebox to the equation

I [installed Threebox](https://www.npmjs.com/package/threebox-plugin) and connected it to my map:

```javascript
// 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();
        }
    })

})
```

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](https://docs.mapbox.com/mapbox-gl-js/example/add-3d-model-threebox/).

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()`](https://github.com/peterqliu/threebox/blob/master/docs/Threebox.md) function. It supports the elevation parameter out of the box.

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

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.

```javascript
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);
        });
    });
})
```

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:

```javascript
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);
    }
}
```

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/cxypqnk1wnnmihc21oez.gif)

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);
```

Which draws as:

![Vertical lines to reflect the elevation better](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4iyzv81bojvekvsixk4f.png)

# 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
    },
});
```

![Full 3D track with vertical lines and a shadow on the ground](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5zumik742xp5rhzpmkwj.png)

## 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.