|
| 1 | +# GPX Map Matching (Snap to Roads) with Geoapify & MapLibre GL |
| 2 | + |
| 3 | +This demo shows how to take GPS track data from a GPX file and snap it to the road network using the |
| 4 | +[Geoapify Map Matching API](https://www.geoapify.com/map-matching-api/). |
| 5 | +The original GPS trace and the matched route are displayed on an interactive map powered by MapLibre GL, |
| 6 | +with travel mode selection, route summary, and download options. |
| 7 | + |
| 8 | +## Features |
| 9 | + |
| 10 | +- Upload a GPX file and visualize its raw GPS trace |
| 11 | +- Snap the GPS trace to the road network using the [Geoapify Map Matching API](https://www.geoapify.com/map-matching-api/) |
| 12 | +- Choose travel mode (walking, driving, cycling) |
| 13 | +- View road attributes such as road class, surface, speed limit, and lanes |
| 14 | +- Compare original and matched route distance and time |
| 15 | +- Download matched routes as **GeoJSON** or **GPX** |
| 16 | + |
| 17 | + |
| 18 | + |
| 19 | +## Demo |
| 20 | + |
| 21 | +Run the standalone demo from GitHub Pages: |
| 22 | +**[Open Demo](https://geoapify.github.io/maps-api-code-samples/javascript/map-matching-to-snap-gpx-to-roads-maplibre/demo_combined.html)** |
| 23 | + |
| 24 | +## APIs and Libraries Used |
| 25 | + |
| 26 | +- [Geoapify Map Matching API](https://www.geoapify.com/map-matching-api/) – snaps raw GPS traces to the road network |
| 27 | +- [MapLibre GL JS](https://maplibre.org/) – renders the interactive vector map |
| 28 | +- [toGeoJSON](https://github.com/mapbox/togeojson) – converts GPX to GeoJSON format |
| 29 | +- [togpx](https://www.npmjs.com/package/togpx) – converts GeoJSON to GPX format |
| 30 | +- [Simplify.js](https://mourner.github.io/simplify-js/) – reduces the number of GPS points for optimal performance |
| 31 | +- [Font Awesome](https://fontawesome.com/) – icons for the UI |
| 32 | + |
| 33 | +## How to Run the Sample |
| 34 | + |
| 35 | +You can run the demo locally or deploy it using GitHub Pages. |
| 36 | + |
| 37 | +### Option 1: Run Locally with Static Server |
| 38 | + |
| 39 | +1. **Install `http-server`** (if not installed globally): |
| 40 | + |
| 41 | +```bash |
| 42 | +npm install -g http-server |
| 43 | +``` |
| 44 | + |
| 45 | +2. **Start the server** from the `src` folder: |
| 46 | + |
| 47 | +```bash |
| 48 | +http-server ./src |
| 49 | +``` |
| 50 | + |
| 51 | +3. **Open the app** in your browser: |
| 52 | + |
| 53 | +``` |
| 54 | +http://localhost:8080/demo.html |
| 55 | +``` |
| 56 | + |
| 57 | +### Option 2: Use IDE Live Preview |
| 58 | + |
| 59 | +If you use VS Code, WebStorm, or another IDE with live server support: |
| 60 | + |
| 61 | +* **VS Code:** Install "Live Server" extension → Right-click `demo.html` → "Open with Live Server" |
| 62 | +* **WebStorm / IntelliJ:** Right-click file → "Open in browser" |
| 63 | + |
| 64 | +> Do not open via `file://` path as it blocks dynamic imports and module loading. |
| 65 | +
|
| 66 | +## How to Build `demo_combined.html` |
| 67 | + |
| 68 | +To create a standalone HTML file (ideal for GitHub Pages or email demos): |
| 69 | + |
| 70 | +1. **Navigate to the root folder** (e.g., `javascript/map-matching-to-snap-gpx-to-roads-maplibre/`): |
| 71 | + |
| 72 | +```bash |
| 73 | +cd javascript |
| 74 | +``` |
| 75 | + |
| 76 | +2. **Install `inline-source`** (once): |
| 77 | + |
| 78 | +```bash |
| 79 | +npm install inline-source |
| 80 | +``` |
| 81 | + |
| 82 | +3. **Run the combine script:** |
| 83 | + |
| 84 | +```bash |
| 85 | +node map-matching-to-snap-gpx-to-roads-maplibre/combine.js |
| 86 | +``` |
| 87 | + |
| 88 | +This will create a `demo_combined.html` with all scripts and styles inlined. |
| 89 | + |
| 90 | +## Code Highlights |
| 91 | + |
| 92 | +### 1. Loading a GPX File |
| 93 | + |
| 94 | +When a user selects a .gpx file, the app uses the FileReader API to read it as plain text. |
| 95 | +This text contains XML data describing GPS tracks. Once loaded, the content is passed to a custom parsing function. |
| 96 | + |
| 97 | +```javascript |
| 98 | +const reader = new FileReader(); |
| 99 | +reader.onload = (e) => { |
| 100 | + // GPX file content as text |
| 101 | + const gpxContent = e.target.result; |
| 102 | + |
| 103 | + // Convert GPX → GeoJSON and extract track points |
| 104 | + const result = fileUtils.parseGpxFile(gpxContent); |
| 105 | + |
| 106 | + // ... |
| 107 | +}; |
| 108 | +reader.readAsText(file); |
| 109 | +``` |
| 110 | + |
| 111 | +The `parseGpxFile()` helper method converts GPX XML into the widely used GeoJSON format using the toGeoJSON library: |
| 112 | + |
| 113 | +```javascript |
| 114 | +parseGpxFile(gpxContent) { |
| 115 | + // Parse GPX XML content |
| 116 | + const parser = new DOMParser(); |
| 117 | + const xmlDoc = parser.parseFromString(gpxContent, 'application/xml'); |
| 118 | + |
| 119 | + // Convert GPX → GeoJSON |
| 120 | + const geoJson = toGeoJSON.gpx(xmlDoc); |
| 121 | + |
| 122 | + // Normalize and optionally simplify |
| 123 | + // ... |
| 124 | +} |
| 125 | +``` |
| 126 | + |
| 127 | +**Key steps:** |
| 128 | +1. Read the GPX file as text using `FileReader`. |
| 129 | +2. Parse XML into a DOM structure with `DOMParser`. |
| 130 | +3. Convert to GeoJSON using `toGeoJSON.gpx()`, which extracts track segments and points. |
| 131 | +4. Normalize track points into an array with coordinates and timestamps (if available). |
| 132 | +5. (Optional) simplify the result for optimal performance. |
| 133 | + |
| 134 | + |
| 135 | +### 2. Simplifying the GPX Track |
| 136 | + |
| 137 | +GPX tracks can contain thousands of points, which can slow down processing and visualization. |
| 138 | +The demo uses [Simplify.js](https://mourner.github.io/simplify-js/) to reduce the number of points to a maximum of **1000** – the limit supported by the public [Geoapify Map Matching API](https://www.geoapify.com/map-matching-api/). |
| 139 | + |
| 140 | +```javascript |
| 141 | +if (coordinates.length > MAX_POINTS /* 1000 */) { |
| 142 | + const result = this.simplifyPolyline(coordinates, timestamps, MAX_POINTS); |
| 143 | + coordinates = result.coordinates; |
| 144 | + timestamps = result.timestamps; |
| 145 | + console.log(`Route simplified to ${coordinates.length} points`); |
| 146 | +} |
| 147 | +``` |
| 148 | + |
| 149 | +**Key steps:** |
| 150 | +1. **Check track size** – If there are more than `MAX_POINTS` points (default: 1000), simplification is applied. |
| 151 | +2. **Run polyline simplification** – `simplifyPolyline()` uses a tolerance-based algorithm to remove points that do not significantly affect the overall geometry. |
| 152 | +3. **Preserve timestamps** – The algorithm keeps time data aligned with the remaining points when available. |
| 153 | +4. **Result** – A simplified track that still represents the original shape but is fast enough to send to the Map Matching API (which only supports 1000 points). |
| 154 | + |
| 155 | +### 3. Sending a Request to the Map Matching API |
| 156 | + |
| 157 | +Once the GPS track is simplified (if needed), the points are sent to the |
| 158 | +[Geoapify Map Matching API](https://www.geoapify.com/map-matching-api/) to “snap” them to the road network. |
| 159 | +The request includes the travel mode and an array of waypoints with coordinates and optional timestamps. |
| 160 | + |
| 161 | +```javascript |
| 162 | +const requestData = { |
| 163 | + mode: travelMode, // "walk", "drive", "bicycle" |
| 164 | + waypoints: gpxData.map(point => ({ |
| 165 | + location: point.location, |
| 166 | + timestamp: point.timestamp |
| 167 | + })) |
| 168 | +}; |
| 169 | + |
| 170 | +const response = await fetch( |
| 171 | + `https://api.geoapify.com/v1/mapmatching?apiKey=${API_KEY}`, |
| 172 | + { |
| 173 | + method: 'POST', |
| 174 | + headers: { 'Content-Type': 'application/json' }, |
| 175 | + body: JSON.stringify(requestData) |
| 176 | + } |
| 177 | +); |
| 178 | + |
| 179 | +const matchingResult = await response.json(); |
| 180 | +``` |
| 181 | + |
| 182 | +**Key steps:** |
| 183 | + |
| 184 | +1. **Prepare request payload** – Includes the travel mode and simplified list of track points. |
| 185 | +2. **Call Map Matching API** – Sends a POST request with JSON payload. |
| 186 | +3. **Receive snapped route** – Returns a [GeoJSON](https://geojson.org/) `FeatureCollection` containing the matched geometry and road attributes such as `road_class`, `surface`, `speed_limit`, etc. |
| 187 | +4. **Handle errors** – Checks response status and throws an error if the request fails or returns no features. |
| 188 | + |
| 189 | +### 4. Visualizing the Result on the Map |
| 190 | + |
| 191 | +The demo shows **both** the original GPS trace and the snapped (matched) route on an interactive |
| 192 | +[MapLibre GL](https://maplibre.org/) map. |
| 193 | +The original trace is displayed as a dashed gray line, while the matched route is drawn in blue. |
| 194 | +Hover popups show road attributes such as `road_class`, `surface`, and `speed_limit`. |
| 195 | + |
| 196 | +```javascript |
| 197 | +// Original GPS trace |
| 198 | +map.addSource('gps-trace', { |
| 199 | + type: 'geojson', |
| 200 | + data: { |
| 201 | + type: 'FeatureCollection', |
| 202 | + features: [{ |
| 203 | + type: 'Feature', |
| 204 | + geometry: { type: 'LineString', coordinates }, |
| 205 | + properties: {} |
| 206 | + }] |
| 207 | + } |
| 208 | +}); |
| 209 | + |
| 210 | +map.addLayer({ |
| 211 | + id: 'gps-trace-line', |
| 212 | + type: 'line', |
| 213 | + source: 'gps-trace', |
| 214 | + paint: { |
| 215 | + 'line-color': '#666', |
| 216 | + 'line-width': 4, |
| 217 | + 'line-dasharray': [10, 5] |
| 218 | + } |
| 219 | +}); |
| 220 | + |
| 221 | +// Matched route from API |
| 222 | +map.addSource('matched-trace', { |
| 223 | + type: 'geojson', |
| 224 | + data: matchingResult |
| 225 | +}); |
| 226 | + |
| 227 | +map.addLayer({ |
| 228 | + id: 'matched-trace-line', |
| 229 | + type: 'line', |
| 230 | + source: 'matched-trace', |
| 231 | + paint: { |
| 232 | + 'line-color': '#007bff', |
| 233 | + 'line-width': 5 |
| 234 | + } |
| 235 | +}); |
| 236 | +``` |
| 237 | + |
| 238 | +**Key steps:** |
| 239 | + |
| 240 | +1. **Add original trace source and layer** – Visualize uploaded GPS track as a dashed gray line. |
| 241 | +2. **Add matched route source and layer** – Display snapped road geometry returned by the Map Matching API. |
| 242 | +3. **Highlight differences** – Compare raw vs. snapped routes visually. |
| 243 | +4. **Enable popups (optional)** – Show road attributes when hovering over the matched route. |
| 244 | + |
| 245 | +### 5. Adding Popups with Road Details |
| 246 | + |
| 247 | +To enhance route visualization, the demo displays **road attributes** in a popup when users hover over the matched route. |
| 248 | +Details such as `road_class`, `surface`, `speed_limit`, and `lane_count` are shown. |
| 249 | + |
| 250 | +```javascript |
| 251 | +const popup = new maplibregl.Popup({ closeButton: false, closeOnClick: false }); |
| 252 | + |
| 253 | +map.on('mouseenter', 'matched-trace-line', (e) => { |
| 254 | + map.getCanvas().style.cursor = 'pointer'; |
| 255 | + const props = e.features[0].properties; |
| 256 | + |
| 257 | + popup.setLngLat(e.lngLat).setHTML(` |
| 258 | + <strong>Road class:</strong> ${props.road_class || 'n/a'}<br/> |
| 259 | + <strong>Surface:</strong> ${props.surface || 'n/a'}<br/> |
| 260 | + <strong>Speed limit:</strong> ${props.speed_limit || 'n/a'} km/h<br/> |
| 261 | + <strong>Lanes:</strong> ${props.lane_count || 'n/a'} |
| 262 | + `).addTo(map); |
| 263 | +}); |
| 264 | + |
| 265 | +map.on('mouseleave', 'matched-trace-line', () => { |
| 266 | + map.getCanvas().style.cursor = ''; |
| 267 | + popup.remove(); |
| 268 | +}); |
| 269 | +``` |
| 270 | + |
| 271 | +**Key steps:** |
| 272 | + |
| 273 | +1. **Create popup instance** – A reusable popup is created once. |
| 274 | +2. **Mouse enter event** – Shows popup with road attributes from feature properties. |
| 275 | +3. **Mouse leave event** – Hides popup and resets map cursor. |
| 276 | + |
| 277 | +### 6. Downloading the Matched Route |
| 278 | + |
| 279 | +The matched route can be downloaded as **GeoJSON** or **GPX** for further processing or usage in GPS devices. |
| 280 | + |
| 281 | +```javascript |
| 282 | +// Download GeoJSON |
| 283 | +fileUtils.downloadGeoJSON(matchingResult); |
| 284 | + |
| 285 | +// Download GPX |
| 286 | +fileUtils.downloadGPX(matchingResult, originalFileName); |
| 287 | +``` |
| 288 | + |
| 289 | +**Key steps:** |
| 290 | + |
| 291 | +1. **GeoJSON export** – Saves snapped geometry in a standard GIS format. |
| 292 | +2. **GPX export** – Allows loading the snapped route back into GPS software or devices. |
| 293 | + |
| 294 | +## Summary |
| 295 | + |
| 296 | +This demo shows how to: |
| 297 | + |
| 298 | +* Load GPX files and parse them into the [GeoJSON](https://geojson.org/) format |
| 299 | +* Simplify long tracks to meet the [Geoapify Map Matching API](https://www.geoapify.com/map-matching-api/) limit of 1000 points |
| 300 | +* Snap raw GPS traces to the road network based on travel mode |
| 301 | +* Visualize both original and snapped routes on an interactive [MapLibre GL](https://maplibre.org/) map |
| 302 | +* Display road attributes in interactive popups |
| 303 | +* Export the snapped route as [GeoJSON](https://geojson.org/) or [GPX](https://www.topografix.com/gpx.asp) |
| 304 | + |
| 305 | +### Learn more and start building |
| 306 | + |
| 307 | +* Explore [Geoapify APIs](https://www.geoapify.com/) |
| 308 | +* Read [Map Matching API documentation](https://www.geoapify.com/map-matching-api/) |
| 309 | +* Try the [live demo](https://geoapify.github.io/maps-api-code-samples/javascript/map-matching-to-snap-gpx-to-roads-maplibre/demo_combined.html) |
| 310 | + |
| 311 | +**Get your free API key and start building your own map-matching apps today → [Get API Key](https://myprojects.geoapify.com)!** |
0 commit comments