Skip to content

Draw Travel Time Bands in Maptoolkit Maps JS

One isochrone answers a yes or no question: can I reach this in ten minutes. Three of them, shaded from dark to light, show how reachability falls away with distance, which is what a catchment map is actually for. This example requests 5, 10 and 15 minute walking isochrones from the same point and draws them as stacked bands with a legend.

const API_KEY = 'YOUR_API_KEY';

    const ORIGIN = [16.3722, 48.2082];
    const PROFILE = 'foot';

    // 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 maptoolkit.Map({
        container: 'map',
        apiKey: API_KEY,
        style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
        center: ORIGIN,
        zoom: 13,
        attributionControl: { compact: false }
    });

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

    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', PROFILE);
        url.searchParams.set('format', 'geojson');
        url.searchParams.set('api_key', API_KEY);

        return fetch(url).then(r => {
            if (!r.ok) throw new Error(`Isochrone API returned ${r.status}`);
            return r.json();
        });
    }

    map.on('load', () => {
        // One request per band. The API returns a single polygon per call.
        Promise.all(BANDS.map(b => isochrone(b.minutes)))
            .then(features => {
                const collection = {
                    type: 'FeatureCollection',
                    features: features.map((feature, i) => ({
                        ...feature,
                        properties: { ...feature.properties, minutes: BANDS[i].minutes }
                    }))
                };

                map.addSource('bands', { type: 'geojson', data: collection });

                const colorByMinutes = [
                    'match', ['get', 'minutes'],
                    5, BANDS[2].color,
                    10, BANDS[1].color,
                    15, BANDS[0].color,
                    '#cccccc'
                ];

                map.addLayer({
                    id: 'bands-fill',
                    type: 'fill',
                    source: 'bands',
                    paint: { 'fill-color': colorByMinutes, 'fill-opacity': 0.55 }
                });

                map.addLayer({
                    id: 'bands-outline',
                    type: 'line',
                    source: 'bands',
                    paint: { 'line-color': colorByMinutes, 'line-width': 1.5 }
                });

                new maptoolkit.Marker().setLngLat(ORIGIN).addTo(map);

                document.getElementById('legend').innerHTML = BANDS
                    .slice()
                    .sort((a, b) => a.minutes - b.minutes)
                    .map(b => `<div class="row"><span class="sw" style="background:${b.color}"></span><span>${b.minutes} min on foot</span></div>`)
                    .join('');
            })
            .catch(() => { document.getElementById('legend').textContent = 'Could not load the isochrones.'; });
    });
<!DOCTYPE html>
<html lang="en">
<head>
    <title>Travel Time Bands - Maptoolkit Maps JS</title>
    <meta property="og:description" content="Stack several isochrones as shaded travel time bands." />
    <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; bottom: 30px; left: 10px; z-index: 999;
            background: #fff; border-radius: 6px; box-shadow: 0 0 15px #68686880;
            font: 13px/1.5 system-ui, sans-serif; padding: 10px 12px;
        }
        #legend .row { display: flex; align-items: center; gap: 8px; }
        #legend .sw { width: 14px; height: 14px; border-radius: 3px; flex: none; }
    </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];
    const PROFILE = 'foot';

    // 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 maptoolkit.Map({
        container: 'map',
        apiKey: API_KEY,
        style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
        center: ORIGIN,
        zoom: 13,
        attributionControl: { compact: false }
    });

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

    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', PROFILE);
        url.searchParams.set('format', 'geojson');
        url.searchParams.set('api_key', API_KEY);

        return fetch(url).then(r => {
            if (!r.ok) throw new Error(`Isochrone API returned ${r.status}`);
            return r.json();
        });
    }

    map.on('load', () => {
        // One request per band. The API returns a single polygon per call.
        Promise.all(BANDS.map(b => isochrone(b.minutes)))
            .then(features => {
                const collection = {
                    type: 'FeatureCollection',
                    features: features.map((feature, i) => ({
                        ...feature,
                        properties: { ...feature.properties, minutes: BANDS[i].minutes }
                    }))
                };

                map.addSource('bands', { type: 'geojson', data: collection });

                const colorByMinutes = [
                    'match', ['get', 'minutes'],
                    5, BANDS[2].color,
                    10, BANDS[1].color,
                    15, BANDS[0].color,
                    '#cccccc'
                ];

                map.addLayer({
                    id: 'bands-fill',
                    type: 'fill',
                    source: 'bands',
                    paint: { 'fill-color': colorByMinutes, 'fill-opacity': 0.55 }
                });

                map.addLayer({
                    id: 'bands-outline',
                    type: 'line',
                    source: 'bands',
                    paint: { 'line-color': colorByMinutes, 'line-width': 1.5 }
                });

                new maptoolkit.Marker().setLngLat(ORIGIN).addTo(map);

                document.getElementById('legend').innerHTML = BANDS
                    .slice()
                    .sort((a, b) => a.minutes - b.minutes)
                    .map(b => `<div class="row"><span class="sw" style="background:${b.color}"></span><span>${b.minutes} min on foot</span></div>`)
                    .join('');
            })
            .catch(() => { document.getElementById('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.

Use the Maptoolkit Connector. Create an interactive map centered on Vienna. Request 5, 10 and 15 minute walking isochrones from [16.3722, 48.2082] and draw them as stacked shaded bands from dark to light, with a legend.

How it works

Bands are one request each. The Isochrone API returns a single polygon per call, and there is no parameter that asks for several at once: repeating time, passing time=5,10,15 and adding buckets all return one polygon. Promise.all sends the three in parallel, so three bands cost the latency of one.

The polygons overlap rather than nest as rings. The 15 minute shape contains the 10 minute shape, which contains the 5 minute shape, so drawing order decides what you see. BANDS is listed largest first and the features enter the collection in that order, which puts the smallest on top. Reverse it and the 15 minute band covers everything and the map reads as one flat blob.

Because they overlap, fill-opacity compounds where bands stack. At 0.55 the innermost band is three layers deep and reads much darker than its own colour, which happens to help here. If you need the colours to be exactly what the legend shows, cut each band to a true ring by subtracting the next one down, which is a polygon difference and needs a geometry library.

format=geojson returns a Feature, not a bare geometry, so each response drops into the FeatureCollection as-is. Spreading it and adding minutes is what lets one match expression colour all three from a single layer. The properties.bucket that comes back is always 0 on a single-time request, so it is not usable as the band index.

The outline layer repeats the same expression rather than using one colour. A band edge in its own colour stays readable where bands are close together, which a single grey outline does not.

Next steps

A band map invites a question about your own data, and answering it is testing which of your locations fall inside the shape. That is what turns three polygons into a site-selection tool.

routeType changes the answer more than the times do. Fifteen minutes on foot and fifteen by bike are different questions, and the values in the Isochrone API reference are what let a user switch between them. Maps JS also ships an IsochroneControl that wraps a single band in one line, which is the shorter path when you do not need the stack.