Draw Travel Time Bands in MapLibre GL JS
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 MapLibre GL JS as stacked bands with a legend.
const API_KEY = "YOUR_API_KEY";
const ORIGIN = [16.3722, 48.2082];
// Largest first: the bands are drawn in this order, so the smallest ends up on top.
const BANDS = [
{ minutes: 15, color: "#c6dbef" },
{ minutes: 10, color: "#6baed6" },
{ minutes: 5, color: "#2171b5" },
];
const map = new maplibregl.Map({
container: "map",
style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
center: ORIGIN,
zoom: 13,
attributionControl: { compact: false },
});
map.addControl(new maplibregl.NavigationControl(), "top-right");
// 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[1]},${ORIGIN[0]}`);
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}`);
const feature = await response.json();
return { ...feature, properties: { minutes } };
}
// "style.load" fires as soon as the style is parsed; "load" would also wait for every tile.
map.once("style.load", async () => {
const legend = document.getElementById("legend");
try {
const features = await Promise.all(BANDS.map((band) => isochrone(band.minutes)));
map.addSource("bands", { type: "geojson", data: { type: "FeatureCollection", features } });
const color = ["match", ["get", "minutes"], ...BANDS.flatMap((band) => [band.minutes, band.color]), "#cccccc"];
// Below the first label layer, so street names stay readable on top of the bands.
const firstSymbolId = map.getStyle().layers.find((layer) => layer.type === "symbol")?.id;
map.addLayer({ id: "bands-fill", type: "fill", source: "bands", paint: { "fill-color": color, "fill-opacity": 0.55 } }, firstSymbolId);
map.addLayer({ id: "bands-line", type: "line", source: "bands", paint: { "line-color": color, "line-width": 1.5 } }, firstSymbolId);
new maplibregl.Marker({ color: "#303f7e" }).setLngLat(ORIGIN).addTo(map);
legend.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.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/maplibre-gl@5.6.1/dist/maplibre-gl.css" />
<script src="https://cdn.jsdelivr.net/npm/maplibre-gl@5.6.1/dist/maplibre-gl.js"></script>
<style>
html, body { width: 100%; height: 100%; margin: 0; padding: 0; }
#map { width: 100%; height: 100%; }
#legend {
position: absolute; top: 12px; left: 12px; z-index: 1; 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>
<div id="legend">Loading...</div>
<script>
const API_KEY = "YOUR_API_KEY";
const ORIGIN = [16.3722, 48.2082];
// Largest first: the bands are drawn in this order, so the smallest ends up on top.
const BANDS = [
{ minutes: 15, color: "#c6dbef" },
{ minutes: 10, color: "#6baed6" },
{ minutes: 5, color: "#2171b5" },
];
const map = new maplibregl.Map({
container: "map",
style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
center: ORIGIN,
zoom: 13,
attributionControl: { compact: false },
});
map.addControl(new maplibregl.NavigationControl(), "top-right");
// 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[1]},${ORIGIN[0]}`);
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}`);
const feature = await response.json();
return { ...feature, properties: { minutes } };
}
// "style.load" fires as soon as the style is parsed; "load" would also wait for every tile.
map.once("style.load", async () => {
const legend = document.getElementById("legend");
try {
const features = await Promise.all(BANDS.map((band) => isochrone(band.minutes)));
map.addSource("bands", { type: "geojson", data: { type: "FeatureCollection", features } });
const color = ["match", ["get", "minutes"], ...BANDS.flatMap((band) => [band.minutes, band.color]), "#cccccc"];
// Below the first label layer, so street names stay readable on top of the bands.
const firstSymbolId = map.getStyle().layers.find((layer) => layer.type === "symbol")?.id;
map.addLayer({ id: "bands-fill", type: "fill", source: "bands", paint: { "fill-color": color, "fill-opacity": 0.55 } }, firstSymbolId);
map.addLayer({ id: "bands-line", type: "line", source: "bands", paint: { "line-color": color, "line-width": 1.5 } }, firstSymbolId);
new maplibregl.Marker({ color: "#303f7e" }).setLngLat(ORIGIN).addTo(map);
legend.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.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 polygons overlap, they are not rings. The 15 minute area contains the 10 minute area,
which contains the 5 minute area. In a single fill layer, features are drawn in the order of
the collection, so BANDS is listed largest first and the 5 minute band ends up on top. In the
opposite order, the 15 minute band would cover the others.
Because they overlap, fill-opacity 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.
One layer for all bands. format=geojson returns a Feature, and isochrone() sets its
minutes property. A single match expression then colors every band from its minutes, and
the outline layer reuses it so each edge has the color of its band.
Below the labels. Passing the first symbol layer’s id as the second argument to addLayer
inserts the bands under it, so street names stay readable.
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.