Skip to content

Draw a Route with an Elevation Profile in MapLibre GL JS

On this page

A route on its own answers how far and how long. For anything walked or ridden, the climb decides whether the route is usable at all. This example requests a hiking route from the Routing API, gets the height of every point from the Elevation API, and shows the profile with the Elevation Control, the Maptoolkit plugin for MapLibre GL JS. Hover the profile to see the position on the route, or drag across it to select a section.

import * as maplibregl from "maplibre-gl";
    import { ElevationControl } from "https://cdn.jsdelivr.net/npm/@maptoolkit/maplibre-elevation-control@1.0.0/dist/maplibre-elevation-control.js";
    import { LogoControl } from "https://cdn.jsdelivr.net/npm/@maptoolkit/maplibre-logo-control@1.1.2/dist/maplibre-logo-control.js";

    const API_KEY = 'YOUR_API_KEY';

    const map = new maplibregl.Map({
        container: 'map',
        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 maplibregl.NavigationControl(), 'top-right');

    // The route as a GeoJSON LineString in [lng, lat] order.
    async function getRoute(start, end) {
        const url = new URL('https://routing.maptoolkit.net/route');
        url.searchParams.append('point', `${start[1]},${start[0]}`);
        url.searchParams.append('point', `${end[1]},${end[0]}`);
        url.searchParams.append('routeType', 'hike');
        url.searchParams.append('points_encoded', 'false');
        url.searchParams.append('api_key', API_KEY);
        const response = await fetch(url);
        if (!response.ok) throw new Error(`Routing API returned ${response.status}`);
        return (await response.json()).paths[0];
    }

    // One height in meters per coordinate. The points go in the query string,
    // so a long route is split into batches.
    async function getElevations(coordinates) {
        const BATCH_SIZE = 150;
        const heights = [];
        for (let i = 0; i < coordinates.length; i += BATCH_SIZE) {
            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);
            const response = await fetch(url);
            if (!response.ok) throw new Error(`Elevation API returned ${response.status}`);
            heights.push(...(await response.json()));
        }
        return heights;
    }

    map.on('load', async () => {
        const path = await getRoute([11.3857, 47.2683], [11.3906, 47.3125]);
        const coordinates = path.points.coordinates;
        const heights = await getElevations(coordinates);

        // The control takes a line with a height in every coordinate: [lng, lat, ele].
        const route = {
            type: 'Feature',
            properties: {},
            geometry: {
                type: 'LineString',
                coordinates: coordinates.map(([lng, lat], i) => [lng, lat, heights[i]])
            }
        };

        // The control draws the route, the selected section and the hover point on the map.
        map.addControl(new ElevationControl(route));

        map.fitBounds([[path.bbox[0], path.bbox[1]], [path.bbox[2], path.bbox[3]]], { padding: { top: 40, right: 40, bottom: 220, left: 40 } });
    });

    map.addControl(new LogoControl({ variant: "com" }));
<!DOCTYPE html>
<html lang="en">
<head>
    <title>Route with Elevation Profile - MapLibre GL JS</title>
    <meta property="og:description" content="Draw a hiking route and show its climb." />
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <!-- MapLibre GL JS 6 is an ES module. The import map tells the browser
         where to load "maplibre-gl" from. -->
    <script type="importmap">
      {
        "imports": {
          "maplibre-gl": "https://cdn.jsdelivr.net/npm/maplibre-gl@6.16.0/dist/maplibre-gl.mjs"
        }
      }
    </script>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@6.16.0/dist/maplibre-gl.css" />
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@maptoolkit/maplibre-elevation-control@1.0.0/dist/maplibre-elevation-control.css" />
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@maptoolkit/maplibre-logo-control@1.1.2/dist/maplibre-logo-control.css" />
    <style>
        html, body { width: 100%; height: 100%; margin: 0; padding: 0; }
        #map { width: 100%; height: 100%; }
    </style>
</head>
<body>
<div id="map"></div>
<script type="module">
    import * as maplibregl from "maplibre-gl";
    import { ElevationControl } from "https://cdn.jsdelivr.net/npm/@maptoolkit/maplibre-elevation-control@1.0.0/dist/maplibre-elevation-control.js";
    import { LogoControl } from "https://cdn.jsdelivr.net/npm/@maptoolkit/maplibre-logo-control@1.1.2/dist/maplibre-logo-control.js";

    const API_KEY = 'YOUR_API_KEY';

    const map = new maplibregl.Map({
        container: 'map',
        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 maplibregl.NavigationControl(), 'top-right');

    // The route as a GeoJSON LineString in [lng, lat] order.
    async function getRoute(start, end) {
        const url = new URL('https://routing.maptoolkit.net/route');
        url.searchParams.append('point', `${start[1]},${start[0]}`);
        url.searchParams.append('point', `${end[1]},${end[0]}`);
        url.searchParams.append('routeType', 'hike');
        url.searchParams.append('points_encoded', 'false');
        url.searchParams.append('api_key', API_KEY);
        const response = await fetch(url);
        if (!response.ok) throw new Error(`Routing API returned ${response.status}`);
        return (await response.json()).paths[0];
    }

    // One height in meters per coordinate. The points go in the query string,
    // so a long route is split into batches.
    async function getElevations(coordinates) {
        const BATCH_SIZE = 150;
        const heights = [];
        for (let i = 0; i < coordinates.length; i += BATCH_SIZE) {
            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);
            const response = await fetch(url);
            if (!response.ok) throw new Error(`Elevation API returned ${response.status}`);
            heights.push(...(await response.json()));
        }
        return heights;
    }

    map.on('load', async () => {
        const path = await getRoute([11.3857, 47.2683], [11.3906, 47.3125]);
        const coordinates = path.points.coordinates;
        const heights = await getElevations(coordinates);

        // The control takes a line with a height in every coordinate: [lng, lat, ele].
        const route = {
            type: 'Feature',
            properties: {},
            geometry: {
                type: 'LineString',
                coordinates: coordinates.map(([lng, lat], i) => [lng, lat, heights[i]])
            }
        };

        // The control draws the route, the selected section and the hover point on the map.
        map.addControl(new ElevationControl(route));

        map.fitBounds([[path.bbox[0], path.bbox[1]], [path.bbox[2], path.bbox[3]]], { padding: { top: 40, right: 40, bottom: 220, left: 40 } });
    });

    map.addControl(new LogoControl({ variant: "com" }));
</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.

Use the Maptoolkit Connector. Create an interactive map with a hiking route from [11.3857, 47.2683] to [11.3906, 47.3125]. Get the elevation of the route from the Elevation API and show it with the Maptoolkit Elevation Control, linked to the map.

How it works

Three coordinate orders meet in this example. The Routing API takes point as lat,lng, and the Elevation API takes points as [[lat, lng], ...]. With points_encoded=false the route comes back as a GeoJSON LineString in [lng, lat] order, and the control expects [lng, lat, ele]. A swapped pair does not raise an error: the route lands in the Indian Ocean, which is where [47, 11] points, and the heights are those of the wrong place.

The route needs more than one elevation request. This route has about 670 coordinates. points travels in the query string, and above roughly 250 coordinates the URL is too long and the API answers 431 or 403. getElevations() sends batches of 150 and joins the results. The response has one height per input coordinate, in the same order, so the batches fit back together without any matching. See Request size for the limits. Do not switch to POST to avoid the batches: the API accepts a body but answers with zeros.

The control needs a height in every coordinate. It does not request heights itself. Any source of [lng, lat, ele] works, for example a GPX track that already has heights. For a long track, the Route Enhancement API returns the heights for the whole line in one request.

The control also draws on the map. By default it adds the route, the selected section and the hover point as map layers, so the example has no line layer of its own. To use your own layers, or none, see the layers option on the plugin page. The plugin page also lists the events, such as selectionend with the ascent and descent of the selection, and the styling options.

path.bbox is [minLng, minLat, maxLng, maxLat], already in GeoJSON order, so it goes into fitBounds as two corners. The bottom padding keeps the route clear of the control.

routeType: 'hike' is doing real work here. A car route through a valley produces a flat line and nothing to read, so the profile only earns its space on a route that climbs.

Next steps

The profile shows the shape of the climb. The numbers people compare before committing to a route, total ascent, descent and the steepest section, come out of the same height array without another request, and they belong next to the profile.

What the ground is made of matters as much as how steep it is on anything ridden. Coloring the line by surface shows where the asphalt ends, which is the other half of the question a cyclist is asking. The routeType values in the Routing API reference are what let the user ask for a bike rather than boots.