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,
});
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 });
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.
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,
},
});
}
This is enough to display something like this:
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"
],
...
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();
}
})
})
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);
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);
});
});
})
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);
}
}
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:
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:
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
},
});
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 (1)
Great work!
I have one question about 0-elevation. If the "0" cooresponds to the ground-elevation, do we have to use (lat, lng, MSL-ground_elevation) to put anything in the correct MSL (mean sea-level) altitude?