Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save hhkaos/28d032cbf47d9df462550419ed8ed38e to your computer and use it in GitHub Desktop.
Save hhkaos/28d032cbf47d9df462550419ed8ed38e to your computer and use it in GitHub Desktop.
ArcGIS Developer Guide: Snap to roads
<!--
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