Show Climb Statistics for a Route in Maptoolkit Maps JS
Total ascent is the number people compare when they choose between two routes, and it is the one number an elevation chart does not give you. This example takes a route, asks the Elevation API for heights along it in a single request, and derives ascent, descent, the high and low points and the steepest kilometre from the array that comes back.
const API_KEY = 'YOUR_API_KEY';
// Ignore height changes below this to keep DEM sampling noise out of the totals.
const NOISE_THRESHOLD_M = 3;
// The Elevation API takes its coordinates in the query string, and a whole route does
// not fit in one URL. Send it in batches.
const BATCH_SIZE = 150;
const map = new maptoolkit.Map({
container: 'map',
apiKey: API_KEY,
style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.hiking.json?api_key=${API_KEY}`,
center: [11.389, 47.290],
zoom: 12,
attributionControl: { compact: false }
});
map.addControl(new maptoolkit.NavigationControl(), 'top-right');
// One request per batch, results concatenated in order.
function fetchElevations(coordinates) {
const requests = [];
for (let i = 0; i < coordinates.length; i += BATCH_SIZE) {
// The Elevation API takes lat,lng. The map works in lng,lat. Swap back.
const points = coordinates.slice(i, i + BATCH_SIZE).map(([lng, lat]) => [lat, lng]);
const url = new URL('https://elevation.maptoolkit.net');
url.searchParams.set('points', JSON.stringify(points));
url.searchParams.set('api_key', API_KEY);
requests.push(fetch(url).then((r) => {
if (!r.ok) throw new Error(`Elevation API returned ${r.status}`);
return r.json();
}));
}
return Promise.all(requests).then((batches) => batches.flat());
}
// Great-circle distance in metres between two [lng, lat] pairs.
function distance(a, b) {
const R = 6371000;
const toRad = d => d * Math.PI / 180;
const dLat = toRad(b[1] - a[1]);
const dLng = toRad(b[0] - a[0]);
const h = Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(a[1])) * Math.cos(toRad(b[1])) * Math.sin(dLng / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(h));
}
function summarise(coordinates, heights) {
let ascent = 0, descent = 0, carried = 0, travelled = 0, steepest = 0;
for (let i = 1; i < heights.length; i++) {
travelled += distance(coordinates[i - 1], coordinates[i]);
carried += heights[i] - heights[i - 1];
if (Math.abs(carried) >= NOISE_THRESHOLD_M) {
carried > 0 ? ascent += carried : descent -= carried;
carried = 0;
}
const run = distance(coordinates[i - 1], coordinates[i]);
if (run > 20) {
const grade = Math.abs((heights[i] - heights[i - 1]) / run) * 100;
if (grade > steepest) steepest = grade;
}
}
return {
ascent: Math.round(ascent),
descent: Math.round(descent),
high: Math.round(Math.max(...heights)),
low: Math.round(Math.min(...heights)),
steepest: steepest.toFixed(1),
perKm: Math.round(ascent / (travelled / 1000))
};
}
function render(s) {
document.getElementById('stats').innerHTML = `
<dl>
<dt>Ascent</dt><dd>${s.ascent} m</dd>
<dt>Descent</dt><dd>${s.descent} m</dd>
<dt>Highest</dt><dd>${s.high} m</dd>
<dt>Lowest</dt><dd>${s.low} m</dd>
<dt>Steepest</dt><dd>${s.steepest} %</dd>
<dt>Ascent per km</dt><dd>${s.perKm} m</dd>
</dl>`;
}
map.on('load', () => {
const routeUrl = new URL('https://routing.maptoolkit.net/route');
routeUrl.searchParams.append('point', '47.2683,11.3857');
routeUrl.searchParams.append('point', '47.3125,11.3906');
routeUrl.searchParams.append('routeType', 'hike');
routeUrl.searchParams.append('api_key', API_KEY);
fetch(routeUrl)
.then(r => r.json())
.then(route => {
const path = route.paths[0];
const coordinates = polyline.decode(path.points).map(([lat, lng]) => [lng, lat]);
map.addLayer({
id: 'route',
type: 'line',
source: { type: 'geojson', data: { type: 'Feature', geometry: { type: 'LineString', coordinates } } },
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: { 'line-color': '#2a3561', 'line-width': 5 }
});
map.fitBounds([[path.bbox[0], path.bbox[1]], [path.bbox[2], path.bbox[3]]], { padding: 60 });
return fetchElevations(coordinates)
.then(heights => render(summarise(coordinates, heights)));
})
.catch(() => { document.getElementById('stats').textContent = 'Could not load the route.'; });
});<!DOCTYPE html>
<html lang="en">
<head>
<title>Climb Statistics - Maptoolkit Maps JS</title>
<meta property="og:description" content="Derive ascent, descent and gradient from an elevation array." />
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://unpkg.com/@maptoolkit/maps@11.0.0-beta.3/dist/maptoolkit.js"></script>
<link rel="stylesheet" href="https://unpkg.com/@maptoolkit/maps@11.0.0-beta.3/dist/maptoolkit.css" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/mapbox-polyline/1.2.1/polyline.min.js"></script>
<style>
html, body { width: 100%; height: 100%; margin: 0; padding: 0; }
#map { width: 100%; height: 100%; }
#stats {
position: absolute; top: 10px; left: 10px; z-index: 999;
background: #fff; border-radius: 6px; box-shadow: 0 0 15px #68686880;
font: 13px/1.5 system-ui, sans-serif; padding: 12px 14px; min-width: 190px;
}
#stats dl { display: grid; grid-template-columns: auto auto; gap: 2px 16px; margin: 0; }
#stats dt { color: #666; }
#stats dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; }
</style>
</head>
<body>
<div id="map"></div>
<div id="stats">Loading route...</div>
<script>
const API_KEY = 'YOUR_API_KEY';
// Ignore height changes below this to keep DEM sampling noise out of the totals.
const NOISE_THRESHOLD_M = 3;
// The Elevation API takes its coordinates in the query string, and a whole route does
// not fit in one URL. Send it in batches.
const BATCH_SIZE = 150;
const map = new maptoolkit.Map({
container: 'map',
apiKey: API_KEY,
style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.hiking.json?api_key=${API_KEY}`,
center: [11.389, 47.290],
zoom: 12,
attributionControl: { compact: false }
});
map.addControl(new maptoolkit.NavigationControl(), 'top-right');
// One request per batch, results concatenated in order.
function fetchElevations(coordinates) {
const requests = [];
for (let i = 0; i < coordinates.length; i += BATCH_SIZE) {
// The Elevation API takes lat,lng. The map works in lng,lat. Swap back.
const points = coordinates.slice(i, i + BATCH_SIZE).map(([lng, lat]) => [lat, lng]);
const url = new URL('https://elevation.maptoolkit.net');
url.searchParams.set('points', JSON.stringify(points));
url.searchParams.set('api_key', API_KEY);
requests.push(fetch(url).then((r) => {
if (!r.ok) throw new Error(`Elevation API returned ${r.status}`);
return r.json();
}));
}
return Promise.all(requests).then((batches) => batches.flat());
}
// Great-circle distance in metres between two [lng, lat] pairs.
function distance(a, b) {
const R = 6371000;
const toRad = d => d * Math.PI / 180;
const dLat = toRad(b[1] - a[1]);
const dLng = toRad(b[0] - a[0]);
const h = Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(a[1])) * Math.cos(toRad(b[1])) * Math.sin(dLng / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(h));
}
function summarise(coordinates, heights) {
let ascent = 0, descent = 0, carried = 0, travelled = 0, steepest = 0;
for (let i = 1; i < heights.length; i++) {
travelled += distance(coordinates[i - 1], coordinates[i]);
carried += heights[i] - heights[i - 1];
if (Math.abs(carried) >= NOISE_THRESHOLD_M) {
carried > 0 ? ascent += carried : descent -= carried;
carried = 0;
}
const run = distance(coordinates[i - 1], coordinates[i]);
if (run > 20) {
const grade = Math.abs((heights[i] - heights[i - 1]) / run) * 100;
if (grade > steepest) steepest = grade;
}
}
return {
ascent: Math.round(ascent),
descent: Math.round(descent),
high: Math.round(Math.max(...heights)),
low: Math.round(Math.min(...heights)),
steepest: steepest.toFixed(1),
perKm: Math.round(ascent / (travelled / 1000))
};
}
function render(s) {
document.getElementById('stats').innerHTML = `
<dl>
<dt>Ascent</dt><dd>${s.ascent} m</dd>
<dt>Descent</dt><dd>${s.descent} m</dd>
<dt>Highest</dt><dd>${s.high} m</dd>
<dt>Lowest</dt><dd>${s.low} m</dd>
<dt>Steepest</dt><dd>${s.steepest} %</dd>
<dt>Ascent per km</dt><dd>${s.perKm} m</dd>
</dl>`;
}
map.on('load', () => {
const routeUrl = new URL('https://routing.maptoolkit.net/route');
routeUrl.searchParams.append('point', '47.2683,11.3857');
routeUrl.searchParams.append('point', '47.3125,11.3906');
routeUrl.searchParams.append('routeType', 'hike');
routeUrl.searchParams.append('api_key', API_KEY);
fetch(routeUrl)
.then(r => r.json())
.then(route => {
const path = route.paths[0];
const coordinates = polyline.decode(path.points).map(([lat, lng]) => [lng, lat]);
map.addLayer({
id: 'route',
type: 'line',
source: { type: 'geojson', data: { type: 'Feature', geometry: { type: 'LineString', coordinates } } },
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: { 'line-color': '#2a3561', 'line-width': 5 }
});
map.fitBounds([[path.bbox[0], path.bbox[1]], [path.bbox[2], path.bbox[3]]], { padding: 60 });
return fetchElevations(coordinates)
.then(heights => render(summarise(coordinates, heights)));
})
.catch(() => { document.getElementById('stats').textContent = 'Could not load the route.'; });
});
</script>
</body>
</html>Use the prompt below with any LLM to get the same result. Make sure the Maptoolkit MCP server is connected first — check out AI Integration & MCP to get started.
How it works
points takes a JSON array of coordinates and the response is a flat array of metres in the
same order, so the index of a height is the index of its coordinate. No pairing step is
needed and no per-point request.
A whole route does not fit in one call. points travels in the query string, and a
decoded route is several hundred coordinates, which overruns the request line. Past roughly
5,000 characters of points the service answers 431, and further up it answers 403,
neither of which mentions length. BATCH_SIZE of 150 keeps each URL to about 3,000
characters with room to spare, and Promise.all puts the batches back together in order.
Do not reach for POST to avoid the batching. The endpoint accepts a JSON body and answers
200 with an array of the right length filled with zeros, so a route that climbs 1,500 m
reports a flat 0 m and nothing in the response says anything went wrong. This is a GET API.
The coordinates go back to lat,lng before the call. The Routing API, the Elevation API and
the decoded polyline all use lat,lng; MapLibre and GeoJSON use lng,lat. This example
converts once on the way in and once on the way out, and the comments mark both places.
NOISE_THRESHOLD_M is the part that separates a correct total from a plausible one. A DEM
samples the ground on a grid, so consecutive points on flat ground differ by a metre or two
of sampling error. Summing every positive difference turns that noise into ascent, and on a
long flat route it can invent several hundred metres of climbing that is not there. The loop
carries the running difference and only commits it once it passes the threshold, which is
how GPS software has always done it. Change the threshold and the total changes, so pick one
and keep it: two applications with different thresholds will disagree about the same route.
The steepest gradient ignores any step shorter than 20 metres for the same reason. Over a five metre step, a two metre sampling error reads as a 40 percent slope.
Math.max(...heights) is fine for a route and not for a track log. Spreading an array into
a function call fails once it is long enough, so anything over a few tens of thousands of
points needs a loop instead.
Ascent per kilometre uses the distance measured along the decoded geometry rather than
path.distance from the routing response, so the two numbers come from the same line.
Next steps
Numbers alone do not show where the climbing happens. Charting the profile under the map puts the shape next to the totals, and the two together are what most outdoor applications show above a route.
Grading the route is the usual step after that: banding ascent per kilometre into easy, moderate and hard, so a list of routes can be sorted and filtered on something a reader understands without reading six numbers. The Route Enhancement API returns elevation and surface for an existing track in one call, which is less work than this when the track is already recorded rather than calculated.