A Pen by Raul Jimenez Ortega on CodePen.
Created
July 2, 2025 09:20
-
-
Save hhkaos/28d032cbf47d9df462550419ed8ed38e to your computer and use it in GitHub Desktop.
ArcGIS Developer Guide: Snap 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> | |
<head> | |
<meta charset="utf-8" /> | |
<meta | |
name="viewport" | |
content="initial-scale=1, maximum-scale=1, user-scalable=no" | |
/> | |
<title>ArcGIS Developer Guide: Snap to Roads</title> | |
<!-- load maps sdk for javascript css --> | |
<link | |
rel="stylesheet" | |
href="https://js.arcgis.com/4.32/esri/themes/light/main.css" | |
/> | |
<!-- <link rel="stylesheet" href="https://js.arcgis.com/4.32/esri/themes/light/main.css" /> --> | |
<!-- Load the maps sdk for javascript api --> | |
<script src="https://js.arcgis.com/4.32/"></script> | |
<!-- <script src="https://js.arcgis.com/4.32"></script> --> | |
<!-- Load the Calcite Components library --> | |
<script | |
type="module" | |
src="https://js.arcgis.com/calcite-components/3.1.0/calcite.esm.js" | |
></script> | |
<style> | |
html, | |
body, | |
#mapView { | |
padding: 0; | |
margin: 0; | |
height: 100%; | |
width: 100%; | |
} | |
.tab-cntrl-cntnr { | |
display: flex; | |
flex-direction: column; | |
align-items: center; | |
width: 250px; | |
padding: 10px; | |
} | |
.button-container { | |
margin: 5px; | |
--calcite-ui-icon-color: red; | |
} | |
</style> | |
<script type="module"> | |
const [ | |
EsriConfig, | |
EsriMap, | |
EsriMapView, | |
EsriGeoprocessor, | |
EsriFeatureLayer, | |
EsriGraphicsLayer, | |
EsriGraphic, | |
EsriWebMap, | |
EsriGeodeticLengthOperator, | |
EsriExtent, | |
EsriIntersectsOperator, | |
EsriUnionOperator, | |
EsriDifferenceOperator, | |
] = await $arcgis.import([ | |
"@arcgis/core/config.js", | |
"@arcgis/core/Map", | |
"@arcgis/core/views/MapView", | |
"@arcgis/core/rest/geoprocessor", | |
"@arcgis/core/layers/FeatureLayer", | |
"@arcgis/core/layers/GraphicsLayer", | |
"@arcgis/core/Graphic", | |
"@arcgis/core/WebMap", | |
"@arcgis/core/geometry/operators/geodeticLengthOperator", | |
"@arcgis/core/geometry/Extent", | |
"@arcgis/core/geometry/operators/intersectsOperator", | |
"@arcgis/core/geometry/operators/unionOperator", | |
"@arcgis/core/geometry/operators/differenceOperator", | |
]) | |
let inputFeatures, | |
travelMode, | |
plannedRouteLayer, | |
sourcePointsLayer, | |
sourceRouteLayer, | |
sourceLayerName = "Truck GPS points", | |
sourceRouteName = "Truck Route", | |
plannedRouteLayerName = "Planned Route", | |
plannedGeodesicLength = 0, | |
speedingRecords = 0, | |
mainFlow, | |
resultStatistics | |
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 accessToken = "YOUR_ACCESS_TOKEN" | |
EsriConfig.apiKey = accessToken | |
const webmap = new EsriWebMap({ | |
portalItem: { | |
id: "f8dee2731b09459f9ca19ddbd5ae3381", | |
}, | |
}) | |
mainFlow = document.getElementById("snapToRoadsFlow") | |
// create results point layer | |
const resultPointsLayer = new EsriFeatureLayer({ | |
id: "snappedPointResults", | |
title: "Snapped points", | |
source: [], | |
spatialReference: { wkid: 4326 }, | |
objectIdField: "ObjectID", | |
fields: [ | |
{ | |
name: "ObjectID", | |
type: "oid", | |
}, | |
{ | |
name: "geometry", | |
type: "geometry", | |
}, | |
{ | |
name: "confidence", | |
type: "double", | |
}, | |
{ | |
name: "ele", | |
type: "double", | |
}, | |
{ | |
name: "lat", | |
type: "double", | |
}, | |
{ | |
name: "lon", | |
type: "double", | |
}, | |
{ | |
name: "line_id", | |
type: "integer", | |
}, | |
{ | |
name: "posted_speed_limit_mph", | |
type: "double", | |
}, | |
{ | |
name: "time", | |
type: "date", | |
}, | |
], | |
geometryType: "point", | |
popupTemplate: { | |
title: "Snapped Point {ObjectID}", | |
content: [ | |
{ | |
type: "fields", | |
fieldInfos: [ | |
{ | |
fieldName: "confidence", | |
label: "Confidence", | |
format: { | |
places: 2, | |
digitSeparator: true, | |
}, | |
}, | |
{ | |
fieldName: "line_id", | |
label: "Line Id", | |
}, | |
{ | |
fieldName: "ele", | |
label: "Elevation", | |
format: { | |
places: 2, | |
digitSeparator: true, | |
}, | |
}, | |
{ | |
fieldName: "lat", | |
label: "Latitude", | |
format: { | |
places: 4, | |
digitSeparator: true, | |
}, | |
}, | |
{ | |
fieldName: "lon", | |
label: "Longitude", | |
format: { | |
places: 4, | |
digitSeparator: true, | |
}, | |
}, | |
{ | |
fieldName: "time", | |
label: "Time", | |
format: { | |
dateFormat: "short-date-short-time", | |
}, | |
}, | |
], | |
}, | |
], | |
}, | |
renderer: { | |
type: "unique-value", | |
valueExpression: `var arcadeExp = $feature.confidence;When(arcadeExp > 0, "Snapped", arcadeExp <= 0, "Not snapped","UNK")`, | |
valueExpressionTitle: "Snapped", | |
uniqueValueInfos: [ | |
{ | |
value: "Snapped", | |
symbol: { | |
type: "simple-marker", | |
color: [0, 0, 0, 0.8], | |
size: "8px", | |
outline: { | |
color: [255, 255, 255, 0.8], | |
width: 1.25, | |
}, | |
}, | |
}, | |
{ | |
value: "Not snapped", | |
symbol: { | |
type: "simple-marker", | |
color: [255, 40, 41, 0.75], | |
size: "8px", | |
outline: { | |
color: "#FFFFFF", | |
width: 1.25, | |
}, | |
}, | |
}, | |
{ | |
value: "UNK", | |
symbol: { | |
type: "simple-marker", | |
color: "#FFFFFF", | |
size: "8px", | |
outline: { | |
color: [255, 0, 0, 0.75], | |
width: 1.25, | |
}, | |
}, | |
}, | |
], | |
}, | |
}) | |
// create results line layer | |
const resultLinesLayer = new EsriFeatureLayer({ | |
id: "snappedLineResults", | |
title: "Snapped routes", | |
source: [], | |
spatialReference: { wkid: 4326 }, | |
objectIdField: "ObjectID", | |
fields: [ | |
{ | |
name: "ObjectID", | |
type: "oid", | |
}, | |
{ | |
name: "geometry", | |
type: "geometry", | |
}, | |
{ | |
name: "line_type", | |
type: "string", | |
}, | |
{ | |
name: "posted_speed_limit_mph", | |
type: "double", | |
}, | |
{ | |
name: "track_id", | |
type: "integer", | |
}, | |
{ | |
name: "length_miles", | |
type: "double", | |
}, | |
], | |
geometryType: "polyline", | |
popupTemplate: { | |
title: "Snapped route {ObjectID}", | |
content: [ | |
{ | |
type: "fields", | |
fieldInfos: [ | |
{ | |
fieldName: "line_type", | |
label: "Type", | |
}, | |
{ | |
fieldName: "length_miles", | |
label: "Segment length (miles)", | |
format: { | |
places: 4, | |
digitSeparator: true, | |
}, | |
}, | |
{ | |
fieldName: "posted_speed_limit_mph", | |
label: "Posted Speed Limit (mph)", | |
format: { | |
places: 0, | |
digitSeparator: false, | |
}, | |
}, | |
], | |
}, | |
], | |
}, | |
renderer: { | |
type: "simple", | |
symbol: { | |
type: "simple-line", | |
color: [0, 150, 255, 0.75], | |
width: 3, | |
}, | |
}, | |
}) | |
const speedingResultsLayer = new EsriFeatureLayer({ | |
id: "speedingResults", | |
title: "Speeding results", | |
source: [], | |
objectIdField: "ObjectID", | |
spatialReference: { wkid: 4326 }, | |
fields: [ | |
{ | |
name: "ObjectID", | |
type: "oid", | |
}, | |
{ | |
name: "geometry", | |
type: "geometry", | |
}, | |
{ | |
name: "line_type", | |
type: "string", | |
}, | |
{ | |
name: "posted_speed_limit_mph", | |
type: "double", | |
}, | |
{ | |
name: "speed", | |
type: "double", | |
}, | |
{ | |
name: "length_miles", | |
type: "double", | |
}, | |
], | |
geometryType: "point", | |
popupTemplate: { | |
title: "Speeding point detected", | |
content: [ | |
{ | |
type: "fields", | |
fieldInfos: [ | |
{ | |
fieldName: "posted_speed_limit_mph", | |
label: "Posted Speed Limit (mph)", | |
format: { | |
places: 2, | |
digitSeparator: false, | |
}, | |
}, | |
{ | |
fieldName: "speed", | |
label: "Captured Speed (mph)", | |
format: { | |
places: 2, | |
digitSeparator: false, | |
}, | |
}, | |
{ | |
fieldName: "length_miles", | |
label: "Segment length (miles)", | |
format: { | |
places: 4, | |
digitSeparator: true, | |
}, | |
}, | |
], | |
}, | |
], | |
}, | |
renderer: { | |
type: "unique-value", | |
valueExpression: | |
"IIF($feature.speed > $feature.posted_speed_limit_mph, 'OverLimit', 'Normal')", | |
valueExpressionTitle: "Speeding Check", | |
uniqueValueInfos: [ | |
{ | |
value: "OverLimit", | |
symbol: { | |
type: "simple-marker", | |
color: [255, 0, 0, 0.8], // Red | |
size: "8px", // Big dot | |
outline: { | |
color: [255, 255, 255, 0.8], | |
width: 1, | |
}, | |
}, | |
label: "Speeding", | |
}, | |
{ | |
value: "Normal", | |
symbol: { | |
type: "simple-marker", | |
color: [0, 0, 0, 0.8], // Transparent or small/gray | |
size: "0px", | |
outline: { | |
color: [255, 255, 255, 0.8], | |
width: 1, | |
}, | |
}, | |
label: "Not Speeding", | |
}, | |
], | |
}, | |
}) | |
// initialize the map | |
const mapView = new EsriMapView({ | |
container: "mapView", | |
map: webmap, | |
center: [-117.3233, 34.0663], | |
zoom: 17, | |
}) | |
mapView.map.addMany([ | |
resultLinesLayer, | |
resultPointsLayer, | |
speedingResultsLayer, | |
]) | |
// Helper: Remove all features from a layer | |
const removeFeatures = async layer => { | |
const f = await layer.queryFeatures() | |
if (f.features) { | |
await layer.applyEdits({ deleteFeatures: f.features }) | |
} | |
} | |
// Helper: Create a notice message in a block | |
const createNotice = (elementId, messages) => { | |
const notice = document.createElement("calcite-notice") | |
notice.open = true | |
notice.width = "full" | |
messages.forEach(msg => { | |
const span = document.createElement("span") | |
span.slot = "message" | |
span.innerText = msg | |
notice.append(span) | |
}) | |
document.getElementById(elementId).appendChild(notice) | |
} | |
// Helper: Get travel mode by name | |
const getTravelModes = async modeName => { | |
const params = { token: accessToken, 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 | |
} | |
// Add results to UI | |
const addResults = async response => { | |
await addGraphics(response) | |
const routeResults = response.results[1].value | |
const routeLength = routeResults.features.reduce( | |
(a, f) => a + f.attributes.length_miles, | |
0 | |
) | |
const totalPoints = response.results[0].value.features.length | |
const snappedPoints = response.results[0].value.features.filter( | |
f => f.attributes.confidence > 0 | |
).length | |
const unSnappedPoints = totalPoints - snappedPoints | |
resultStatistics = [ | |
`Points snapped: ${snappedPoints} of ${totalPoints}`, | |
`Points unsnapped: ${unSnappedPoints} of ${totalPoints}`, | |
`Actual route: ${routeLength.toFixed(2)} miles`, | |
`Planned route: ${plannedGeodesicLength.toFixed(2)} miles`, | |
`Percentage difference: ${( | |
((routeLength - plannedGeodesicLength) / routeLength) * | |
100 | |
).toFixed(2)}%`, | |
`Speeding: ${speedingRecords} of ${totalPoints}`, | |
`Percentage: ${((speedingRecords / totalPoints) * 100).toFixed(2)}%`, | |
] | |
} | |
// Add results to the map | |
const addGraphics = async results => { | |
const lines = results.results[1].value.features.map( | |
result => | |
new EsriGraphic({ | |
geometry: result.geometry, | |
attributes: result.attributes, | |
}) | |
) | |
await resultLinesLayer.applyEdits({ addFeatures: lines }) | |
const points = results.results[0].value.features.map(result => { | |
if ( | |
result.attributes.speed > result.attributes.posted_speed_limit_mph | |
) { | |
speedingRecords++ | |
} | |
return new EsriGraphic({ | |
geometry: result.geometry, | |
attributes: result.attributes, | |
}) | |
}) | |
await resultPointsLayer.applyEdits({ addFeatures: points }) | |
await speedingResultsLayer.applyEdits({ addFeatures: points }) | |
} | |
// Snap points to roads | |
const snapPoints = async () => { | |
// Clear previous results | |
await removeFeatures(resultPointsLayer) | |
await removeFeatures(resultLinesLayer) | |
await removeFeatures(speedingResultsLayer) | |
speedingRecords = 0 | |
// Set loading state for the button | |
document | |
.getElementById("snapPointsButton") | |
.setAttribute("loading", true) | |
try { | |
// Create parameters for the Snap to Roads service | |
const params = { | |
points: inputFeatures, | |
token: accessToken, | |
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", | |
], | |
} | |
const results = await EsriGeoprocessor.execute( | |
serviceURLs.snapToRoads, | |
params | |
) | |
sourcePointsLayer.opacity = 0.75 | |
addResults(results) // Add results to the map and UI | |
} catch (error) { | |
console.error(error) | |
} finally { | |
document.getElementById("snapPointsButton").removeAttribute("loading") | |
} | |
} | |
const getRouteLength = async () => { | |
// Get planned route length in miles | |
const fs = await plannedRouteLayer.queryFeatures() | |
const routeLength = fs.features.reduce( | |
(a, f) => a + f.attributes["length_miles"], | |
0 | |
) | |
return routeLength | |
} | |
// Handle the map load event | |
const mapDidLoad = async () => { | |
sourceRouteLayer = webmap.allLayers.find( | |
layer => layer.title === sourceRouteName | |
) | |
sourcePointsLayer = webmap.allLayers.find( | |
layer => layer.title === sourceLayerName | |
) | |
plannedRouteLayer = webmap.allLayers.find( | |
layer => layer.title === plannedRouteLayerName | |
) | |
plannedGeodesicLength = await getRouteLength() | |
inputFeatures = await sourcePointsLayer.queryFeatures() | |
// Add the panel to the map UI | |
mapView.ui.add(document.getElementById("infoContainer"), "top-right") | |
// Get travel mode | |
const mode = await getTravelModes("Driving Time") | |
travelMode = mode.attributes.TravelMode | |
} | |
// Handle snap button click event | |
const snapButtonWasClicked = async () => { | |
await snapPoints() | |
} | |
// Remove previous results | |
const clearButtonWasClicked = async () => { | |
// Clear all results and reset UI | |
;["routeBlock", "speedingBlock"].forEach(id => { | |
document.getElementById(id)?.replaceChildren() | |
}) | |
sourcePointsLayer.opacity = 1 | |
plannedRouteLayer.visible = false | |
sourceRouteLayer.visible = true | |
sourcePointsLayer.visible = true | |
await removeFeatures(resultPointsLayer) | |
await removeFeatures(resultLinesLayer) | |
await removeFeatures(speedingResultsLayer) | |
speedingRecords = 0 | |
} | |
const resetUI = activeStep => { | |
const showPlannedRouteBtn = document.getElementById( | |
"showPlannedRouteButton" | |
) | |
const snapPointsBtn = document.getElementById("snapPointsButton") | |
const showActualRouteBtn = document.getElementById( | |
"showActualRouteButton" | |
) | |
switch (activeStep) { | |
case 0: | |
snapPointsBtn.parentElement.parentElement.disabled = false | |
showPlannedRouteBtn.parentElement.parentElement.disabled = true | |
showActualRouteBtn.parentElement.parentElement.disabled = true | |
break | |
case 1: // | |
snapPointsBtn.parentElement.parentElement.disabled = true | |
showPlannedRouteBtn.parentElement.parentElement.disabled = false | |
showActualRouteBtn.parentElement.parentElement.disabled = true | |
break | |
case 2: | |
snapPointsBtn.parentElement.parentElement.disabled = true | |
showPlannedRouteBtn.parentElement.parentElement.disabled = true | |
showActualRouteBtn.parentElement.parentElement.disabled = false | |
break | |
} | |
} | |
document.querySelectorAll("calcite-button").forEach(a => { | |
a.addEventListener("click", async e => { | |
switch (e.currentTarget.id) { | |
case "snapPointsButton": | |
await snapButtonWasClicked() | |
resetUI(1) | |
break | |
case "showPlannedRouteButton": | |
plannedRouteLayer.visible = !plannedRouteLayer.visible | |
resetUI(2) | |
break | |
case "showActualRouteButton": | |
createFlowItem() | |
createNotice("routeBlock", resultStatistics.slice(2, 5)) // Route comparison | |
createNotice("speedingBlock", resultStatistics.slice(5, 7)) // Speeding records | |
break | |
} | |
}) | |
}) | |
document | |
.getElementById("resetButton") | |
.addEventListener("click", async () => { | |
await clearButtonWasClicked() | |
resetUI(0) | |
}) | |
// Listen for the map load event | |
mapView.when(mapDidLoad) | |
// Create a flow item when the user clicks the Snap points button | |
const createFlowItem = () => { | |
const newFlowItem = document.createElement("calcite-flow-item") | |
newFlowItem.addEventListener("calciteFlowItemBack", () => | |
newFlowItem.remove() | |
) | |
newFlowItem.heading = "Route analysis" | |
// newFlowItem.description = "Results from the Snap to Roads service" | |
;[ | |
// { | |
// id: "snappedPointsBlock", | |
// heading: "Snapped points", | |
// description: "Points snapped to the nearest road", | |
// expanded: true, | |
// }, | |
{ | |
id: "routeBlock", | |
heading: "Route statistics", | |
description: "Actual route vs planned route", | |
expanded: true, | |
}, | |
{ | |
id: "speedingBlock", | |
heading: "Location statistics", | |
description: "Locations of speeding along the route", | |
expanded: true, | |
}, | |
].forEach(({ id, heading, description, expanded }) => { | |
const block = document.createElement("calcite-block") | |
block.id = id | |
block.expanded = expanded | |
block.heading = heading | |
block.description = description | |
block.collapsible = false | |
newFlowItem.append(block) | |
}) | |
mainFlow.append(newFlowItem) | |
document | |
.querySelectorAll("calcite-flow-item") | |
.forEach(item => (item.selected = false)) | |
newFlowItem.selected = true | |
} | |
</script> | |
</head> | |
<body> | |
<div id="mapView"></div> | |
<div id="infoContainer" class="esri-widget"> | |
<calcite-panel> | |
<calcite-flow id="snapToRoadsFlow"> | |
<calcite-flow-item heading="Route analysis"> | |
<calcite-action | |
id="resetButton" | |
slot="header-actions-end" | |
icon="reset" | |
></calcite-action> | |
<calcite-tooltip | |
reference-element="resetButton" | |
text="Configure" | |
slot="header-actions-end" | |
>Resets application to initial state.</calcite-tooltip | |
> | |
<calcite-block | |
heading="Snap GPS points" | |
icon-start="number-circle-1f" | |
> | |
<div class="button-container" slot="actions-end"> | |
<calcite-button id="snapPointsButton" size="s" | |
>Go</calcite-button | |
> | |
</div> | |
</calcite-block> | |
<calcite-block | |
heading="Display planned route" | |
icon-start="number-circle-2f" | |
disabled | |
> | |
<div class="button-container" slot="actions-end"> | |
<calcite-button id="showPlannedRouteButton" size="s" | |
>Go</calcite-button | |
> | |
</div> | |
</calcite-block> | |
<calcite-block | |
heading="Calculate statistics" | |
icon-start="number-circle-3f" | |
disabled | |
> | |
<div class="button-container" slot="actions-end"> | |
<calcite-button id="showActualRouteButton" size="s" | |
>Go</calcite-button | |
> | |
</div> | |
</calcite-block> | |
</calcite-flow-item> | |
</calcite-flow> | |
</calcite-panel> | |
</div> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment