A Pen by Raul Jimenez Ortega on CodePen.
Created
July 2, 2025 09:20
-
-
Save hhkaos/3d560cd5f7e50c36db4c56ddd7d88b6f to your computer and use it in GitHub Desktop.
ArcGIS Developer Guide: Snap GPX track points to roads
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!-- | |
To run this demo, you need to replace 'YOUR_ACCESS_TOKEN' with an access token from ArcGIS that has the correct privileges. | |
To get started, sign up for a free ArcGIS Location Platform account or a free trial of ArcGIS Online and create developer credentials. | |
https://developers.arcgis.com/documentation/security-and-authentication/get-started/ | |
--> | |
<html lang="en"> | |
<head> | |
<meta charset="utf-8" /> | |
<title>Upload GPX and Snap to Roads</title> | |
<meta name="viewport" content="width=device-width, initial-scale=1" /> | |
<!-- ArcGIS Maps SDK for JavaScript --> | |
<link | |
rel="stylesheet" | |
href="https://js.arcgis.com/4.32/esri/themes/light/main.css" | |
/> | |
<script src="https://js.arcgis.com/4.32/"></script> | |
<!-- Calcite Components --> | |
<script | |
type="module" | |
src="https://js.arcgis.com/calcite-components/3.1.0/calcite.esm.js" | |
></script> | |
<link | |
rel="stylesheet" | |
href="https://js.arcgis.com/calcite-components/3.1.0/calcite.css" | |
/> | |
<style> | |
html, | |
body, | |
#viewDiv { | |
height: 100%; | |
width: 100%; | |
margin: 0; | |
padding: 0; | |
} | |
#uiPanel { | |
z-index: 10; | |
background: white; | |
padding: 1rem; | |
border-radius: 0px; | |
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); | |
width: 280px; | |
} | |
#fileInput { | |
display: none; | |
} | |
</style> | |
</head> | |
<body> | |
<div id="viewDiv"></div> | |
<div id="uiPanel" class="esri-widget"> | |
<calcite-button | |
id="downloadSampleBtn" | |
icon-start="download" | |
appearance="outline" | |
width="full" | |
style="margin-bottom: 0.5rem;" | |
>Download sample GPX File</calcite-button | |
> | |
<calcite-label> | |
<calcite-button | |
id="uploadBtn" | |
icon-start="upload" | |
appearance="solid" | |
width="full" | |
>Upload GPX File</calcite-button | |
> | |
<input id="fileInput" type="file" accept=".gpx" /> | |
</calcite-label> | |
<calcite-button | |
id="snapBtn" | |
icon-start="pin-plus" | |
appearance="outline" | |
disabled | |
width="full" | |
>Snap points</calcite-button | |
> | |
<calcite-button | |
id="resetBtn" | |
icon-start="reset" | |
appearance="outline" | |
width="full" | |
style="margin-top: 0.5rem;" | |
>Reset</calcite-button | |
> | |
<div id="layerSwitches" style="display:none; margin-top:1rem;"> | |
<calcite-label layout="inline"> | |
<calcite-switch id="rawPointsSwitch" checked></calcite-switch> | |
GPS Points | |
</calcite-label> | |
<calcite-label layout="inline"> | |
<calcite-switch id="snappedPointsSwitch" checked></calcite-switch> | |
Snapped Points | |
</calcite-label> | |
<calcite-label layout="inline"> | |
<calcite-switch id="snappedRouteSwitch" checked></calcite-switch> | |
Snapped Route | |
</calcite-label> | |
</div> | |
</div> | |
<script type="module"> | |
// Import ArcGIS modules | |
const [ | |
EsriConfig, | |
EsriMap, | |
EsriMapView, | |
EsriGeoprocessor, | |
EsriGraphicsLayer, | |
EsriGraphic, | |
EsriPoint, | |
EsriPolyline, | |
EsriFeatureLayer, | |
] = await $arcgis.import([ | |
"@arcgis/core/config.js", | |
"@arcgis/core/Map", | |
"@arcgis/core/views/MapView", | |
"@arcgis/core/rest/geoprocessor", | |
"@arcgis/core/layers/GraphicsLayer", | |
"@arcgis/core/Graphic", | |
"@arcgis/core/geometry/Point", | |
"@arcgis/core/geometry/Polyline", | |
"@arcgis/core/layers/FeatureLayer", | |
]) | |
// --- Constants --- | |
const apiKey = "YOUR_ACCESS_TOKEN" | |
EsriConfig.apiKey = apiKey | |
const serviceURLs = { | |
snapToRoads: | |
"https://route-api.arcgis.com/arcgis/rest/services/World/SnapToRoadsSync/GPServer/SnapToRoads", | |
travelModes: | |
"https://route-api.arcgis.com/arcgis/rest/services/World/Utilities/GPServer/GetTravelModes", | |
} | |
const US_CENTER = [-98.5795, 39.8283] | |
const US_ZOOM = 3 | |
// --- Map and Layers --- | |
const map = new EsriMap({ basemap: "streets-navigation-vector" }) | |
const view = new EsriMapView({ | |
container: "viewDiv", | |
map, | |
center: US_CENTER, | |
zoom: US_ZOOM, | |
}) | |
const rawPointsLayer = new EsriFeatureLayer({ | |
title: "Raw GPX Points", | |
source: [], | |
fields: [ | |
{ name: "ObjectID", type: "oid" }, | |
{ name: "lat", type: "double" }, | |
{ name: "lon", type: "double" }, | |
], | |
objectIdField: "ObjectID", | |
geometryType: "point", | |
spatialReference: { wkid: 4326 }, | |
renderer: { | |
type: "simple", | |
symbol: { | |
type: "simple-marker", | |
color: "#007ac2", | |
size: 6, | |
}, | |
}, | |
}) | |
const snappedPointsLayer = new EsriGraphicsLayer({ | |
title: "Snapped Points", | |
}) | |
const snappedRouteLayer = new EsriGraphicsLayer({ | |
title: "Snapped Route", | |
}) | |
map.addMany([rawPointsLayer, snappedRouteLayer, snappedPointsLayer]) | |
// --- UI Elements --- | |
const uploadBtn = document.getElementById("uploadBtn") | |
const fileInput = document.getElementById("fileInput") | |
const snapBtn = document.getElementById("snapBtn") | |
const resetBtn = document.getElementById("resetBtn") | |
const downloadSampleBtn = document.getElementById("downloadSampleBtn") | |
const layerSwitches = document.getElementById("layerSwitches") | |
const rawPointsSwitch = document.getElementById("rawPointsSwitch") | |
const snappedPointsSwitch = document.getElementById("snappedPointsSwitch") | |
const snappedRouteSwitch = document.getElementById("snappedRouteSwitch") | |
// --- State --- | |
let gpxPoints = [] | |
let travelMode = null | |
// --- Event Handlers --- | |
// Upload button triggers file input | |
uploadBtn.addEventListener("click", () => fileInput.click()) | |
// Handle GPX file selection | |
fileInput.addEventListener("change", async e => { | |
const file = e.target.files[0] | |
if (!file) return | |
const text = await file.text() | |
gpxPoints = parseGPX(text) | |
if (gpxPoints.length === 0) { | |
alert("No track points found in GPX file.") | |
return | |
} | |
await clearAllLayers() | |
// Add raw points to map as features | |
const features = gpxPoints.map((pt, i) => ({ | |
geometry: { type: "point", longitude: pt.lon, latitude: pt.lat }, | |
attributes: { ObjectID: i + 1, lat: pt.lat, lon: pt.lon }, | |
})) | |
await rawPointsLayer.applyEdits({ addFeatures: features }) | |
// Zoom to the extent of rawPointsLayer | |
const extent = await rawPointsLayer.queryExtent() | |
if (extent && extent.extent) { | |
view.goTo(extent.extent) | |
rawPointsLayer.visible = true | |
} | |
snapBtn.disabled = false | |
}) | |
// Snap to Roads button | |
snapBtn.addEventListener("click", async () => { | |
if (gpxPoints.length === 0) return | |
snapBtn.loading = true | |
snappedPointsLayer.removeAll() | |
snappedRouteLayer.removeAll() | |
// Query the raw points layer for features | |
const features = await rawPointsLayer.queryFeatures() | |
// Create a feature set | |
const params = { | |
points: features, | |
token: apiKey, | |
travel_mode: travelMode, | |
return_lines: true, | |
return_location_fields: true, | |
road_properties_on_snapped_points: [ | |
"posted_speed_limit_mph", | |
"length_miles", | |
], | |
road_properties_on_lines: ["posted_speed_limit_mph", "length_miles"], | |
} | |
try { | |
// Execute the geoprocessing service | |
const result = await EsriGeoprocessor.execute( | |
serviceURLs.snapToRoads, | |
params | |
) | |
// Get the snapped points | |
const snappedPoints = result.results[0].value.features | |
if (snappedPoints.length === 0) { | |
alert("No roads found for the provided points.") | |
return | |
} | |
const snappedGraphics = snappedPoints.map( | |
f => | |
new EsriGraphic({ | |
geometry: new EsriPoint({ | |
longitude: f.geometry.x, | |
latitude: f.geometry.y, | |
}), | |
symbol: { type: "simple-marker", color: "black", size: 6 }, | |
}) | |
) | |
snappedPointsLayer.addMany(snappedGraphics) | |
// Get the snapped route lines | |
const snappedLines = result.results[1].value.features | |
if (snappedLines.length === 0) { | |
alert("No route found for the provided points.") | |
return | |
} | |
const snappedRouteGraphics = snappedLines.map( | |
f => | |
new EsriGraphic({ | |
geometry: f.geometry, | |
symbol: { | |
type: "simple-line", | |
color: [0, 150, 255, 0.75], | |
width: 3, | |
}, | |
}) | |
) | |
snappedRouteLayer.addMany(snappedRouteGraphics) | |
// Zoom to snapped route | |
if (snappedGraphics.length > 0) view.goTo(snappedGraphics) | |
showHideLayerSwitchesAndLayers(true) | |
} catch (err) { | |
alert("Snap to Roads failed: " + err.message) | |
} finally { | |
snapBtn.loading = false | |
} | |
}) | |
// Download sample GPX file button | |
downloadSampleBtn.addEventListener("click", () => { | |
const link = document.createElement("a") | |
link.href = "https://developers.arcgis.com/documentation//data/RedlandsRoute.gpx" | |
link.download = "sample.gpx" | |
document.body.appendChild(link) | |
link.click() | |
document.body.removeChild(link) | |
}) | |
// Layer switch handlers | |
rawPointsSwitch.addEventListener("calciteSwitchChange", e => { | |
rawPointsLayer.visible = e.target.checked | |
}) | |
snappedPointsSwitch.addEventListener("calciteSwitchChange", e => { | |
snappedPointsLayer.visible = e.target.checked | |
}) | |
snappedRouteSwitch.addEventListener("calciteSwitchChange", e => { | |
snappedRouteLayer.visible = e.target.checked | |
}) | |
// Reset button | |
resetBtn.addEventListener("click", async () => { | |
// Clear all layers and reset state | |
await clearAllLayers() | |
gpxPoints = [] | |
snapBtn.disabled = true | |
fileInput.value = "" | |
showHideLayerSwitchesAndLayers(false) | |
view.goTo({ center: US_CENTER, zoom: US_ZOOM }) | |
}) | |
// --- Helper Functions --- | |
// Remove all features from all layers | |
async function clearAllLayers() { | |
await removeFeatures(rawPointsLayer) | |
;[snappedPointsLayer, snappedRouteLayer].forEach(layer => | |
layer.removeAll() | |
) | |
} | |
// Remove all features from a FeatureLayer | |
async function removeFeatures(layer) { | |
const { features } = await layer.queryFeatures() | |
if (features?.length) { | |
await layer.applyEdits({ deleteFeatures: features }) | |
} | |
} | |
// Parse GPX file for <trkpt> or <wpt> elements | |
function parseGPX(xmlText) { | |
const parser = new DOMParser() | |
const xml = parser.parseFromString(xmlText, "application/xml") | |
let points = Array.from(xml.getElementsByTagName("trkpt")) | |
if (!points.length) points = Array.from(xml.getElementsByTagName("wpt")) | |
if (!points.length) { | |
console.error("No track points or waypoints found in GPX file.") | |
return [] | |
} | |
return points | |
.map(pt => ({ | |
lat: parseFloat(pt.getAttribute("lat")), | |
lon: parseFloat(pt.getAttribute("lon")), | |
})) | |
.filter(pt => !isNaN(pt.lat) && !isNaN(pt.lon)) | |
} | |
// Get travel mode by name | |
async function getTravelModes(modeName) { | |
const params = { token: apiKey, f: "json" } | |
const response = await EsriGeoprocessor.execute( | |
serviceURLs.travelModes, | |
params | |
) | |
let mode = response.results[0].value.features.find( | |
f => f.attributes.Name === modeName | |
) | |
if (!mode) { | |
console.error(`Travel mode '${modeName}' not found`) | |
mode = response.results[0].value.features[0] | |
} | |
return mode | |
} | |
// Show all layer switches and make all layers visible | |
const showHideLayerSwitchesAndLayers = show => { | |
layerSwitches.style.display = show ? "block" : "none" | |
rawPointsSwitch.checked = show | |
snappedPointsSwitch.checked = show | |
snappedRouteSwitch.checked = show | |
rawPointsLayer.visible = show | |
snappedPointsLayer.visible = show | |
snappedRouteLayer.visible = show | |
} | |
// Map load event handler | |
view.when(async () => { | |
view.ui.add(document.getElementById("uiPanel"), "top-right") | |
const mode = await getTravelModes("Driving Time") | |
travelMode = mode.attributes.TravelMode | |
}) | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment