Drawing 3D lines in Mapbox with the threebox plugin

I had this recent use case where I needed to draw a line in the space on top of a map layout. This is in fact a track for a local flight, which is much more interesting to watch and browse in 3D rather than 2D, to appreciate the elevation parameter.

I used Mapbox as my online map, as it is well known, and used for the Strava application, for which I am a frequent user.

Mapbox comes with a convenient way to display a GeoJSON data file to a map, as described in this Mapbox documentation page.

Setting up the scene with Mapbox

I work in the context of a PHP Symfony application, and I use the NPM package manager. I installed mapbox-gl thanks to the npm i mapbox-gl line. I created a Mapbox account and token (help page here, token page here) so I can use it in my code.

The basic map creation looks like this 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 check this page: Display a map on a web page.

Adding 3D terrain

As I want to enjoy my map in 3D, it makes sense to add some terrain visualisation, which is not done by default. In the Mapbox terminology, it uses a raster-dem layer, which is sampled 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 really close to the sample. Now, when I ctrl+click and move my mouse, I can see some elevation. The exaggeration constant helps to dramatise the terrain a little bit to better appreciate relief.

Adding my GeoJSON track to the map

As I said earlier, Mapbox comes with a convenient way to add GeoJSON data to my map, documented here. In my case, the GeoJSON data is in fact a processing of a GPX file done in the backend. I have my GeoJSON file stored on disk.

In my context, the code looks like this:

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:

Image description

Make this line 3D

Here we are going outside the comfort zone of Mapbox. To my disappointment, Mapbox does not go that far with 3D and GeoJSON. Note that my GeoJSON actually stores the elevation, and that is 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

On several discussion and Stack Overflow issues, some workarounds are suggested, but they are quite far from my need.

The flexibility I need here requires the import of a Mapbox plugin, inherited from the 3JS project, called Threebox; a concatenation of 3JS 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, where we can draw very specific shapes in the 3D space of Mapbox. The threebox plugin is showcased in this Mapbox documentation page.

I was a bit scared at first to dive into too much obscure tools, but after some difficulties to integrate everything, it was pretty easy to use.

Drawing in 3D

To add a line to the scene, there is the handy tb.line() function. To my surprise, 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

The final touch is to connect threebox with the data to draw. Unfortunately, I was unable to get the GeoJSON data (also called features) from the scene. There is a querySourceFeatures to get the features coming from a source, but I was unable to get any valuable data from it.

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

This listener would expect a sourcedata event, and if the id is air-track, e.g. my source ID as used in the map.addSource function, I load the GeoJSON data from my file (again), and store the coordinates in the coords function. Then I call the draw3dLine function:

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

The core logic is written in this function. For each couple of coordinates in the source, it is drawing one line in the space, which shows something like this:

Image description

I found it a little bit hard to read, that’s why I added a vertical line after each 3D segment. Each of those lines has the coordinates of the last segment point, and the same point (lat lng) at elevation zero.

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

Image description

I kept the GeoJSON track feature as given by Mapbox to keep the visible line on the surface of the terrain. I just reduced its witdh to 1 and changed the color to dark gray. It gives 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

Discover more from Coursity

Subscribe to get the latest posts sent to your email.

Leave a Comment

Your email address will not be published. Required fields are marked *

Discover more from Coursity

Subscribe now to keep reading and get access to the full archive.

Continue reading

Scroll to Top