diff --git a/src/addGraphicsToLayer.ts b/src/addGraphicsToLayer.ts index 851747aa..12d27dc6 100644 --- a/src/addGraphicsToLayer.ts +++ b/src/addGraphicsToLayer.ts @@ -11,27 +11,32 @@ export async function addGraphicsToLayer( milepostLayer: FeatureLayer, locationGraphics: Graphic[], ) { - // Add graphics to the layer and await for the edit to complete. - const editsResult = await milepostLayer.applyEdits( - { - addFeatures: locationGraphics, - }, - {}, - ); - - // Get the added features from the edits result by querying the milepost layer - // for the features with matching object IDs. - const query = milepostLayer.createQuery(); - query.objectIds = editsResult.addFeatureResults.map((r) => r.objectId); - const results = await milepostLayer.queryFeatures(query); + /* __PURE__ */ console.group(addGraphicsToLayer.name, { + milepostLayer: { ...milepostLayer }, + locationGraphics: locationGraphics.map((g) => g.toJSON() as unknown), + }); + try { + // Add graphics to the layer and await for the edit to complete. + const editsResult = await milepostLayer.applyEdits( + { + addFeatures: locationGraphics, + }, + {}, + ); + /* __PURE__ */ console.debug( + "editsResult", + editsResult.addFeatureResults.map((e) => ({ ...e })), + ); - return results.features; -} + // Get the added features from the edits result by querying the milepost layer + // for the features with matching object IDs. + const query = milepostLayer.createQuery(); + /* __PURE__ */ console.debug("query", query.toJSON()); + query.objectIds = editsResult.addFeatureResults.map((r) => r.objectId); + const results = await milepostLayer.queryFeatures(query); -if (import.meta.hot) { - import.meta.hot.accept((newModule) => { - if (newModule) { - console.log("hot module replacement", newModule); - } - }); + return results.features; + } finally { + /* __PURE__ */ console.groupEnd(); + } } diff --git a/src/layers/MilepostLayer/index.ts b/src/layers/MilepostLayer/index.ts index 953ea689..60e43fe9 100644 --- a/src/layers/MilepostLayer/index.ts +++ b/src/layers/MilepostLayer/index.ts @@ -1,20 +1,14 @@ import Collection from "@arcgis/core/core/Collection"; -import type SpatialReference from "@arcgis/core/geometry/SpatialReference"; -import FeatureLayer from "@arcgis/core/layers/FeatureLayer"; +import type FeatureLayer from "@arcgis/core/layers/FeatureLayer"; import type Field from "@arcgis/core/layers/support/Field"; import FieldInfo from "@arcgis/core/popup/FieldInfo"; -import SimpleRenderer from "@arcgis/core/renderers/SimpleRenderer"; import ActionButton from "@arcgis/core/support/actions/ActionButton"; -import { SimpleMarkerSymbol } from "@arcgis/core/symbols"; -import waExtent from "../../WAExtent"; -import { highwaySignBackgroundColor, highwaySignTextColor } from "../../colors"; import { objectIdFieldName } from "../../elc/types"; import type { MilepostExpressionInfo } from "./arcade"; import { expressions as arcadeExpressions, locationLinksContent, } from "./arcade"; -import labelClass from "./labelClass"; type FieldProperties = Required>[0]; @@ -25,7 +19,7 @@ export enum fieldNames { Direction = "Direction", } -const fields = [ +export const fields = [ { name: objectIdFieldName, type: "oid", @@ -86,101 +80,57 @@ function createActionButtons() { } /** - * Creates the {@link FeatureLayer} that displays located mileposts. - * @param spatialReference - The {@link SpatialReference} of the layer. - * @returns - A {@link FeatureLayer} + * A function that creates and adds field information for an expression. + * @param milepostExpressionInfo - The expression information to create the field info for. + * @returns The created field info. */ -export function createMilepostLayer(spatialReference: SpatialReference) { - /** - * A function that creates and adds field information for an expression. - * @param milepostExpressionInfo - The expression information to create the field info for. - * @returns The created field info. - */ - function createAndAddFieldInfoForExpression( - milepostExpressionInfo: MilepostExpressionInfo, - ) { - const fieldInfo = new FieldInfo({ - fieldName: `expression/${milepostExpressionInfo.name}`, - visible: !["webMercatorToWgs1984", "milepostLabel"].includes( - milepostExpressionInfo.name, - ), - }); - return fieldInfo; - } - - /** - * Creates a popup template for the milepost layer by hiding certain fields and adding arcade expressions. - * @returns The created popup template. - */ - function createPopupTemplate() { - const popupTemplate = milepostLayer.createPopupTemplate({ - // Hide all of the initial fields. - // These fields are already displayed in the popup's title. - visibleFieldNames: new Set(), - }); +function createAndAddFieldInfoForExpression( + milepostExpressionInfo: MilepostExpressionInfo, +) { + const fieldInfo = new FieldInfo({ + fieldName: `expression/${milepostExpressionInfo.name}`, + visible: !["webMercatorToWgs1984", "milepostLabel"].includes( + milepostExpressionInfo.name, + ), + }); + return fieldInfo; +} - const actions = createActionButtons(); - popupTemplate.actions = actions; +/** + * Creates a popup template for the milepost layer by hiding certain fields and adding arcade expressions. + * @param milepostLayer - The milepost layer. + * @returns The created popup template. + */ +export function createPopupTemplate(milepostLayer: FeatureLayer) { + const popupTemplate = milepostLayer.createPopupTemplate({ + // Hide all of the initial fields. + // These fields are already displayed in the popup's title. + visibleFieldNames: new Set(), + }); - // Import the Arcade expressions, add them to the popup template, and then - // add them to the popup template's fieldInfos array. - popupTemplate.expressionInfos = arcadeExpressions; + const actions = createActionButtons(); + popupTemplate.actions = actions; - // Append expressions to the PopupTemplate's fieldInfos array. - for (const xi of arcadeExpressions) { - const fieldInfo = createAndAddFieldInfoForExpression(xi); - // Hide the GeoURI and SRViewURL fields. - if (["geoURI"].includes(xi.name)) { - fieldInfo.visible = false; - } - popupTemplate.fieldInfos.push(fieldInfo); - } - popupTemplate.title = "{Route} ({Direction}) @ {expression/milepostLabel}"; + // Import the Arcade expressions, add them to the popup template, and then + // add them to the popup template's fieldInfos array. + popupTemplate.expressionInfos = arcadeExpressions; - if (Array.isArray(popupTemplate.content)) { - popupTemplate.content = [locationLinksContent, ...popupTemplate.content]; + // Append expressions to the PopupTemplate's fieldInfos array. + for (const xi of arcadeExpressions) { + const fieldInfo = createAndAddFieldInfoForExpression(xi); + // Hide the GeoURI and SRViewURL fields. + if (["geoURI"].includes(xi.name)) { + fieldInfo.visible = false; } + popupTemplate.fieldInfos.push(fieldInfo); + } + popupTemplate.title = "{Route} ({Direction}) @ {expression/milepostLabel}"; - return popupTemplate; + if (Array.isArray(popupTemplate.content)) { + popupTemplate.content = [locationLinksContent, ...popupTemplate.content]; } - /** - * This is the symbol for the point on the route. - */ - const milepostLayer = new FeatureLayer({ - labelingInfo: [labelClass], - title: "Mileposts", - id: "mileposts", - listMode: "hide", - fields: fields, - geometryType: "point", - objectIdField: objectIdFieldName, - fullExtent: waExtent, - spatialReference, - // Since there are no features at the beginning, - // need to add an empty array as the source. - source: [], - popupEnabled: true, - hasM: true, - }); - milepostLayer.renderer = createRenderer(); - milepostLayer.popupTemplate = createPopupTemplate(); + milepostLayer.popupTemplate = popupTemplate; - return milepostLayer; -} -function createRenderer() { - const actualMPSymbol = new SimpleMarkerSymbol({ - color: highwaySignBackgroundColor, - size: 12, - style: "circle", - outline: { - width: 1, - color: highwaySignTextColor, - }, - }); - - const renderer = new SimpleRenderer({ - symbol: actualMPSymbol, - }); - return renderer; + return popupTemplate; } diff --git a/LocateMP.stylx b/src/layers/MilepostLayer/milepost-line-layer/LocateMP.stylx similarity index 100% rename from LocateMP.stylx rename to src/layers/MilepostLayer/milepost-line-layer/LocateMP.stylx diff --git a/src/layers/Milepost Location Renderer.json b/src/layers/MilepostLayer/milepost-line-layer/Milepost Location Renderer.json similarity index 100% rename from src/layers/Milepost Location Renderer.json rename to src/layers/MilepostLayer/milepost-line-layer/Milepost Location Renderer.json diff --git a/src/layers/MilepostLayer/milepost-line-layer/MilepostOffsetLineRenderer.ts b/src/layers/MilepostLayer/milepost-line-layer/MilepostOffsetLineRenderer.ts new file mode 100644 index 00000000..ce3914b7 --- /dev/null +++ b/src/layers/MilepostLayer/milepost-line-layer/MilepostOffsetLineRenderer.ts @@ -0,0 +1,7 @@ +import MilepostLocationRenderer from "./Milepost Location Renderer.json"; +import SimpleRenderer from "@arcgis/core/renderers/SimpleRenderer"; + +/** + * Simple Renderer using a CIM symbol. + */ +export default SimpleRenderer.fromJSON(MilepostLocationRenderer); diff --git a/src/layers/MilepostLayer/milepost-line-layer/index.ts b/src/layers/MilepostLayer/milepost-line-layer/index.ts new file mode 100644 index 00000000..a72aa12a --- /dev/null +++ b/src/layers/MilepostLayer/milepost-line-layer/index.ts @@ -0,0 +1,38 @@ +import FeatureLayer from "@arcgis/core/layers/FeatureLayer"; +import { createPopupTemplate, fields } from ".."; +import waExtent from "../../../WAExtent"; +import { objectIdFieldName } from "../../../elc/types"; +import MilepostOffsetLineRenderer from "./MilepostOffsetLineRenderer"; + +/** + * Creates a new feature layer that displays mileposts as lines. + * @param spatialReference - The spatial reference of the layer. + * @returns A new feature layer that displays mileposts as lines. + */ +export function createMilepostLineLayer( + spatialReference = waExtent.spatialReference, +) { + // Make a clone of the milepost point layer, as most of the properties + // will be the same aside from the geometry type and renderer. + const lineLayerProperties: __esri.FeatureLayerProperties = { + geometryType: "polyline", + title: "Near Mileposts", + fields, + objectIdField: objectIdFieldName, + id: "nearMileposts", + listMode: "hide", + fullExtent: waExtent, + spatialReference, + // Since there are no features at the beginning, + // need to add an empty array as the source. + renderer: MilepostOffsetLineRenderer, + source: [], + popupEnabled: true, + hasM: true, + }; + + const lineLayer = new FeatureLayer(lineLayerProperties); + lineLayer.popupTemplate = createPopupTemplate(lineLayer); + + return lineLayer; +} diff --git a/src/layers/MilepostLayer/milepost-point-layer/index.ts b/src/layers/MilepostLayer/milepost-point-layer/index.ts new file mode 100644 index 00000000..6c0b6275 --- /dev/null +++ b/src/layers/MilepostLayer/milepost-point-layer/index.ts @@ -0,0 +1,60 @@ +import type SpatialReference from "@arcgis/core/geometry/SpatialReference"; +import FeatureLayer from "@arcgis/core/layers/FeatureLayer"; +import SimpleRenderer from "@arcgis/core/renderers/SimpleRenderer"; +import { SimpleMarkerSymbol } from "@arcgis/core/symbols"; +import { createPopupTemplate, fields } from ".."; +import waExtent from "../../../WAExtent"; +import { + highwaySignBackgroundColor, + highwaySignTextColor, +} from "../../../colors"; +import { objectIdFieldName } from "../../../elc/types"; +import labelClass from "../labelClass"; + +/** + * Creates the {@link FeatureLayer} that displays located mileposts. + * @param spatialReference - The {@link SpatialReference} of the layer. + * @returns - A {@link FeatureLayer} + */ +export function createMilepostPointLayer(spatialReference: SpatialReference) { + /** + * This is the symbol for the point on the route. + */ + const milepostLayer = new FeatureLayer({ + labelingInfo: [labelClass], + title: "Mileposts", + id: "mileposts", + listMode: "hide", + fields: fields, + geometryType: "point", + objectIdField: objectIdFieldName, + fullExtent: waExtent, + spatialReference, + // Since there are no features at the beginning, + // need to add an empty array as the source. + source: [], + popupEnabled: true, + hasM: true, + }); + + milepostLayer.renderer = createRenderer(); + createPopupTemplate(milepostLayer); + + return milepostLayer; +} +function createRenderer() { + const actualMPSymbol = new SimpleMarkerSymbol({ + color: highwaySignBackgroundColor, + size: 12, + style: "circle", + outline: { + width: 1, + color: highwaySignTextColor, + }, + }); + + const renderer = new SimpleRenderer({ + symbol: actualMPSymbol, + }); + return renderer; +} diff --git a/src/main.ts b/src/main.ts index d835580e..faee0928 100644 --- a/src/main.ts +++ b/src/main.ts @@ -19,13 +19,14 @@ import "./layers/AccessControlLayer"; import { accessControlLayer } from "./layers/AccessControlLayer"; import { cityLimitsLayer } from "./layers/CityLimitsLayer"; import "./layers/MilepostLayer"; -import { createMilepostLayer } from "./layers/MilepostLayer"; +import { createMilepostLineLayer } from "./layers/MilepostLayer/milepost-line-layer"; +import { createMilepostPointLayer } from "./layers/MilepostLayer/milepost-point-layer"; import "./layers/TempLayer"; import { tempLayer } from "./layers/TempLayer"; import "./layers/parcels"; import { createParcelsGroupLayer } from "./layers/parcels"; import "./types"; -import { UIAddPositions, isGraphicHit } from "./types"; +import { UIAddPositions, hasXAndY, isGraphicHit } from "./types"; import isInternal from "./urls/isIntranet"; import { setupSidebarCollapseButton } from "./widgets/CollapseButton"; import "./widgets/LayerList"; @@ -37,6 +38,7 @@ import EsriMap from "@arcgis/core/Map"; import Viewpoint from "@arcgis/core/Viewpoint"; import config from "@arcgis/core/config"; import { whenOnce } from "@arcgis/core/core/reactiveUtils"; +import Polyline from "@arcgis/core/geometry/Polyline"; import PortalItem from "@arcgis/core/portal/PortalItem"; import MapView from "@arcgis/core/views/MapView"; import Expand from "@arcgis/core/widgets/Expand"; @@ -57,6 +59,7 @@ import("./setupAnalytics") .then(({ default: a }) => { /* __PURE__ */ console.debug("Tag Manager loaded", a); analytics = a; + analytics?.page(); }) .catch((reason) => { console.error("Failed to load Tag Manager", reason); @@ -207,8 +210,15 @@ const elcMainlinesOnlyFilter = * @param view - The map view. */ function openPopup(hits: __esri.GraphicHit[], view: MapView) { + /* __PURE__ */ console.debug("openPopup", { + hits: hits.map((h) => h.graphic.toJSON() as unknown), + }); + function extractGraphic(graphicHit: __esri.GraphicHit): Graphic { + const { graphic } = graphicHit; + return graphic; + } // Get the features that were hit by the hit test. - const features = hits.map(({ graphic }) => graphic); + const features = hits.map(extractGraphic); const updateUrlSearch = () => { const routeLocation = features .map( @@ -229,6 +239,9 @@ function openPopup(hits: __esri.GraphicHit[], view: MapView) { updateUrlSearchParams(routeLocation); }; + /* __PURE__ */ console.debug("about to open popup", { + features: features.map((f) => f.toJSON() as unknown), + }); view .openPopup({ features, @@ -283,6 +296,7 @@ if (!testWebGL2Support()) { * @returns - a promise that resolves to an array of {@link RouteLocation|RouteLocations} */ async function callFindNearestRouteLocation(event: __esri.ViewClickEvent) { + /* __PURE__ */ console.group(callFindNearestRouteLocation.name); const { x, y, spatialReference } = event.mapPoint; const locations = await findNearestRouteLocations({ coordinates: [x, y], @@ -298,7 +312,24 @@ if (!testWebGL2Support()) { } const locationGraphic = routeLocationToGraphic(location); - addGraphicsToLayer(milepostLayer, [locationGraphic]) + if (hasXAndY(locationGraphic.geometry)) { + const { x: routeX, y: routeY } = locationGraphic.geometry; + locationGraphic.geometry = new Polyline({ + paths: [ + [ + [x, y], + [routeX, routeY], + ], + ], + spatialReference, + }); + } + const layer = + locationGraphic.geometry.type === "point" + ? milepostPointLayer + : milepostLineLayer; + /* __PURE__ */ console.debug("location graphic", locationGraphic.toJSON()); + addGraphicsToLayer(layer, [locationGraphic]) .then((addResults) => { /* __PURE__ */ console.debug( "addResults returned by addGraphicsToLayer", @@ -308,7 +339,6 @@ if (!testWebGL2Support()) { .catch((error: unknown) => { console.error("addGraphicsToLayer failed", error); }); - return locations; } @@ -325,10 +355,13 @@ if (!testWebGL2Support()) { } request.httpsDomains.push("wsdot.wa.gov", "data.wsdot.wa.gov"); - const milepostLayer = createMilepostLayer(waExtent.spatialReference); + const milepostPointLayer = createMilepostPointLayer( + waExtent.spatialReference, + ); + const milepostLineLayer = createMilepostLineLayer(waExtent.spatialReference); // Show the instructions alert once the mileposts layer has been loaded. - milepostLayer.on("layerview-create", () => { + milepostPointLayer.on("layerview-create", () => { const alert = document.body.querySelector( "#instructionsAlert", @@ -355,7 +388,13 @@ if (!testWebGL2Support()) { const map = new EsriMap({ basemap: grayBasemap, - layers: [cityLimitsLayer, accessControlLayer, tempLayer, milepostLayer], + layers: [ + cityLimitsLayer, + accessControlLayer, + tempLayer, + milepostPointLayer, + milepostLineLayer, + ], }); map.add(createParcelsGroupLayer()); @@ -461,7 +500,7 @@ if (!testWebGL2Support()) { import("./widgets/ClearButton").then( ({ createClearButton }) => { const clearButton = createClearButton({ - layers: [milepostLayer, tempLayer], + layers: [milepostPointLayer, milepostLineLayer, tempLayer], }); view.ui.add([home, clearButton], UIAddPositions.topLeading); }, @@ -512,6 +551,9 @@ if (!testWebGL2Support()) { // Call findNearestRouteLocations try { await callFindNearestRouteLocation(event); + removeTempGraphic().catch((reason: unknown) => { + console.error("Failed to remove temporary graphic", reason); + }); } catch (error) { const message = "Could not find a route location near this location."; @@ -542,16 +584,16 @@ if (!testWebGL2Support()) { * Removes the temporary graphic. * @returns - a promise that resolves when the graphic is removed. */ - const removeTempGraphic = () => { + function removeTempGraphic() { // Remove the temporary graphic return tempLayer.applyEdits({ deleteFeatures: [tempGraphic], }); - }; + } }; view .hitTest(event, { - include: milepostLayer, + include: [milepostPointLayer, milepostLineLayer], }) .then(handleHitTestResult) .catch((reason: unknown) => { @@ -562,18 +604,18 @@ if (!testWebGL2Support()) { // Set up the form for inputting SRMPdata. import("./setupForm") - .then(({ setupForm }) => setupForm(view, milepostLayer)) + .then(({ setupForm }) => setupForm(view, milepostPointLayer)) .catch((reason: unknown) => { console.error("failed to setup form", reason); }); if (import.meta.env.DEV) { - milepostLayer + milepostPointLayer .when(async () => { const { createExportButton } = await import("./widgets/ExportButton"); const button = createExportButton({ - layer: milepostLayer, + layer: milepostPointLayer, }); view.ui.add(button, UIAddPositions.bottomTrailing); }) @@ -584,16 +626,16 @@ if (!testWebGL2Support()) { // Once the milepost layerview has been created, check for ELC data from the URL // and, if present, add the location to the map. - milepostLayer.on("layerview-create", () => { + milepostPointLayer.on("layerview-create", () => { /** * Calls the ELC API to retrieve graphics from the URL and adds them to the milepost layer. * @returns A promise that resolves when the graphics have been added to the layer and the view has been updated. */ const callElc = async () => { - const elcGraphics = await callElcFromUrl(milepostLayer); + const elcGraphics = await callElcFromUrl(milepostPointLayer); if (elcGraphics) { const addedFeatures = await addGraphicsToLayer( - milepostLayer, + milepostPointLayer, elcGraphics, ); const scale = Number.parseFloat(import.meta.env.VITE_ZOOM_SCALE); diff --git a/src/types.ts b/src/types.ts index a1721bb3..7594377e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -60,7 +60,7 @@ export interface TypedGraphic< * false otherwise. */ export function isGraphicHit( - viewHit: __esri.ViewHit, + viewHit: __esri.MapViewViewHit, ): viewHit is __esri.GraphicHit { return viewHit.type === "graphic"; } diff --git a/tests/CIM.test.ts b/tests/CIM.test.ts new file mode 100644 index 00000000..6d429356 --- /dev/null +++ b/tests/CIM.test.ts @@ -0,0 +1,11 @@ +import renderer from "../src/layers/MilepostLayer/milepost-line-layer/MilepostOffsetLineRenderer"; +import { describe, test } from "vitest"; + +describe.concurrent( + "CIM renderer from JSON exported from ArcGIS Online", + () => { + test("renderer was created successfully", ({ expect }) => { + expect(renderer).toBeDefined(); + }); + }, +);