Color a Route by Elevation in Leaflet, with Direction Arrows
A single-colored line shows where a route goes, not how it climbs. This example requests a hiking route from Innsbruck up the Nordkette with the Maptoolkit Routing API, asks the Elevation API for the height of every point, and colors each piece of the line by its height, from blue in the valley to red at the top. Arrows from Leaflet.PolylineDecorator show which way the route runs, and the legend gives the lowest and highest point.
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"];
// Canvas draws hundreds of short lines much faster than one SVG element each.
// zoomSnap 0.25 lets fitBounds pick an in-between zoom, so the route fills the map.
const map = L.map("map", { zoomControl: false, preferCanvas: true, zoomSnap: 0.25 }).setView([47.29, 11.391], 13);
L.control.zoom({ position: "topright" }).addTo(map);
L.tileLayer(`https://rtc-cdn.maptoolkit.net/rtc/maptoolkit-maptoolkit.hiking/{z}/{x}/{y}{r}.png?api_key=${API_KEY}`, {
maxZoom: 18,
attribution:
"© <a href='https://www.maptoolkit.com' target='_blank'>Maptoolkit</a> " +
"© <a href='https://www.openstreetmap.org/copyright' target='_blank'>OSM</a>",
}).addTo(map);
const legend = L.control({ position: "topleft" });
legend.onAdd = () => L.DomUtil.create("div", "legend");
legend.addTo(map);
legend.getContainer().textContent = "Loading route...";
// 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)));
const mix = a.map((channel, k) => Math.round(channel + (b[k] - channel) * (scaled - i)));
return `rgb(${mix.join(",")})`;
}
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 response = await fetch(url);
if (!response.ok) throw new Error(`Routing API returned ${response.status}`);
return (await response.json()).paths[0];
}
// One request per batch of [lat, lng] points; Promise.all keeps them in order.
async function fetchElevations(latlngs) {
const requests = [];
for (let i = 0; i < latlngs.length; i += BATCH_SIZE) {
const url = new URL("https://elevation.maptoolkit.net");
url.searchParams.set("points", JSON.stringify(latlngs.slice(i, i + BATCH_SIZE)));
url.searchParams.set("api_key", API_KEY);
requests.push(fetch(url).then((response) => {
if (!response.ok) throw new Error(`Elevation API returned ${response.status}`);
return response.json();
}));
}
return (await Promise.all(requests)).flat();
}
(async () => {
try {
const path = await fetchRoute();
// GeoJSON is [lng, lat]; Leaflet and the Elevation API use [lat, lng].
const latlngs = path.points.coordinates.map(([lng, lat]) => [lat, lng]);
const heights = await fetchElevations(latlngs);
const low = Math.min(...heights), high = Math.max(...heights);
// A white casing under the colors keeps the light middle of the ramp visible.
L.polyline(latlngs, { color: "#ffffff", weight: 9, opacity: 1, interactive: false }).addTo(map);
// One short line per segment, colored by the mean height of its two ends.
for (let i = 1; i < latlngs.length; i++) {
const t = ((heights[i - 1] + heights[i]) / 2 - low) / (high - low);
L.polyline([latlngs[i - 1], latlngs[i]], { color: colorAt(t), weight: 6, opacity: 1, interactive: false }).addTo(map);
}
// Arrows repeated along the line, 25 px from the start and then every 90 px.
L.polylineDecorator(latlngs, {
patterns: [{
offset: 25,
repeat: 90,
symbol: L.Symbol.arrowHead({ pixelSize: 9, polygon: true, pathOptions: { stroke: false, fillOpacity: 1, color: "#1f2430" } }),
}],
}).addTo(map);
map.fitBounds(L.latLngBounds(latlngs), { padding: [30, 30] });
legend.getContainer().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 {
legend.getContainer().textContent = "Could not load the route.";
}
})();<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdn.jsdelivr.net/npm/leaflet-polylinedecorator@1.6.0/dist/leaflet.polylineDecorator.js"></script>
<style>
html, body { width: 100%; height: 100%; margin: 0; padding: 0; }
#map { width: 100%; height: 100%; }
.legend {
width: 170px; padding: 10px 12px; background: #fff; border-radius: 10px;
box-shadow: 0 4px 20px rgba(20, 30, 60, 0.18); font: 13px/1.4 system-ui, sans-serif; color: #1f2430;
}
.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: #4a5068; font-variant-numeric: tabular-nums; }
</style>
</head>
<body>
<div id="map"></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"];
// Canvas draws hundreds of short lines much faster than one SVG element each.
// zoomSnap 0.25 lets fitBounds pick an in-between zoom, so the route fills the map.
const map = L.map("map", { zoomControl: false, preferCanvas: true, zoomSnap: 0.25 }).setView([47.29, 11.391], 13);
L.control.zoom({ position: "topright" }).addTo(map);
L.tileLayer(`https://rtc-cdn.maptoolkit.net/rtc/maptoolkit-maptoolkit.hiking/{z}/{x}/{y}{r}.png?api_key=${API_KEY}`, {
maxZoom: 18,
attribution:
"© <a href='https://www.maptoolkit.com' target='_blank'>Maptoolkit</a> " +
"© <a href='https://www.openstreetmap.org/copyright' target='_blank'>OSM</a>",
}).addTo(map);
const legend = L.control({ position: "topleft" });
legend.onAdd = () => L.DomUtil.create("div", "legend");
legend.addTo(map);
legend.getContainer().textContent = "Loading route...";
// 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)));
const mix = a.map((channel, k) => Math.round(channel + (b[k] - channel) * (scaled - i)));
return `rgb(${mix.join(",")})`;
}
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 response = await fetch(url);
if (!response.ok) throw new Error(`Routing API returned ${response.status}`);
return (await response.json()).paths[0];
}
// One request per batch of [lat, lng] points; Promise.all keeps them in order.
async function fetchElevations(latlngs) {
const requests = [];
for (let i = 0; i < latlngs.length; i += BATCH_SIZE) {
const url = new URL("https://elevation.maptoolkit.net");
url.searchParams.set("points", JSON.stringify(latlngs.slice(i, i + BATCH_SIZE)));
url.searchParams.set("api_key", API_KEY);
requests.push(fetch(url).then((response) => {
if (!response.ok) throw new Error(`Elevation API returned ${response.status}`);
return response.json();
}));
}
return (await Promise.all(requests)).flat();
}
(async () => {
try {
const path = await fetchRoute();
// GeoJSON is [lng, lat]; Leaflet and the Elevation API use [lat, lng].
const latlngs = path.points.coordinates.map(([lng, lat]) => [lat, lng]);
const heights = await fetchElevations(latlngs);
const low = Math.min(...heights), high = Math.max(...heights);
// A white casing under the colors keeps the light middle of the ramp visible.
L.polyline(latlngs, { color: "#ffffff", weight: 9, opacity: 1, interactive: false }).addTo(map);
// One short line per segment, colored by the mean height of its two ends.
for (let i = 1; i < latlngs.length; i++) {
const t = ((heights[i - 1] + heights[i]) / 2 - low) / (high - low);
L.polyline([latlngs[i - 1], latlngs[i]], { color: colorAt(t), weight: 6, opacity: 1, interactive: false }).addTo(map);
}
// Arrows repeated along the line, 25 px from the start and then every 90 px.
L.polylineDecorator(latlngs, {
patterns: [{
offset: 25,
repeat: 90,
symbol: L.Symbol.arrowHead({ pixelSize: 9, polygon: true, pathOptions: { stroke: false, fillOpacity: 1, color: "#1f2430" } }),
}],
}).addTo(map);
map.fitBounds(L.latLngBounds(latlngs), { padding: [30, 30] });
legend.getContainer().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 {
legend.getContainer().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
Heights for every point. points_encoded=false makes the Routing API
return the route as GeoJSON. Its coordinates, flipped to [lat, lng], go to the
Elevation API, which returns one height per point in the same order. A route
has hundreds of points, too many for one URL, so they are sent in batches of 150.
A color per segment. Leaflet draws a polyline in one color, so a line whose color changes
is many short polylines. Each segment between two route points gets the color for the mean
height of its ends, placed between the route’s lowest and highest point. colorAt() blends
between the two nearest colors of the ramp, so the color changes smoothly rather than in bands.
The pieces keep Leaflet’s default round caps: each one overlaps its neighbors slightly at the
joins, so no gap opens at a bend and the white casing underneath only shows along the edges.
Canvas for many lines. preferCanvas: true draws vector layers onto one canvas instead of
an SVG element each. With hundreds of segments, panning and zooming stay smooth. The segments
are interactive: false because they don’t need mouse events.
Direction arrows. L.polylineDecorator places symbols along a line. The pattern puts the
first arrowhead 25 pixels from the start and repeats it every 90 pixels. The distances are in
screen pixels, so the arrows keep their spacing at every zoom level. L.Symbol.arrowHead points
each one along the line at its position.
A relative scale. The colors run from this route’s lowest to its highest point, which shows the most detail for one route. To compare several routes, fix the scale instead, for example 500 to 2500 m, so a color always means the same height.
The same method colors a track by any value per point: speed from a GPS recording, gradient from the heights, or heart rate.
Next steps
For the totals, ascent, descent and the steepest section, see Show Climb Statistics for a Route, and for the heights as a chart, Show an Elevation Profile. Coloring by what the route runs on instead is Style a Route by Surface Type.
The same example in Maptoolkit Maps JS is Color a Route by Elevation.