Elevation Service Use Case: Hiking
Our Elevation Service provides elevation data above the sea level for any location on Earth’s surface. This is useful for hiking, biking or motorcycling applications. In this article we display a hike on a map and then plot a chart of its height.
Our Elevation Service provides elevation data above the sea level for any location on Earth’s surface. This is useful for hiking, biking or motorcycling applications.
Typical usages are plotting a chart of the height increase and decrease along a path or computing the height difference.
I propose you to discover this service through an integration example by rendering a hike on a map and then by plotting the elevation chart along its path.
First, we need to choose a hike and gets its track data. Let's go to the Vercors Massif in France to climb to the top of the Grand Veymont.
The track is available here as a GeoJSON file.
Display the track on a map
Let's see what the track looks like on a map.
To create our vector map to display the track we are going to use mapbox-gl-js.
- Initialize the map with a style. A good choice of style for highlighting a hike is to use jawg-terrain thanks to its relief rendering.
var map = new mapboxgl.Map({
container: 'map',
style: 'https://api.jawg.io/styles/jawg-terrain.json?access-token=<your-access-token>',
center: [5.47944, 44.8555],
zoom: 12
});
- Once the style loaded, add a new source to the map with our track data then add the track style layers just before the labels.
map.on('load', () => {
map.addSource("track", {
type: "geojson",
data: "grand-veymont.geojson"
});
map.addLayer({
"id": "track-case",
"type": "line",
"source": "track",
"layout": {
"line-join": "round",
"line-cap": "round"
},
"paint": {
"line-gap-width": 4,
"line-color": "#1FCCB8",
"line-width": 1
}
}, 'poi-label'); // Add the layer before the layer 'poi-label'
map.addLayer({
"id": "track",
"type": "line",
"source": "track",
"layout": {
"line-join": "round",
"line-cap": "round"
},
"paint": {
"line-color": "#BFF1ED",
"line-width": 4
}
}, 'poi-label'); // Add the layer before the layer 'poi-label'
});
Here the result:
Plot the elevation chart
In order to plot the elevation chart we need the elevation value at each point of the track. To get those elevation values we will request the Elevation Service.
The Elevation Service
The Elevation Service can compute the elevation for a set of locations with the locations
query parameter. The locations coordinates can be expressed as a pair of latitude,longitude and separated by a pipe character '|'.
43.296346,5.369889|43.604482,1.443962|45.759723,4.842223
The following url-encoded request will compute the elevation at Marseille, Toulouse and Lyon.
https://api.jawg.io/elevations?locations=43.296346,5.369889%7C43.604482,1.443962%7C45.759723,4.842223&access-token=<your-access-token>
The Elevation Service responds with the following:
[
{
"elevation": 0.0,
"location": {
"lat": 43.296346,
"lng": 5.369889
},
"resolution": 445.05704286596927
},
{
"elevation": 149.60059,
"location": {
"lat": 43.604482,
"lng": 1.443962
},
"resolution": 442.7953748275146
},
{
"elevation": 178.5,
"location": {
"lat": 45.759723,
"lng": 4.842223
},
"resolution": 426.621899544754
}
]
Making the request with our track locations
Great, it seems that all we have to do is:
- Get the coordinates from our GeoJSON
- For each coordinates, swap the elements. GeoJSON coordinates are stored as [longitude, latitude] whereas our Elevation Service locations are (latitude, longitude)
- Join them with a pipe character '|'
- Make the request to the Elevation Service with your access-token.
fetch('grand-veymont.geojson') // 1. Get the coordinates from our GeoJSON
.then(response => response.json())
.then(track => {
var locations = track.coordinates
.map(c => [c[1], c[0]]) // 2. Swap longitude and latitude
.join('|'); // 3. Join with a pipe '|'
var url = 'https://api.jawg.io/elevations?locations=' + locations + '&access-token=<access-token>';
return fetch(encodeURI(url)); // Encode the url
})
.then(response => response.json())
.then(elevations => console.log(elevations));
If you do that, you'll notice that the request URI will be very long (>50,000 characters) and will, for sure, exceed the maximum URI length that your browser or the server can support.
To be safe we should make sure the URI length is less than 2,000 characters.
The "pipe" encoding, i.e. lat1,lng1|lat2,lng2
is easily readable and well suitable for requesting small location sets, but tends to be very verbose.
When dealing with large location sets, we should use the second format supported by the Elevation Service: the Google polyline format which can represent a set of locations in a much more compact way.
For example the 3 locations
43.296346,5.369889|43.604482,1.443962|45.759723,4.842223
are encoded as eiggGyxw_@yd{@`x}Vg}cLcvvS
We will use this code to encode our locations to the polyline format.
Now we encode our locations and perform the request:
fetch('grand-veymont.geojson') // 1. Get the coordinates from our GeoJSON
.then(response => response.json())
.then(track => {
var locations = track.coordinates;
var polyline = encode(locations); // 2. encode as polyline
var url = 'https://api.jawg.io/elevations?locations=' + polyline + '&access-token=<access-token>';
return fetch(encodeURI(url)); // Encode the url
})
.then(response => response.json())
.then(elevations => console.log(elevations));
Although the polyline format made the URI length more than 10 times smaller, it still has around 4,000 characters and if you try to perform the request, you'll end up with the same error we had previously.
Thankfully the Elevation Service supports sending the locations in a POST body:
fetch('grand-veymont.geojson') // 1. Get the coordinates from our GeoJSON
.then(response => response.json())
.then(track => {
var locations = track.coordinates
var polyline = encode(locations); // 2. encode as polyline
var url = 'https://api.jawg.io/elevations?locations=' + polyline + '&access-token=<access-token>';
return fetch(encodeURI(url), { // Encode the url
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ locations: polyline })
});
})
.then(response => response.json())
.then(elevations => console.log(elevations));
Here the URI length is just fine and the request is sent. However the server responds with an error:
HTTP/1.1 400 Bad Request
Locations size must be <= 500
Indeed, the maximum number of locations per request is 500. Since our track has 2017 locations, we'll have to perform multiple requests and then concatenate the results:
fetch('grand-veymont.geojson') // 1. Get the coordinates from our GeoJSON
.then(response => response.json())
.then(track => {
var locations = track.coordinates;
var size = 500;
var chunks = [];
for (i = 0; i < locations.length; i += size) {
chunks.push(locations.slice(i, i + size));
}
var requests = chunks
.map(locations => encode(locations))
.map(polyline => {
var url = 'https://api.jawg.io/elevations/locations?access-token=<access-tokn>';
return fetch(encodeURI(url), { // Encode the url
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
locations: polyline
})
})
.then(response => response.json());
});
return Promise.all(requests);
})
.then(elevations => console.log(elevations.flat()));
Plotting the chart with D3.js
Now that we have our elevation values, we will use D3.js to draw the elevation chart.
The drawing process is the following:
- The y-axis will be the elevation values
- The x-axis will be the indices
- Draw the area and the outline
- Draw the min and max elevation values
- Draw circles at the start and the end of the track
- Add interaction to show the elevation at the mouse position
function drawElevationChart(elevations) {
// set the dimensions and margins of the graph
var margin = {
top: 30,
right: 100,
bottom: 10,
left: 50
};
var viewBoxWidth = 600;
var viewBoxHeight = 200;
var width = viewBoxWidth - margin.left - margin.right;
var height = viewBoxHeight - margin.top - margin.bottom;
var svg = d3.select("#elevation-chart")
.append("svg")
.attr("viewBox", "0 0 " + viewBoxWidth + " " + viewBoxHeight)
.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
var data = elevations.map(e => e.elevation);
var min = d3.min(data);
var minIndex = data.indexOf(min);
var max = d3.max(data);
var maxIndex = data.indexOf(max);
// Add X axis -> the indices
var x = d3.scaleLinear()
.domain([0, data.length - 1])
.range([0, width]);
// Add Y axis -> the elevations
var y = d3.scaleLinear()
.domain([1000, max])
.range([height, 0]);
// Add the area
svg.append("path")
.datum(data)
.attr("fill", "#BFF1ED")
.attr("d", d3.area()
.x((d, i) => x(i))
.y0(y(1000))
.y1(d => y(d))
);
// Add the outline
svg.append("path")
.datum(data)
.attr("fill", "none")
.attr("stroke", "#1FCCB8")
.attr("stroke-width", 2.0)
.attr("d", d3.line()
.x((d, i) => x(i))
.y(d => y(d))
);
// Add the min line
// Add the stroke line
svg.append("line")
.attr("x1", x(data.length - 1) + 30)
.attr("y1", y(min))
.attr("x2", x(minIndex))
.attr("y2", y(min))
.attr("fill", "none")
.attr("stroke", "#9DB8C9")
.attr("stroke-width", 2.0)
.attr("stroke-dasharray", "6 1");
// Add the text
svg.append("text")
.attr("x", x(data.length - 1) + 35)
.attr("y", y(min))
.attr("text-anchor", "start")
.attr("alignment-baseline", "middle")
.attr("font-size", "0.8em")
.text(Math.round(min) + "m")
.style("fill", "#386E90")
// Add the max line
// Add the stroke line
svg.append("line")
.attr("x1", x(data.length - 1) + 30)
.attr("y1", y(max))
.attr("x2", x(maxIndex))
.attr("y2", y(max))
.attr("fill", "none")
.attr("stroke", "#9DB8C9")
.attr("stroke-width", 2.0)
.attr("stroke-dasharray", "6 1");
// Add the text
svg.append("text")
.attr("x", x(data.length - 1) + 35)
.attr("y", y(max))
.attr("text-anchor", "start")
.attr("alignment-baseline", "middle")
.attr("font-size", "0.8em")
.text(Math.round(max) + "m")
.style("fill", "#386E90")
// Add the start and end circles
svg.append("circle")
.attr("cx", x(0))
.attr("cy", y(data[0]))
.attr("r", 4)
.style("fill", "white")
.style("stroke-width", 2.0)
.style("stroke", "#1FCCB8");
svg.append("circle")
.attr("cx", x(data.length - 1))
.attr("cy", y(data[data.length - 1]))
.attr("r", 4)
.style("fill", "white")
.style("stroke-width", 2.0)
.style("stroke", "#1FCCB8");
// Create the circle that travels along the curve of chart
var focus = svg
.append('g')
.append('circle')
.attr("r", 4)
.style("fill", "white")
.style("stroke-width", 2.0)
.style("stroke", "#1FCCB8")
.style("opacity", 0);
// Create the text that travels along the curve of chart
var focusText = svg
.append('g')
.append('text')
.style("opacity", 0)
.attr("text-anchor", "middle")
.attr("alignment-baseline", "middle")
.attr("font-size", "0.8em")
.style("fill", "#386E90")
// Create a rect on top of the svg area: this rectangle recovers mouse position
svg
.append('rect')
.style("fill", "none")
.style("pointer-events", "all")
.attr('width', width)
.attr('height', height)
.on('mouseover', mouseover)
.on('mousemove', mousemove)
.on('mouseout', mouseout);
function mouseover() {
focus.style("opacity", 1)
focusText.style("opacity", 1)
}
function mousemove() {
// recover coordinate we need
var x0 = x.invert(d3.mouse(this)[0]);
var i = Math.floor(x0);
var elevation = data[i];
focus
.attr("cx", x(i))
.attr("cy", y(elevation));
focusText
.text(Math.round(elevation) + "m")
.attr("x", x(i))
.attr("y", y(elevation) - 10);
}
function mouseout() {
focus.style("opacity", 0);
focusText.style("opacity", 0);
}
}
You can find the full example here. Don't forget to use your own access-token.
To learn more about the Elevation Service API you can read the documentation.