Skip to content
Route by Surface Type

Style a Route by Surface Type in Maptoolkit Maps JS

A cyclist choosing between two routes wants to know where the asphalt ends. The Route Enhancement API returns the road surface and highway type under each part of a route, as positions along the line rather than as geometry, so the work on your side is cutting the route at those positions and colouring each piece. This example requests a bike route, enhances it with surface data, and draws it as a single layer coloured by 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 = new maptoolkit.Map({
        container: 'map',
        apiKey: API_KEY,
        style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.cycling.json?api_key=${API_KEY}`,
        center: [12.401, 47.453],
        zoom: 13.5,
        attributionControl: { compact: false }
    });

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

    // Cumulative distance along the line, so a fraction can be turned into a position.
    function measure(coords) {
        const cum = [0];
        for (let i = 1; i < coords.length; i++) {
            const [x1, y1] = coords[i - 1], [x2, y2] = coords[i];
            const dx = (x2 - x1) * Math.cos((y1 + y2) / 2 * Math.PI / 180);
            cum.push(cum[i - 1] + Math.hypot(dx, y2 - y1));
        }
        return cum;
    }

    // Cut the line between two fractions, interpolating both ends so the pieces meet.
    function slice(coords, cum, fromFrac, toFrac) {
        const total = cum[cum.length - 1];
        const d0 = fromFrac * total, d1 = toFrac * total;

        const pointAt = (d) => {
            let i = 1;
            while (i < cum.length - 1 && cum[i] < d) i++;
            const span = cum[i] - cum[i - 1];
            const t = span > 0 ? (d - cum[i - 1]) / span : 0;
            return [
                coords[i - 1][0] + (coords[i][0] - coords[i - 1][0]) * t,
                coords[i - 1][1] + (coords[i][1] - coords[i - 1][1]) * t
            ];
        };

        const out = [pointAt(d0)];
        for (let i = 0; i < coords.length; i++) {
            if (cum[i] > d0 && cum[i] < d1) out.push(coords[i]);
        }
        out.push(pointAt(d1));
        return out;
    }

    function renderBreakdown(segments) {
        const share = {};
        for (const s of segments) share[s.surface] = (share[s.surface] || 0) + (s.to - s.from);

        document.getElementById('breakdown').innerHTML = Object.entries(share)
            // Drop slivers that would round to 0% and clutter the legend.
            .filter(([, fraction]) => Math.round(fraction * 100) >= 1)
            .sort((a, b) => b[1] - a[1])
            .map(([surface, fraction]) => `
                <div class="row">
                  <span class="sw" style="background:${SURFACE_COLORS[surface] || SURFACE_COLORS.other}"></span>
                  <span>${surface === 'other' ? 'unknown' : surface}</span>
                  <span class="pc">${Math.round(fraction * 100)}%</span>
                </div>`)
            .join('');
    }

    map.on('load', () => {
        // points_encoded=false returns GeoJSON directly, so no polyline decoding is needed.
        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.append('routeType', 'bike');
        routeUrl.searchParams.append('points_encoded', 'false');
        routeUrl.searchParams.append('api_key', API_KEY);

        fetch(routeUrl)
            .then(r => r.json())
            .then(route => {
                const line = route.paths[0].points;

                // POST, because a decoded route is too long for a query string.
                const body = new URLSearchParams({
                    geometry: JSON.stringify(line),
                    surface: '1',
                    routeType: 'bike'
                });

                return fetch(`https://enhance.maptoolkit.net/route?api_key=${API_KEY}`, {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
                    body
                })
                    .then(r => r.json())
                    .then(enhanced => {
                        // One surface array per geometry segment, in the same order.
                        const parts = enhanced.geometry.coordinates;
                        const features = [];

                        parts.forEach((coords, i) => {
                            const flat = coords.map(([lng, lat]) => [lng, lat]);
                            const cum = measure(flat);
                            for (const seg of enhanced.surface[i] || []) {
                                features.push({
                                    type: 'Feature',
                                    properties: { surface: seg.surface, highway: seg.highway },
                                    geometry: { type: 'LineString', coordinates: slice(flat, cum, seg.from, seg.to) }
                                });
                            }
                        });

                        map.addLayer({
                            id: 'route-surface',
                            type: 'line',
                            source: { type: 'geojson', data: { type: 'FeatureCollection', features } },
                            layout: { 'line-join': 'round', 'line-cap': 'round' },
                            paint: {
                                'line-width': 6,
                                'line-color': [
                                    'match', ['get', 'surface'],
                                    'asphalt', SURFACE_COLORS.asphalt,
                                    'paved',   SURFACE_COLORS.paved,
                                    'unpaved', SURFACE_COLORS.unpaved,
                                    'natural', SURFACE_COLORS.natural,
                                    'alpine',  SURFACE_COLORS.alpine,
                                    SURFACE_COLORS.other
                                ]
                            }
                        });

                        renderBreakdown(enhanced.surface.flat());

                        const bbox = route.paths[0].bbox;
                        map.fitBounds([[bbox[0], bbox[1]], [bbox[2], bbox[3]]], { padding: 60 });
                    });
            })
            .catch(() => { document.getElementById('breakdown').textContent = 'Could not load the route.'; });
    });
<!DOCTYPE html>
<html lang="en">
<head>
    <title>Route by Surface Type - Maptoolkit Maps JS</title>
    <meta property="og:description" content="Colour a route by the surface it runs on." />
    <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%; }
        #breakdown {
            position: absolute; top: 10px; 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; min-width: 150px;
        }
        #breakdown .row { display: flex; align-items: center; gap: 8px; }
        #breakdown .sw { width: 14px; height: 4px; border-radius: 2px; flex: none; }
        #breakdown .pc { margin-left: auto; font-variant-numeric: tabular-nums; color: #555; }
    </style>
</head>
<body>
<div id="map"></div>
<div id="breakdown">Loading route...</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 = new maptoolkit.Map({
        container: 'map',
        apiKey: API_KEY,
        style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.cycling.json?api_key=${API_KEY}`,
        center: [12.401, 47.453],
        zoom: 13.5,
        attributionControl: { compact: false }
    });

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

    // Cumulative distance along the line, so a fraction can be turned into a position.
    function measure(coords) {
        const cum = [0];
        for (let i = 1; i < coords.length; i++) {
            const [x1, y1] = coords[i - 1], [x2, y2] = coords[i];
            const dx = (x2 - x1) * Math.cos((y1 + y2) / 2 * Math.PI / 180);
            cum.push(cum[i - 1] + Math.hypot(dx, y2 - y1));
        }
        return cum;
    }

    // Cut the line between two fractions, interpolating both ends so the pieces meet.
    function slice(coords, cum, fromFrac, toFrac) {
        const total = cum[cum.length - 1];
        const d0 = fromFrac * total, d1 = toFrac * total;

        const pointAt = (d) => {
            let i = 1;
            while (i < cum.length - 1 && cum[i] < d) i++;
            const span = cum[i] - cum[i - 1];
            const t = span > 0 ? (d - cum[i - 1]) / span : 0;
            return [
                coords[i - 1][0] + (coords[i][0] - coords[i - 1][0]) * t,
                coords[i - 1][1] + (coords[i][1] - coords[i - 1][1]) * t
            ];
        };

        const out = [pointAt(d0)];
        for (let i = 0; i < coords.length; i++) {
            if (cum[i] > d0 && cum[i] < d1) out.push(coords[i]);
        }
        out.push(pointAt(d1));
        return out;
    }

    function renderBreakdown(segments) {
        const share = {};
        for (const s of segments) share[s.surface] = (share[s.surface] || 0) + (s.to - s.from);

        document.getElementById('breakdown').innerHTML = Object.entries(share)
            // Drop slivers that would round to 0% and clutter the legend.
            .filter(([, fraction]) => Math.round(fraction * 100) >= 1)
            .sort((a, b) => b[1] - a[1])
            .map(([surface, fraction]) => `
                <div class="row">
                  <span class="sw" style="background:${SURFACE_COLORS[surface] || SURFACE_COLORS.other}"></span>
                  <span>${surface === 'other' ? 'unknown' : surface}</span>
                  <span class="pc">${Math.round(fraction * 100)}%</span>
                </div>`)
            .join('');
    }

    map.on('load', () => {
        // points_encoded=false returns GeoJSON directly, so no polyline decoding is needed.
        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.append('routeType', 'bike');
        routeUrl.searchParams.append('points_encoded', 'false');
        routeUrl.searchParams.append('api_key', API_KEY);

        fetch(routeUrl)
            .then(r => r.json())
            .then(route => {
                const line = route.paths[0].points;

                // POST, because a decoded route is too long for a query string.
                const body = new URLSearchParams({
                    geometry: JSON.stringify(line),
                    surface: '1',
                    routeType: 'bike'
                });

                return fetch(`https://enhance.maptoolkit.net/route?api_key=${API_KEY}`, {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
                    body
                })
                    .then(r => r.json())
                    .then(enhanced => {
                        // One surface array per geometry segment, in the same order.
                        const parts = enhanced.geometry.coordinates;
                        const features = [];

                        parts.forEach((coords, i) => {
                            const flat = coords.map(([lng, lat]) => [lng, lat]);
                            const cum = measure(flat);
                            for (const seg of enhanced.surface[i] || []) {
                                features.push({
                                    type: 'Feature',
                                    properties: { surface: seg.surface, highway: seg.highway },
                                    geometry: { type: 'LineString', coordinates: slice(flat, cum, seg.from, seg.to) }
                                });
                            }
                        });

                        map.addLayer({
                            id: 'route-surface',
                            type: 'line',
                            source: { type: 'geojson', data: { type: 'FeatureCollection', features } },
                            layout: { 'line-join': 'round', 'line-cap': 'round' },
                            paint: {
                                'line-width': 6,
                                'line-color': [
                                    'match', ['get', 'surface'],
                                    'asphalt', SURFACE_COLORS.asphalt,
                                    'paved',   SURFACE_COLORS.paved,
                                    'unpaved', SURFACE_COLORS.unpaved,
                                    'natural', SURFACE_COLORS.natural,
                                    'alpine',  SURFACE_COLORS.alpine,
                                    SURFACE_COLORS.other
                                ]
                            }
                        });

                        renderBreakdown(enhanced.surface.flat());

                        const bbox = route.paths[0].bbox;
                        map.fitBounds([[bbox[0], bbox[1]], [bbox[2], bbox[3]]], { padding: 60 });
                    });
            })
            .catch(() => { document.getElementById('breakdown').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.

Use the Maptoolkit Connector. Create an interactive map with a bike route from [12.3920, 47.4460] to [12.4100, 47.4600]. Enhance it with surface data from the Route Enhancement API, split the line at the surface changes, colour each piece by surface type and show a percentage breakdown.

How it works

points_encoded=false on the Routing API returns the geometry as a GeoJSON LineString instead of an encoded polyline. That removes the polyline library, and more usefully it removes the coordinate flip: the decoded form is already [lng, lat], so the same array goes straight into the enhancement request and into the map.

The enhancement goes by POST. A decoded route is a few thousand characters of JSON, which is more than a query string should carry, and the Route Enhancement API accepts the same parameters as a form body.

Surface data comes back as positions, not geometry. Each entry has a from and a to that are fractions of the route, 0 at the start and 1 at the end, so the response tells you where the surface changes and leaves the cutting to you. That is what measure and slice do: one builds a cumulative distance along the line, the other returns the piece between two fractions, interpolating both ends so neighbouring pieces meet exactly instead of leaving gaps at every change.

measure scales longitude by cos(latitude) rather than using a full great-circle distance. The fractions only need the proportions along one line to be right, and the cheap approximation is accurate enough for that over the length of a route.

enhanced.surface is an array of arrays, one per segment of the MultiLineString the service returns, which is why the code iterates parts and indexes enhanced.surface[i]. Taking enhanced.surface[0] and applying it to the whole route is correct only when the route is a single unbroken segment.

The match expression colours the whole collection from one layer, with the last value as the fallback, so a surface type that is added later renders in grey rather than disappearing.

other means unknown, not a surface. It appears when a segment could not be map-matched with enough confidence to read a tag off it, and it is worth labelling as unknown in your interface rather than folding into unpaved, which would be inventing data. This route has none of it, which is what a well-tagged area looks like; a route over sparsely tagged mountain paths can come back more than half unknown. When that happens, loosening mapMatchingThreshold accepts imperfect matches and recovers some of it, at the cost of some confidence in the answer.

Next steps

The breakdown is the part people act on. Turning it into a filter, so a list of tours can be narrowed to those over ninety percent asphalt, is what the data is really for, and it needs only the percentages this already computes.

Surface answers what the route runs on; the other half of the question is how much it climbs. Ascent, descent and the steepest section come from the same route, and the Route Enhancement API can return elevation in the same call as surface rather than as a second request.