Draw Travel Time Bands in Leaflet
One isochrone answers a yes or no question: can I get there in ten minutes. Three of them, shaded from dark to light, show how reachability falls off with distance, which is what a catchment map is for. This example requests 5, 10 and 15 minute walking isochrones for the same point from the Maptoolkit Isochrone API and draws them in Leaflet as stacked bands with a legend.
const API_KEY = "YOUR_API_KEY";
const ORIGIN = [48.2082, 16.3722];
// Largest first: Leaflet draws layers in the order they are added, so the smallest ends up on top.
const BANDS = [
{ minutes: 15, color: "#c6dbef" },
{ minutes: 10, color: "#6baed6" },
{ minutes: 5, color: "#2171b5" },
];
const map = L.map("map", { zoomControl: false }).setView(ORIGIN, 14);
L.control.zoom({ position: "topright" }).addTo(map);
L.tileLayer(`https://rtc-cdn.maptoolkit.net/rtc/maptoolkit-maptoolkit.summer/{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...";
// One request per band: the API returns one polygon per call.
async function isochrone(minutes) {
const url = new URL("https://routing.maptoolkit.net/isochrone");
url.searchParams.set("point", ORIGIN.join(","));
url.searchParams.set("time", minutes);
url.searchParams.set("routeType", "foot");
url.searchParams.set("format", "geojson");
url.searchParams.set("api_key", API_KEY);
const response = await fetch(url);
if (!response.ok) throw new Error(`Isochrone API returned ${response.status}`);
return response.json();
}
Promise.all(BANDS.map((band) => isochrone(band.minutes)))
.then((features) => {
features.forEach((feature, i) => {
L.geoJSON(feature, {
style: { color: BANDS[i].color, weight: 1.5, opacity: 1, fillColor: BANDS[i].color, fillOpacity: 0.55 },
interactive: false,
}).addTo(map);
});
L.marker(ORIGIN).addTo(map);
legend.getContainer().innerHTML = "<strong>Walking time</strong>" + BANDS.toReversed()
.map((band) => `<div class="row"><span class="swatch" style="background:${band.color}"></span>${band.minutes} min</div>`)
.join("");
})
.catch(() => { legend.getContainer().textContent = "Could not load the isochrones."; });<!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%; }
.legend {
padding: 10px 12px; background: #fff; border-radius: 10px; box-shadow: 0 4px 20px rgba(20, 30, 60, 0.18);
font: 13px/1.6 system-ui, sans-serif; color: #1f2430;
}
.legend strong { display: block; margin-bottom: 2px; }
.legend .row { display: flex; align-items: center; gap: 8px; }
.legend .swatch { width: 14px; height: 14px; border-radius: 3px; }
</style>
</head>
<body>
<div id="map"></div>
<script>
const API_KEY = "YOUR_API_KEY";
const ORIGIN = [48.2082, 16.3722];
// Largest first: Leaflet draws layers in the order they are added, so the smallest ends up on top.
const BANDS = [
{ minutes: 15, color: "#c6dbef" },
{ minutes: 10, color: "#6baed6" },
{ minutes: 5, color: "#2171b5" },
];
const map = L.map("map", { zoomControl: false }).setView(ORIGIN, 14);
L.control.zoom({ position: "topright" }).addTo(map);
L.tileLayer(`https://rtc-cdn.maptoolkit.net/rtc/maptoolkit-maptoolkit.summer/{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...";
// One request per band: the API returns one polygon per call.
async function isochrone(minutes) {
const url = new URL("https://routing.maptoolkit.net/isochrone");
url.searchParams.set("point", ORIGIN.join(","));
url.searchParams.set("time", minutes);
url.searchParams.set("routeType", "foot");
url.searchParams.set("format", "geojson");
url.searchParams.set("api_key", API_KEY);
const response = await fetch(url);
if (!response.ok) throw new Error(`Isochrone API returned ${response.status}`);
return response.json();
}
Promise.all(BANDS.map((band) => isochrone(band.minutes)))
.then((features) => {
features.forEach((feature, i) => {
L.geoJSON(feature, {
style: { color: BANDS[i].color, weight: 1.5, opacity: 1, fillColor: BANDS[i].color, fillOpacity: 0.55 },
interactive: false,
}).addTo(map);
});
L.marker(ORIGIN).addTo(map);
legend.getContainer().innerHTML = "<strong>Walking time</strong>" + BANDS.toReversed()
.map((band) => `<div class="row"><span class="swatch" style="background:${band.color}"></span>${band.minutes} min</div>`)
.join("");
})
.catch(() => { legend.getContainer().textContent = "Could not load the isochrones."; });
</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 request per band. The Isochrone API returns one polygon per call.
Repeating time, passing time=5,10,15 or adding buckets all still return a single polygon.
Promise.all sends the three requests in parallel, so three bands take about as long as one.
The request takes lat,lng, the response is [lng, lat]. point is latitude first, the
same order as Leaflet’s own coordinates, so ORIGIN is passed as it is. The polygon comes back
as GeoJSON in [longitude, latitude] order, and L.geoJSON converts it for you.
The polygons overlap, they are not rings. The 15 minute area contains the 10 minute area,
which contains the 5 minute area. Leaflet draws vector layers in the order they are added, so
BANDS is listed largest first and the 5 minute band ends up on top. Promise.all keeps the
results in the order of the requests, whichever one answers first.
Because they overlap, fillOpacity adds up where bands stack, and the inner band looks darker
than its own color. That helps the reading here. If the colors on the map must match the legend
exactly, cut each band into a ring by subtracting the next smaller one, which needs a geometry
library such as Turf.
interactive: false. The bands do not react to the mouse, so clicks and drags reach the
map, and the cursor does not turn into a pointer over them.
The bands are drawn on top of the raster tiles, labels included, since the tiles are single images. The 0.55 opacity keeps the street names underneath readable. To draw bands below the labels, use vector tiles in Leaflet or a vector map such as the MapLibre version.
Next steps
To test which of your own locations fall inside an area, see Find Locations Inside an Isochrone. With several bands, the same test tells you which band each location falls in.
routeType changes the shape more than the minutes do: 15 minutes on foot and 15 minutes by
bike are different areas. The values are in the
Isochrone API reference.
The same example in Maptoolkit Maps JS is Draw Travel Time Bands.