Style a Route by Surface Type in Leaflet
A cyclist choosing between two routes wants to know where the asphalt ends. The Maptoolkit Route Enhancement API returns the surface and road type under each part of a route, as positions along the line rather than as geometry. This example requests a bike route, enhances it with surface data, cuts the line at the surface changes and draws each piece in Leaflet in the color of its surface, with a breakdown of how much of the ride is on each.
const API_KEY = "YOUR_API_KEY";
const SURFACE_COLORS = {
asphalt: "#37474f",
paved: "#78909c",
unpaved: "#b07d4a",
natural: "#7a9a55",
alpine: "#9c8aa5",
other: "#c2c6cc",
};
const map = L.map("map", { zoomControl: false }).setView([47.453, 12.401], 14);
L.control.zoom({ position: "topright" }).addTo(map);
L.tileLayer(`https://rtc-cdn.maptoolkit.net/rtc/maptoolkit-maptoolkit.cycling/{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 breakdown = L.control({ position: "topleft" });
breakdown.onAdd = () => L.DomUtil.create("div", "breakdown");
breakdown.addTo(map);
breakdown.getContainer().textContent = "Loading route...";
// Cumulative length along the line, so a fraction of the route can be turned into a position.
function measure(coordinates) {
const lengths = [0];
for (let i = 1; i < coordinates.length; i++) {
const [x1, y1] = coordinates[i - 1];
const [x2, y2] = coordinates[i];
const dx = (x2 - x1) * Math.cos((((y1 + y2) / 2) * Math.PI) / 180);
lengths.push(lengths[i - 1] + Math.hypot(dx, y2 - y1));
}
return lengths;
}
// The part of the line between two fractions, with both ends interpolated so the pieces meet.
function slice(coordinates, lengths, from, to) {
const total = lengths[lengths.length - 1];
const pointAt = (d) => {
let i = 1;
while (i < lengths.length - 1 && lengths[i] < d) i++;
const span = lengths[i] - lengths[i - 1];
const t = span > 0 ? (d - lengths[i - 1]) / span : 0;
const [x1, y1] = coordinates[i - 1];
const [x2, y2] = coordinates[i];
return [x1 + (x2 - x1) * t, y1 + (y2 - y1) * t];
};
const d0 = from * total, d1 = to * total;
const inner = coordinates.filter((_, i) => lengths[i] > d0 && lengths[i] < d1);
return [pointAt(d0), ...inner, pointAt(d1)];
}
async function fetchJson(url, options) {
const response = await fetch(url, options);
if (!response.ok) throw new Error(`${url} returned ${response.status}`);
return response.json();
}
(async () => {
try {
const routeUrl = new URL("https://routing.maptoolkit.net/route");
routeUrl.searchParams.append("point", "47.4460,12.3920");
routeUrl.searchParams.append("point", "47.4600,12.4100");
routeUrl.searchParams.set("routeType", "bike");
// GeoJSON instead of an encoded polyline, ready for the enhancement request.
routeUrl.searchParams.set("points_encoded", "false");
routeUrl.searchParams.set("api_key", API_KEY);
const path = (await fetchJson(routeUrl)).paths[0];
// POST, because a route is too long for a query string. The api_key stays in the URL.
const enhanced = await fetchJson(`https://enhance.maptoolkit.net/route?api_key=${API_KEY}`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ geometry: JSON.stringify(path.points), surface: "1", routeType: "bike" }),
});
// The geometry is a MultiLineString, with one list of surface sections per line.
const features = enhanced.geometry.coordinates.flatMap((coordinates, i) => {
const lengths = measure(coordinates);
return (enhanced.surface[i] || []).map(({ from, to, surface, highway }) => ({
type: "Feature",
properties: { surface, highway },
geometry: { type: "LineString", coordinates: slice(coordinates, lengths, from, to) },
}));
});
const collection = { type: "FeatureCollection", features };
// A white casing under the colored pieces keeps the light colors visible on the tiles.
L.geoJSON(collection, { style: { color: "#ffffff", weight: 9, opacity: 1 }, interactive: false }).addTo(map);
const route = L.geoJSON(collection, {
style: (feature) => ({ color: SURFACE_COLORS[feature.properties.surface] || SURFACE_COLORS.other, weight: 6, opacity: 1 }),
onEachFeature: (feature, layer) => layer.bindTooltip(`${feature.properties.surface}, ${feature.properties.highway}`, { sticky: true }),
}).addTo(map);
map.fitBounds(route.getBounds(), { paddingTopLeft: [220, 40], paddingBottomRight: [40, 40] });
// Share of the route per surface, from the fractions.
const share = {};
for (const { surface, from, to } of enhanced.surface.flat()) share[surface] = (share[surface] || 0) + (to - from);
breakdown.getContainer().innerHTML = "<strong>Surface</strong>" + Object.entries(share)
.filter(([, fraction]) => Math.round(fraction * 100) >= 1)
.sort((a, b) => b[1] - a[1])
.map(([surface, fraction]) => `<div class="row">
<span class="swatch" style="background:${SURFACE_COLORS[surface] || SURFACE_COLORS.other}"></span>
${surface === "other" ? "unknown" : surface}<span class="share">${Math.round(fraction * 100)} %</span></div>`)
.join("");
} catch {
breakdown.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>
<style>
html, body { width: 100%; height: 100%; margin: 0; padding: 0; }
#map { width: 100%; height: 100%; }
.breakdown {
min-width: 160px; padding: 10px 12px; background: #fff; border-radius: 10px;
box-shadow: 0 4px 20px rgba(20, 30, 60, 0.18); font: 13px/1.7 system-ui, sans-serif; color: #1f2430;
}
.breakdown strong { display: block; }
.breakdown .row { display: flex; align-items: center; gap: 8px; }
.breakdown .swatch { width: 16px; height: 5px; border-radius: 3px; }
.breakdown .share { margin-left: auto; font-variant-numeric: tabular-nums; color: #6b7185; }
</style>
</head>
<body>
<div id="map"></div>
<script>
const API_KEY = "YOUR_API_KEY";
const SURFACE_COLORS = {
asphalt: "#37474f",
paved: "#78909c",
unpaved: "#b07d4a",
natural: "#7a9a55",
alpine: "#9c8aa5",
other: "#c2c6cc",
};
const map = L.map("map", { zoomControl: false }).setView([47.453, 12.401], 14);
L.control.zoom({ position: "topright" }).addTo(map);
L.tileLayer(`https://rtc-cdn.maptoolkit.net/rtc/maptoolkit-maptoolkit.cycling/{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 breakdown = L.control({ position: "topleft" });
breakdown.onAdd = () => L.DomUtil.create("div", "breakdown");
breakdown.addTo(map);
breakdown.getContainer().textContent = "Loading route...";
// Cumulative length along the line, so a fraction of the route can be turned into a position.
function measure(coordinates) {
const lengths = [0];
for (let i = 1; i < coordinates.length; i++) {
const [x1, y1] = coordinates[i - 1];
const [x2, y2] = coordinates[i];
const dx = (x2 - x1) * Math.cos((((y1 + y2) / 2) * Math.PI) / 180);
lengths.push(lengths[i - 1] + Math.hypot(dx, y2 - y1));
}
return lengths;
}
// The part of the line between two fractions, with both ends interpolated so the pieces meet.
function slice(coordinates, lengths, from, to) {
const total = lengths[lengths.length - 1];
const pointAt = (d) => {
let i = 1;
while (i < lengths.length - 1 && lengths[i] < d) i++;
const span = lengths[i] - lengths[i - 1];
const t = span > 0 ? (d - lengths[i - 1]) / span : 0;
const [x1, y1] = coordinates[i - 1];
const [x2, y2] = coordinates[i];
return [x1 + (x2 - x1) * t, y1 + (y2 - y1) * t];
};
const d0 = from * total, d1 = to * total;
const inner = coordinates.filter((_, i) => lengths[i] > d0 && lengths[i] < d1);
return [pointAt(d0), ...inner, pointAt(d1)];
}
async function fetchJson(url, options) {
const response = await fetch(url, options);
if (!response.ok) throw new Error(`${url} returned ${response.status}`);
return response.json();
}
(async () => {
try {
const routeUrl = new URL("https://routing.maptoolkit.net/route");
routeUrl.searchParams.append("point", "47.4460,12.3920");
routeUrl.searchParams.append("point", "47.4600,12.4100");
routeUrl.searchParams.set("routeType", "bike");
// GeoJSON instead of an encoded polyline, ready for the enhancement request.
routeUrl.searchParams.set("points_encoded", "false");
routeUrl.searchParams.set("api_key", API_KEY);
const path = (await fetchJson(routeUrl)).paths[0];
// POST, because a route is too long for a query string. The api_key stays in the URL.
const enhanced = await fetchJson(`https://enhance.maptoolkit.net/route?api_key=${API_KEY}`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ geometry: JSON.stringify(path.points), surface: "1", routeType: "bike" }),
});
// The geometry is a MultiLineString, with one list of surface sections per line.
const features = enhanced.geometry.coordinates.flatMap((coordinates, i) => {
const lengths = measure(coordinates);
return (enhanced.surface[i] || []).map(({ from, to, surface, highway }) => ({
type: "Feature",
properties: { surface, highway },
geometry: { type: "LineString", coordinates: slice(coordinates, lengths, from, to) },
}));
});
const collection = { type: "FeatureCollection", features };
// A white casing under the colored pieces keeps the light colors visible on the tiles.
L.geoJSON(collection, { style: { color: "#ffffff", weight: 9, opacity: 1 }, interactive: false }).addTo(map);
const route = L.geoJSON(collection, {
style: (feature) => ({ color: SURFACE_COLORS[feature.properties.surface] || SURFACE_COLORS.other, weight: 6, opacity: 1 }),
onEachFeature: (feature, layer) => layer.bindTooltip(`${feature.properties.surface}, ${feature.properties.highway}`, { sticky: true }),
}).addTo(map);
map.fitBounds(route.getBounds(), { paddingTopLeft: [220, 40], paddingBottomRight: [40, 40] });
// Share of the route per surface, from the fractions.
const share = {};
for (const { surface, from, to } of enhanced.surface.flat()) share[surface] = (share[surface] || 0) + (to - from);
breakdown.getContainer().innerHTML = "<strong>Surface</strong>" + Object.entries(share)
.filter(([, fraction]) => Math.round(fraction * 100) >= 1)
.sort((a, b) => b[1] - a[1])
.map(([surface, fraction]) => `<div class="row">
<span class="swatch" style="background:${SURFACE_COLORS[surface] || SURFACE_COLORS.other}"></span>
${surface === "other" ? "unknown" : surface}<span class="share">${Math.round(fraction * 100)} %</span></div>`)
.join("");
} catch {
breakdown.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
One geometry for both calls. points_encoded=false makes the Routing API
return the route as a GeoJSON LineString. The same object is sent to the
Route Enhancement API as geometry, by POST because it is too long
for a query string.
Surface comes back as positions, not lines. Each section in surface has a from and a to
that are fractions of the route, 0 at the start and 1 at the end, plus surface and highway.
The response says where the surface changes and leaves the cutting to you. measure builds the
cumulative length along the line, and slice returns the part between two fractions, with both
ends interpolated so neighboring pieces meet without gaps.
measure scales longitude by the cosine of the latitude instead of computing great-circle
distances. The fractions only need the proportions along one line, and the approximation is
accurate enough for that.
An array per line. The enhanced geometry is a MultiLineString, and surface holds one list
of sections per line, in the same order. That is why the code walks the lines and reads
enhanced.surface[i] for each. Using surface[0] for the whole route only works while the route
is one unbroken line.
Styled per feature. The pieces form one GeoJSON FeatureCollection, and the style function
of L.geoJSON picks each piece’s color from its surface property. The same collection, drawn
first in white and wider, is the casing. Hover a piece to see its surface and road type in a
tooltip. The whole calculation works in GeoJSON’s [lng, lat]; only L.geoJSON converts it for
the map.
other means unknown. It shows up where a section could not be matched confidently enough
to read its surface. Label it as unknown instead of counting it as unpaved, which would invent
data. Sections under 1 % are left out of the breakdown so it isn’t cluttered with slivers.
Next steps
The breakdown is what people act on: filtering a list of tours down to those with more than 90 % asphalt only needs the percentages this example already computes.
The Route Enhancement API can return elevation in the same request as surface. For ascent and descent from the Elevation API, see Show Climb Statistics for a Route.
The same example in Maptoolkit Maps JS is Style a Route by Surface Type.