Skip to content
Route Colored by Elevation

Color a Route by Elevation in Maptoolkit Maps JS

A single-colored line shows where a route goes, not how it climbs. This example requests a hiking route from Innsbruck up the Nordkette, asks the Elevation API for the height of every point, and turns the heights into a line-gradient that runs from blue in the valley to red at the top. A symbol layer repeats an arrow along the line to show which way the route runs.

const API_KEY = 'YOUR_API_KEY';

    // The Elevation API reads its points from the query string, so a whole route is sent in batches.
    const BATCH_SIZE = 150;
    // Low to high.
    const RAMP = ['#2b83ba', '#abdda4', '#ffffbf', '#fdae61', '#d7191c'];

    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.391, 47.290],
        zoom: 12,
        attributionControl: { compact: false }
    });

    map.addControl(new maptoolkit.NavigationControl(), 'top-right');

    // A color for a value between 0 and 1, blended between the two nearest ramp colors.
    function colorAt(t) {
        const scaled = Math.min(Math.max(t, 0), 1) * (RAMP.length - 1);
        const i = Math.min(Math.floor(scaled), RAMP.length - 2);
        const [a, b] = [RAMP[i], RAMP[i + 1]].map(hex => [1, 3, 5].map(k => parseInt(hex.slice(k, k + 2), 16)));
        return `rgb(${a.map((c, k) => Math.round(c + (b[k] - c) * (scaled - i))).join(',')})`;
    }

    // Great-circle distance in metres between two [lng, lat] points.
    function distance([lng1, lat1], [lng2, lat2]) {
        const rad = Math.PI / 180;
        const h = Math.sin((lat2 - lat1) * rad / 2) ** 2 +
                  Math.cos(lat1 * rad) * Math.cos(lat2 * rad) * Math.sin((lng2 - lng1) * rad / 2) ** 2;
        return 2 * 6371000 * Math.asin(Math.sqrt(h));
    }

    async function fetchRoute() {
        const url = new URL('https://routing.maptoolkit.net/route');
        url.searchParams.append('point', '47.2683,11.3857');
        url.searchParams.append('point', '47.3125,11.3906');
        url.searchParams.set('routeType', 'foot');
        url.searchParams.set('points_encoded', 'false');
        url.searchParams.set('api_key', API_KEY);
        const r = await fetch(url);
        if (!r.ok) throw new Error(`Routing API returned ${r.status}`);
        return (await r.json()).paths[0];
    }

    async function fetchElevations(coordinates) {
        const requests = [];
        for (let i = 0; i < coordinates.length; i += BATCH_SIZE) {
            // The Elevation API takes [lat, lng]; GeoJSON is [lng, lat].
            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 (await Promise.all(requests)).flat();
    }

    // A white arrow on a dark circle, drawn once on a canvas and registered as a map image.
    function addArrowImage() {
        const size = 40;
        const ctx = Object.assign(document.createElement('canvas'), { width: size, height: size }).getContext('2d');
        ctx.fillStyle = '#1f2430';
        ctx.beginPath(); ctx.arc(20, 20, 17, 0, 2 * Math.PI); ctx.fill();
        // Pointing right: symbols placed along a line face the direction the line runs.
        ctx.fillStyle = '#ffffff';
        ctx.beginPath(); ctx.moveTo(29, 20); ctx.lineTo(14, 11); ctx.lineTo(17, 20); ctx.lineTo(14, 29); ctx.closePath(); ctx.fill();
        map.addImage('arrow', ctx.getImageData(0, 0, size, size), { pixelRatio: 2 });
    }

    map.on('load', async () => {
        try {
            const path = await fetchRoute();
            const coordinates = path.points.coordinates;
            const heights = await fetchElevations(coordinates);
            const low = Math.min(...heights), high = Math.max(...heights);

            // line-gradient positions are fractions of the line's length, so measure it.
            const along = [0];
            for (let i = 1; i < coordinates.length; i++) along.push(along[i - 1] + distance(coordinates[i - 1], coordinates[i]));
            const total = along[along.length - 1];

            // One stop per point, skipping points that would not move the position forward.
            const stops = [];
            let previous = -1;
            coordinates.forEach((_, i) => {
                const position = along[i] / total;
                if (position - previous < 0.002 && i !== coordinates.length - 1) return;
                stops.push(position, colorAt((heights[i] - low) / (high - low)));
                previous = position;
            });

            // lineMetrics: true is what makes line-progress, and so line-gradient, available.
            map.addSource('route', { type: 'geojson', lineMetrics: true, data: path.points });
            map.addLayer({
                id: 'route-casing', type: 'line', source: 'route',
                layout: { 'line-join': 'round', 'line-cap': 'round' },
                paint: { 'line-color': '#ffffff', 'line-width': 9 }
            });
            map.addLayer({
                id: 'route', type: 'line', source: 'route',
                layout: { 'line-join': 'round', 'line-cap': 'round' },
                paint: {
                    'line-width': 6,
                    'line-gradient': ['interpolate', ['linear'], ['line-progress'], ...stops]
                }
            });

            addArrowImage();
            map.addLayer({
                id: 'route-arrows', type: 'symbol', source: 'route',
                layout: {
                    'symbol-placement': 'line',
                    'symbol-spacing': 90,
                    'icon-image': 'arrow',
                    'icon-size': 0.7,
                    'icon-allow-overlap': true
                }
            });

            const [minLng, minLat, maxLng, maxLat] = path.bbox;
            map.fitBounds([[minLng, minLat], [maxLng, maxLat]], { padding: { top: 40, bottom: 40, left: 220, right: 40 } });

            document.getElementById('legend').innerHTML = `<strong>Elevation</strong>
                <div class="ramp" style="background:linear-gradient(to right, ${RAMP.join(', ')})"></div>
                <div class="ends"><span>${Math.round(low)} m</span><span>${Math.round(high)} m</span></div>`;
        } catch (e) {
            document.getElementById('legend').textContent = 'Could not load the route.';
        }
    });
<!DOCTYPE html>
<html lang="en">
<head>
    <title>Route Colored by Elevation - Maptoolkit Maps JS</title>
    <meta property="og:description" content="Color a route by height with a line-gradient and add direction arrows." />
    <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" />
    <style>
        html, body { width: 100%; height: 100%; margin: 0; padding: 0; }
        #map { width: 100%; height: 100%; }
        #legend {
            position: absolute; top: 10px; left: 10px; z-index: 999; width: 170px;
            background: #fff; border-radius: 6px; box-shadow: 0 0 15px #68686880;
            font: 13px/1.4 system-ui, sans-serif; padding: 10px 12px;
        }
        #legend strong { display: block; margin-bottom: 6px; }
        #legend .ramp { height: 10px; border-radius: 5px; }
        #legend .ends { display: flex; justify-content: space-between; margin-top: 3px; color: #555; font-variant-numeric: tabular-nums; }
    </style>
</head>
<body>
<div id="map"></div>
<div id="legend">Loading route...</div>
<script>
    const API_KEY = 'YOUR_API_KEY';

    // The Elevation API reads its points from the query string, so a whole route is sent in batches.
    const BATCH_SIZE = 150;
    // Low to high.
    const RAMP = ['#2b83ba', '#abdda4', '#ffffbf', '#fdae61', '#d7191c'];

    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.391, 47.290],
        zoom: 12,
        attributionControl: { compact: false }
    });

    map.addControl(new maptoolkit.NavigationControl(), 'top-right');

    // A color for a value between 0 and 1, blended between the two nearest ramp colors.
    function colorAt(t) {
        const scaled = Math.min(Math.max(t, 0), 1) * (RAMP.length - 1);
        const i = Math.min(Math.floor(scaled), RAMP.length - 2);
        const [a, b] = [RAMP[i], RAMP[i + 1]].map(hex => [1, 3, 5].map(k => parseInt(hex.slice(k, k + 2), 16)));
        return `rgb(${a.map((c, k) => Math.round(c + (b[k] - c) * (scaled - i))).join(',')})`;
    }

    // Great-circle distance in metres between two [lng, lat] points.
    function distance([lng1, lat1], [lng2, lat2]) {
        const rad = Math.PI / 180;
        const h = Math.sin((lat2 - lat1) * rad / 2) ** 2 +
                  Math.cos(lat1 * rad) * Math.cos(lat2 * rad) * Math.sin((lng2 - lng1) * rad / 2) ** 2;
        return 2 * 6371000 * Math.asin(Math.sqrt(h));
    }

    async function fetchRoute() {
        const url = new URL('https://routing.maptoolkit.net/route');
        url.searchParams.append('point', '47.2683,11.3857');
        url.searchParams.append('point', '47.3125,11.3906');
        url.searchParams.set('routeType', 'foot');
        url.searchParams.set('points_encoded', 'false');
        url.searchParams.set('api_key', API_KEY);
        const r = await fetch(url);
        if (!r.ok) throw new Error(`Routing API returned ${r.status}`);
        return (await r.json()).paths[0];
    }

    async function fetchElevations(coordinates) {
        const requests = [];
        for (let i = 0; i < coordinates.length; i += BATCH_SIZE) {
            // The Elevation API takes [lat, lng]; GeoJSON is [lng, lat].
            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 (await Promise.all(requests)).flat();
    }

    // A white arrow on a dark circle, drawn once on a canvas and registered as a map image.
    function addArrowImage() {
        const size = 40;
        const ctx = Object.assign(document.createElement('canvas'), { width: size, height: size }).getContext('2d');
        ctx.fillStyle = '#1f2430';
        ctx.beginPath(); ctx.arc(20, 20, 17, 0, 2 * Math.PI); ctx.fill();
        // Pointing right: symbols placed along a line face the direction the line runs.
        ctx.fillStyle = '#ffffff';
        ctx.beginPath(); ctx.moveTo(29, 20); ctx.lineTo(14, 11); ctx.lineTo(17, 20); ctx.lineTo(14, 29); ctx.closePath(); ctx.fill();
        map.addImage('arrow', ctx.getImageData(0, 0, size, size), { pixelRatio: 2 });
    }

    map.on('load', async () => {
        try {
            const path = await fetchRoute();
            const coordinates = path.points.coordinates;
            const heights = await fetchElevations(coordinates);
            const low = Math.min(...heights), high = Math.max(...heights);

            // line-gradient positions are fractions of the line's length, so measure it.
            const along = [0];
            for (let i = 1; i < coordinates.length; i++) along.push(along[i - 1] + distance(coordinates[i - 1], coordinates[i]));
            const total = along[along.length - 1];

            // One stop per point, skipping points that would not move the position forward.
            const stops = [];
            let previous = -1;
            coordinates.forEach((_, i) => {
                const position = along[i] / total;
                if (position - previous < 0.002 && i !== coordinates.length - 1) return;
                stops.push(position, colorAt((heights[i] - low) / (high - low)));
                previous = position;
            });

            // lineMetrics: true is what makes line-progress, and so line-gradient, available.
            map.addSource('route', { type: 'geojson', lineMetrics: true, data: path.points });
            map.addLayer({
                id: 'route-casing', type: 'line', source: 'route',
                layout: { 'line-join': 'round', 'line-cap': 'round' },
                paint: { 'line-color': '#ffffff', 'line-width': 9 }
            });
            map.addLayer({
                id: 'route', type: 'line', source: 'route',
                layout: { 'line-join': 'round', 'line-cap': 'round' },
                paint: {
                    'line-width': 6,
                    'line-gradient': ['interpolate', ['linear'], ['line-progress'], ...stops]
                }
            });

            addArrowImage();
            map.addLayer({
                id: 'route-arrows', type: 'symbol', source: 'route',
                layout: {
                    'symbol-placement': 'line',
                    'symbol-spacing': 90,
                    'icon-image': 'arrow',
                    'icon-size': 0.7,
                    'icon-allow-overlap': true
                }
            });

            const [minLng, minLat, maxLng, maxLat] = path.bbox;
            map.fitBounds([[minLng, minLat], [maxLng, maxLat]], { padding: { top: 40, bottom: 40, left: 220, right: 40 } });

            document.getElementById('legend').innerHTML = `<strong>Elevation</strong>
                <div class="ramp" style="background:linear-gradient(to right, ${RAMP.join(', ')})"></div>
                <div class="ends"><span>${Math.round(low)} m</span><span>${Math.round(high)} m</span></div>`;
        } catch (e) {
            document.getElementById('legend').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.

Use the Maptoolkit Connector. Create an interactive map with the Maptoolkit hiking style and a walking route from [11.3857, 47.2683] to [11.3906, 47.3125]. Get the elevation of every point with the Elevation API, color the route with a line-gradient from blue at the lowest to red at the highest point, add direction arrows along the line with a symbol layer, and show a legend.

How it works

line-gradient colors one line along its length. The expression maps line-progress, the position along the line from 0 at the start to 1 at the end, to a color. It only works on a GeoJSON source created with lineMetrics: true; without it, the layer draws nothing and logs an error. line-gradient also replaces line-color, so the layer has no plain color at all.

Heights become stops. The Elevation API returns one height per route point, in order. Each point’s position along the line is its distance from the start divided by the total length, and its color comes from where its height sits between the route’s lowest and highest point. Points closer together than 0.2 % of the length are skipped: the stops must increase strictly, and hundreds of nearly identical positions add nothing but a longer expression.

The distances are measured along the same coordinates the source draws, so the colors land on the right parts of the line. colorAt() blends between neighboring ramp colors, and the gradient interpolates between stops, so the line changes color smoothly.

Direction arrows as a symbol layer. symbol-placement: 'line' repeats the icon along the route every symbol-spacing pixels and turns it to follow the line in the direction its coordinates run. The arrow is drawn once on a canvas and registered with addImage(), pointing right, which is the direction of travel for line placement. pixelRatio: 2 keeps it sharp on high-density screens. icon-allow-overlap stops the basemap labels from pushing arrows away.

Elevation in batches. The Elevation API takes a JSON array of [lat, lng] points in the query string, so a route of several hundred points goes in batches of 150, and Promise.all keeps the results in order.

The scale runs from this route’s lowest to its highest point, which shows the most detail for one route. To compare routes, fix the scale instead, so a color always means the same height.

Next steps

For ascent, descent and the steepest section of the same route, see Show Climb Statistics for a Route, and to chart the heights under the map, Draw a Route with an Elevation Profile. The same line-gradient approach colors a recorded track by speed or heart rate: any value per point works.