Skip to content

Snap a GPS Track to the Road Network in Maptoolkit Maps JS

A phone recording a walk produces a line that drifts across rivers, cuts corners and wanders into buildings. The Map Matching API snaps that trace onto the ways it was actually recorded on and returns a clean route. This example loads a real 5 km recording, matches it, and draws both lines so you can see what changed.

const API_KEY = 'YOUR_API_KEY';

    // Two URLs for one file, on purpose. The browser reads it same-origin to draw the raw
    // line; the service fetches it itself, so that one has to be absolute and public.
    const GPX_PATH = 'innsbruck-walk.gpx';
    const GPX_URL = 'https://docs.maptoolkit.com/demos/innsbruck-walk.gpx';

    const map = new maptoolkit.Map({
        container: 'map',
        apiKey: API_KEY,
        style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
        center: [11.40, 47.27],
        zoom: 14,
        attributionControl: { compact: false }
    });

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

    function line(id, coordinates, color, width, dash) {
        map.addLayer({
            id,
            type: 'line',
            source: { type: 'geojson', data: { type: 'Feature', geometry: { type: 'LineString', coordinates } } },
            layout: { 'line-join': 'round', 'line-cap': 'round' },
            paint: { 'line-color': color, 'line-width': width, ...(dash ? { 'line-dasharray': dash } : {}) }
        });
    }

    map.on('load', async () => {
        // The recorded trace, parsed in the browser purely so it can be drawn.
        const xml = await fetch(GPX_PATH).then(r => r.text());
        const doc = new DOMParser().parseFromString(xml, 'application/xml');
        const raw = [...doc.getElementsByTagName('trkpt')].map(pt => [
            Number(pt.getAttribute('lon')),
            Number(pt.getAttribute('lat'))
        ]);

        line('raw', raw, '#e8710a', 3, [2, 1.5]);

        // POST, because a long track does not fit in a query string. The api_key stays
        // in the URL: sent as a form field it is ignored and the request 403s.
        const body = new URLSearchParams({ gpx: GPX_URL, routeType: 'foot' });
        const response = await fetch(`https://routing.maptoolkit.net/match?api_key=${API_KEY}`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
            body
        });

        if (!response.ok) {
            document.getElementById('panel').textContent = `Matching failed: ${response.status}`;
            return;
        }

        const result = await response.json();
        const path = result.paths[0];
        const matched = polyline.decode(path.points).map(([lat, lng]) => [lng, lat]);

        line('matched', matched, '#1d6fb8', 4);

        map.fitBounds([[path.bbox[0], path.bbox[1]], [path.bbox[2], path.bbox[3]]], { padding: 50 });

        document.getElementById('panel').innerHTML = `
            <label><input type="checkbox" data-layer="raw" checked>
              <span class="sw" style="background:#e8710a"></span> Recorded trace</label>
            <label><input type="checkbox" data-layer="matched" checked>
              <span class="sw" style="background:#1d6fb8"></span> Map matched</label>
            <div class="stat">${raw.length} recorded points, matched to ${(path.distance / 1000).toFixed(2)} km</div>`;

        document.getElementById('panel').addEventListener('change', (e) => {
            map.setLayoutProperty(e.target.dataset.layer, 'visibility', e.target.checked ? 'visible' : 'none');
        });
    });
<!DOCTYPE html>
<html lang="en">
<head>
    <title>Snap a GPS Track - Maptoolkit Maps JS</title>
    <meta property="og:description" content="Snap a recorded GPS trace to the road network." />
    <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" />
    <script src="https://cdnjs.cloudflare.com/ajax/libs/mapbox-polyline/1.2.1/polyline.min.js"></script>
    <style>
        html, body { width: 100%; height: 100%; margin: 0; padding: 0; }
        #map { width: 100%; height: 100%; }
        #panel {
            position: absolute; top: 10px; left: 10px; z-index: 999;
            background: #fff; border-radius: 6px; box-shadow: 0 0 15px #68686880;
            font: 13px/1.6 system-ui, sans-serif; padding: 10px 12px;
        }
        #panel label { display: flex; align-items: center; gap: 8px; cursor: pointer; }
        #panel .sw { width: 16px; height: 4px; border-radius: 2px; flex: none; }
        #panel .stat { color: #666; font-size: 12px; margin-top: 6px; }
    </style>
</head>
<body>
<div id="map"></div>
<div id="panel">Matching track...</div>
<script>
    const API_KEY = 'YOUR_API_KEY';

    // Two URLs for one file, on purpose. The browser reads it same-origin to draw the raw
    // line; the service fetches it itself, so that one has to be absolute and public.
    const GPX_PATH = 'innsbruck-walk.gpx';
    const GPX_URL = 'https://docs.maptoolkit.com/demos/innsbruck-walk.gpx';

    const map = new maptoolkit.Map({
        container: 'map',
        apiKey: API_KEY,
        style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
        center: [11.40, 47.27],
        zoom: 14,
        attributionControl: { compact: false }
    });

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

    function line(id, coordinates, color, width, dash) {
        map.addLayer({
            id,
            type: 'line',
            source: { type: 'geojson', data: { type: 'Feature', geometry: { type: 'LineString', coordinates } } },
            layout: { 'line-join': 'round', 'line-cap': 'round' },
            paint: { 'line-color': color, 'line-width': width, ...(dash ? { 'line-dasharray': dash } : {}) }
        });
    }

    map.on('load', async () => {
        // The recorded trace, parsed in the browser purely so it can be drawn.
        const xml = await fetch(GPX_PATH).then(r => r.text());
        const doc = new DOMParser().parseFromString(xml, 'application/xml');
        const raw = [...doc.getElementsByTagName('trkpt')].map(pt => [
            Number(pt.getAttribute('lon')),
            Number(pt.getAttribute('lat'))
        ]);

        line('raw', raw, '#e8710a', 3, [2, 1.5]);

        // POST, because a long track does not fit in a query string. The api_key stays
        // in the URL: sent as a form field it is ignored and the request 403s.
        const body = new URLSearchParams({ gpx: GPX_URL, routeType: 'foot' });
        const response = await fetch(`https://routing.maptoolkit.net/match?api_key=${API_KEY}`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
            body
        });

        if (!response.ok) {
            document.getElementById('panel').textContent = `Matching failed: ${response.status}`;
            return;
        }

        const result = await response.json();
        const path = result.paths[0];
        const matched = polyline.decode(path.points).map(([lat, lng]) => [lng, lat]);

        line('matched', matched, '#1d6fb8', 4);

        map.fitBounds([[path.bbox[0], path.bbox[1]], [path.bbox[2], path.bbox[3]]], { padding: 50 });

        document.getElementById('panel').innerHTML = `
            <label><input type="checkbox" data-layer="raw" checked>
              <span class="sw" style="background:#e8710a"></span> Recorded trace</label>
            <label><input type="checkbox" data-layer="matched" checked>
              <span class="sw" style="background:#1d6fb8"></span> Map matched</label>
            <div class="stat">${raw.length} recorded points, matched to ${(path.distance / 1000).toFixed(2)} km</div>`;

        document.getElementById('panel').addEventListener('change', (e) => {
            map.setLayoutProperty(e.target.dataset.layer, 'visibility', e.target.checked ? 'visible' : 'none');
        });
    });
</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 that loads a GPX recording, sends it to the Map Matching API, and draws the raw recorded trace and the matched result as two lines with a toggle.

How it works

The response shape is identical to the Routing API, so anything that already renders a route renders a matched track with no changes. paths[0].points is an encoded polyline, bbox and distance are where you expect them, and instructions are there too.

polyline.decode returns [lat, lng] and everything on the map is [lng, lat], so the result is mapped before use. The GPX parse produces [lng, lat] directly because the attributes are read in that order deliberately.

The api_key belongs in the query string, even on a POST. Passed as a form field it is not read and the request fails with 403 Access denied!, which looks like a key problem rather than a placement problem.

The service fetches the GPX itself, so the URL passed in gpx has to be reachable from the public internet. A file behind a login, on localhost or on a private network fails with Request failed with status code 404. That is why this example carries two URLs for one file: the browser reads it with a relative path, which stays same-origin and needs no CORS header, while the service gets the absolute one. Fetching the absolute URL from the browser instead fails with a CORS error on any page not served from that host. To match a track the browser already holds, send repeated point parameters instead of gpx.

Two parameters bite. point is lat,lng, latitude first, the opposite of GeoJSON. And instructions=false returns 400 No map-matching possible for your track., blaming the track rather than the parameter; omit it or set it to true.

Matching is heavier than routing, so give the call a generous timeout. Tracks are also checked against a maximum length and rejected with a 400 rather than truncated, so split a long recording into segments and match them separately.

Drawing the raw trace dashed underneath the matched line is worth keeping in production tooling. It is the only way to see whether the matcher put the track on the right path, and on a parallel cycleway beside a road it sometimes does not.

Next steps

Do not reach for this to clean a route you calculated. The Routing API already returns network geometry, and matching it adds nothing. This is for recordings.

For elevation and surface along the same track, the Route Enhancement API does its own matching and returns the enrichment with it, which is one call instead of two. A matched track also carries instructions, so the same response drives a turn-by-turn list of what the recording actually did.