See the JSDoc comment at the top of index.ts for details
Last active
October 8, 2025 19:32
-
-
Save JeffJacobson/ffd3ecdc618f7a4f65078cb950e93d51 to your computer and use it in GitHub Desktop.
Locate WSDOT milepost nearest click
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
| export const routeIdField = "RouteID"; | |
| export const directionField = "Direction"; | |
| export const srmpField = "SRMP"; | |
| export const abField = "AheadBackInd"; |
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
| /** | |
| * @packageDocumentation | |
| * | |
| * This module is responsible for creating a milepost layer that | |
| * initially has all its features hidden. | |
| * | |
| * An event listener is added to the map view that makes | |
| * it so that when a user clicks on a map near a milepost, | |
| * and if their click was not on any other features in the map, | |
| * nearest milepost to where they clicked is shown. | |
| * | |
| * If the user clicks on the map again… | |
| * | |
| * - If they clicked near another milepost, that milepost is shown. | |
| * - If they didn't click near a milepost, all mileposts are hidden. | |
| */ | |
| /** | |
| * This is the ArcGIS Online item ID for the mileposts layer. | |
| */ | |
| const agolId = "22324eb30f6949eabc180bfbe0de6fcb"; | |
| // Dynamically import field name constants, and definitions for symbology and labelling. | |
| const { abField, directionField, routeIdField, srmpField } = await import( | |
| "./field-names" | |
| ); | |
| const { mpSymbol } = await import("./symbol"); | |
| const { labelingInfo } = await import("./labeling"); | |
| // Dynamically import ArcGIS Maps SDK for JavaScript modules from Esri's CDN. | |
| const [ | |
| distanceOperator, | |
| FeatureLayer, | |
| DisplayFilter, | |
| DisplayFilterInfo, | |
| SimpleRenderer, | |
| ] = await $arcgis.import([ | |
| "@arcgis/core/geometry/operators/distanceOperator.js", | |
| "@arcgis/core/layers/FeatureLayer.js", | |
| "@arcgis/core/layers/support/DisplayFilter.js", | |
| "@arcgis/core/layers/support/DisplayFilterInfo", | |
| "@arcgis/core/renderers/SimpleRenderer.js", | |
| ] as const); | |
| /** | |
| * The renderer for the layer. | |
| */ | |
| const renderer = new SimpleRenderer({ | |
| symbol: mpSymbol, | |
| }); | |
| /** | |
| * This is the `where` clause to hide all features. | |
| */ | |
| const displayNoneWhere = "1=0"; | |
| /** | |
| * This is the filter for the layer that initially has all its features hidden. | |
| */ | |
| const displayFilter = new DisplayFilter({ | |
| where: displayNoneWhere, | |
| id: "filter", | |
| }); | |
| /** | |
| * This is the display filter info for the layer which | |
| * defines the display filters to be used by the layer. | |
| */ | |
| const displayFilterInfo = new DisplayFilterInfo({ | |
| activeFilterId: displayFilter.id, | |
| filters: [displayFilter], | |
| }); | |
| /** | |
| * The mileposts layer. | |
| * | |
| * Overrides the AGOL-defined property for the layer: | |
| * | |
| * - title | |
| * - id | |
| * - renderer | |
| * - display filters | |
| * - labeling | |
| * - scales at which the layer is visible | |
| * - disable popup | |
| */ | |
| export const milepostLayer = new FeatureLayer({ | |
| id: "milepost", | |
| title: "Mileposts", | |
| renderer, | |
| displayFilterEnabled: true, | |
| labelingInfo, | |
| displayFilterInfo, | |
| portalItem: { | |
| id: agolId, | |
| }, | |
| popupEnabled: false, | |
| minScale: 0, | |
| maxScale: 0, | |
| }); | |
| /* | |
| Setup hot module reloading. | |
| When this module is modified, the labelling and symbology | |
| will be updated on the map without reloading the page. | |
| */ | |
| if (import.meta.hot) { | |
| import.meta.hot.accept( | |
| ["./labeling", "./symbol"] as const, | |
| ([newLabelingModule, newSymbolModule]) => { | |
| if (newLabelingModule) { | |
| console.log("hot module replacement", newLabelingModule); | |
| milepostLayer.labelingInfo = newLabelingModule.labelingInfo; | |
| milepostLayer.refresh(); | |
| } | |
| if (newSymbolModule) { | |
| console.log("hot module replacement", newSymbolModule); | |
| const { renderer } = milepostLayer; | |
| if (renderer?.type === "simple") { | |
| renderer.symbol = newSymbolModule.mpSymbol; | |
| milepostLayer.refresh(); | |
| } | |
| } | |
| }, | |
| ); | |
| } | |
| /** | |
| * Hides all features in the mileposts layer. | |
| */ | |
| function hideMilepostFeatures() { | |
| displayFilter.where = displayNoneWhere; | |
| } | |
| /** | |
| * Queries the mileposts layer for features nearest to where the user clicked. | |
| * @param clickPoint - The point where the user clicked. | |
| * @param features - The result of a query to the mileposts layer. | |
| * @param nearestOnly - Whether to return only the nearest feature as opposed | |
| * to multiple features, one for each nearby route and direction. | |
| * @returns - The nearest points to where the user clicked. | |
| * If {@link nearestOnly} is `true`, this will be an array of length 1; | |
| * otherwise, it will be an array of length greater than 1, unless there | |
| * are no nearby routes, in which case the array will be empty, regardless | |
| * of the value of {@link nearestOnly}. | |
| */ | |
| function getFeaturesNearestToPoint( | |
| clickPoint: __esri.Point, | |
| features: __esri.FeatureSet["features"], | |
| nearestOnly = true, | |
| ): __esri.Graphic[] { | |
| // Assign distance value to each feature. | |
| for (const f of features) { | |
| if (!f.geometry) { | |
| continue; | |
| } | |
| const distance = distanceOperator.execute(f.geometry, clickPoint); | |
| f.attributes.distance = distance; | |
| } | |
| /** | |
| * Sorts features by distance. | |
| * @param a - A {@link __esri.Graphic} | |
| * @param b - Another {@link __esri.Graphic} | |
| * @returns - -1 if a is less than b, 1 if a is greater than b, and 0 if they are equal | |
| */ | |
| const sortByDistance = (a: __esri.Graphic, b: __esri.Graphic): number => | |
| a.attributes.distance - b.attributes.distance; | |
| // If nearestOnly is true, return the nearest feature. | |
| if (nearestOnly) { | |
| // Sort features by distance. | |
| features.sort(sortByDistance); | |
| // Add the feature with the shortest distance to the output array. | |
| const nearestFeature = features.at(0); | |
| return nearestFeature ? [nearestFeature] : []; | |
| } | |
| // If nearestOnly is false, group features by route and direction, | |
| // and return the nearest feature for each group. | |
| /** | |
| * Groups features by route and direction. | |
| */ | |
| const groupedFeatures = Object.groupBy( | |
| features, | |
| (f) => | |
| `${f.attributes[routeIdField] as string}${f.attributes[directionField] as string}`, | |
| ); | |
| // Initialize the output array. | |
| const output: __esri.Graphic[] = []; | |
| // Loop through the grouped features and add the nearest feature to the output array. | |
| for (const [, graphics] of Object.entries(groupedFeatures)) { | |
| // Skip to next if there are no graphics in the current group. | |
| if (!graphics?.length) { | |
| continue; | |
| } | |
| // Sort features by distance. | |
| graphics.sort(sortByDistance); | |
| // Add the feature with the shortest distance to the output array. | |
| output.push(graphics[0]); | |
| } | |
| // Return the output array. | |
| return output; | |
| } | |
| interface MilepostQueryOptions | |
| extends Pick<__esri.QueryProperties, "distance" | "units"> { | |
| nearestOnly?: boolean; | |
| } | |
| const defaultMilepostQueryOptions: MilepostQueryOptions = { | |
| distance: 0.01, | |
| units: "miles", | |
| nearestOnly: true, | |
| }; | |
| /** | |
| * Queries the mileposts layer for features nearest to where the user clicked, and displays them on the map. | |
| * @param clickPoint - The point where the user clicked. | |
| * @returns - A promise that resolves when the features have been displayed. | |
| */ | |
| async function showMilepostsNearClick( | |
| clickPoint: __esri.Point, | |
| options?: MilepostQueryOptions, | |
| ) { | |
| // Get the query options, filling in default values where omitted from the options parameter. | |
| const { distance, units, nearestOnly } = { | |
| // Set the default values. | |
| ...defaultMilepostQueryOptions, | |
| // Override the defaults with the values from the options parameter. | |
| // Any values that were not provided will remain at their default values. | |
| ...(options ?? {}), | |
| }; | |
| /** | |
| * A query for the mileposts layer for all features | |
| * within a given distance of where the user clicked. | |
| */ | |
| const query = milepostLayer.createQuery(); | |
| query.geometry = clickPoint; | |
| query.distance = distance; | |
| query.units = units; | |
| // We do not need the geometry of the features returned by the query. | |
| query.returnGeometry = false; | |
| query.outFields = [ | |
| milepostLayer.objectIdField, | |
| routeIdField, | |
| directionField, | |
| srmpField, | |
| abField, | |
| ]; | |
| // Execute the query. | |
| const features = await milepostLayer.queryFeatures(query); | |
| // Filter the features to only the ones nearest to the click point. | |
| const filteredFeatures = getFeaturesNearestToPoint( | |
| clickPoint, | |
| features.features, | |
| nearestOnly, | |
| ); | |
| if (filteredFeatures.length === 0) { | |
| hideMilepostFeatures(); | |
| return; | |
| } | |
| const objectIds = filteredFeatures.map( | |
| /** | |
| * Extracts the object ID from the feature. | |
| * @param f - A {@link __esri.Graphic} | |
| * @returns The object ID of the feature | |
| * @throws {TypeError} Thrown if the object ID cannot be retrieved for some reason. | |
| * The {@link TypeError}'s cause property will contain the feature that caused the error in the `feature` key. | |
| */ | |
| (f) => { | |
| const oid = f.attributes[milepostLayer.objectIdField]; | |
| if (oid == null) { | |
| console.error("Could not retrieve Object ID.", f.toJSON()); | |
| throw new TypeError("Could not retrieve Object ID.", { | |
| cause: { | |
| feature: f, | |
| }, | |
| }); | |
| } | |
| return oid; | |
| }, | |
| ); | |
| // Update the display filter to only show the features nearest to the click point | |
| // by setting the where clause to only include the object IDs of the features nearest to the click point. | |
| displayFilter.where = | |
| objectIds.length > 0 | |
| ? `OBJECTID IN (${objectIds.join(",")})` | |
| : displayNoneWhere; | |
| } | |
| /** | |
| * Type guard for {@link __esri.MapView}. | |
| * @param view - A {@link __esri.View} | |
| * @returns True if the view is a {@link __esri.MapView}, false otherwise | |
| */ | |
| function isMapView(view: __esri.View): view is __esri.MapView { | |
| return view.type === "2d"; | |
| } | |
| milepostLayer.on( | |
| "layerview-create", | |
| /** | |
| * Event handler for the `layerview-create` event that | |
| * shows mileposts near the user's click on the map if | |
| * they have not clicked on any features. | |
| * @param event - The event object | |
| */ | |
| (event) => { | |
| const { view } = event; | |
| view.on("click", async (clickEvent) => { | |
| // Exit immediately if the current view is not a MapView. | |
| if (!isMapView(view)) { | |
| return; | |
| } | |
| // Specify the layers to be included, which is all of the map's FeatureLayers. | |
| const includeLayers = view.map?.layers.filter( | |
| (l) => l.type === "feature", | |
| ); | |
| // Test to see if the user click intersected with any of the feature layers. | |
| const hitTestResult = await view.hitTest(clickEvent, { | |
| include: includeLayers, | |
| }); | |
| // If the user click did not intersect with any of the feature layers, show the mileposts near the click. | |
| // Otherwise, hide all of the milepost features. | |
| if (!hitTestResult.results.length) { | |
| showMilepostsNearClick(clickEvent.mapPoint); | |
| } else { | |
| hideMilepostFeatures(); | |
| } | |
| }); | |
| }, | |
| ); |
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
| // 👆 Added a ".js" to this filename just to get the syntax highlighting on GitHub Gists. | |
| // You should remove the ".js" part when using it. | |
| /** | |
| * This Arcade script is used to generate labels for the milepost layer. | |
| */ | |
| /** | |
| * Gets the SRMP and Ahead/Back indicator attributes and | |
| * concatenates them into a single string. | |
| * @returns A string containing a number with up to three decimal places | |
| * and an optional "B" indicator. | |
| */ | |
| function getMilepost() { | |
| var output = Text($feature.SRMP, "#.###"); | |
| if ($feature.AheadBackInd == "B") { | |
| output += "B" | |
| } | |
| return output; | |
| } | |
| /** | |
| * Gets the route prefix "I-", "US ", or "SR " corresponding to the | |
| * route associated with the current milepost feature. | |
| * @returns "I-", "US ", or "SR " | |
| */ | |
| function getShield() { | |
| var isRoutes = [5, 82, 90, 182, 205, 405, 705]; | |
| var usRoutes = [2, 12, 97, 101, 195, 197, 395, 730]; | |
| var shield = "SR "; | |
| var srNum = Number($feature.StateRouteNumber); | |
| if (Includes(isRoutes, srNum)) { | |
| shield = "I-"; | |
| } else if (Includes(usRoutes, srNum)) { | |
| shield = "US "; | |
| } | |
| return shield; | |
| } | |
| /** | |
| * Gets the route label for the current milepost feature: | |
| * an integer with a suffix returned by {@link getShield()}. | |
| * @returns The route label for the current milepost feature. | |
| */ | |
| function getRouteLabel() { | |
| var output = $feature.RouteID; | |
| if ($feature.StateRouteNumber == $feature.RouteID) { | |
| output = `${getShield()}${Number($feature.StateRouteNumber)}` | |
| } | |
| return output; | |
| } | |
| // Concatenate the route label and milepost into a single string, along with | |
| // corresponding labels, separated by a newline. | |
| Concatenate([`Route: ${getRouteLabel()} (${$feature.Direction})`, `Milepost: ${getMilepost()}`], "\n"); |
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
| import labelExpression from "./label.arcade?raw"; | |
| const [TextSymbol, LabelClass] = await $arcgis.import([ | |
| "@arcgis/core/symbols/TextSymbol.js", | |
| "@arcgis/core/layers/support/LabelClass.js", | |
| ] as const); | |
| const textSymbol = new TextSymbol({ | |
| color: import.meta.env.VITE_MP_SIGN_FG_COLOR, | |
| font: { | |
| family: "Arial", | |
| size: 9, | |
| weight: "bolder", | |
| }, | |
| horizontalAlignment: "center", | |
| backgroundColor: import.meta.env.VITE_MP_SIGN_BG_COLOR, | |
| borderLineColor: import.meta.env.VITE_MP_SIGN_FG_COLOR, | |
| borderLineSize: 2, | |
| }); | |
| export const labelingInfo = [ | |
| new LabelClass({ | |
| labelExpressionInfo: { | |
| title: "Route and Milepost", | |
| expression: labelExpression, | |
| }, | |
| labelPlacement: "above-center", | |
| symbol: textSymbol, | |
| }), | |
| ]; |
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
| const SimpleMarkerSymbol = await $arcgis.import( | |
| "@arcgis/core/symbols/SimpleMarkerSymbol.js", | |
| ); | |
| export const mpSymbol = new SimpleMarkerSymbol({ | |
| color: [1, 115, 92, 255], | |
| size: 10, | |
| outline: { | |
| color: [255, 255, 255, 255], | |
| width: 1, | |
| }, | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment