From da2fd26fbf386dbde1230ec6526084ee302feeaf Mon Sep 17 00:00:00 2001 From: Soumajit Ghosh Date: Mon, 24 Aug 2026 14:06:32 +0530 Subject: [PATCH 1/5] refactor: normalize map provider contract --- src/components/map/MapView.svelte | 7 +++++ src/lib/Provider/GoogleMapProvider.svelte.js | 31 ++++++++++++++----- .../Provider/OpenStreetMapProvider.svelte.js | 21 ++++++++++--- src/lib/types.js | 9 +++--- 4 files changed, 51 insertions(+), 17 deletions(-) diff --git a/src/components/map/MapView.svelte b/src/components/map/MapView.svelte index 730a7042..c1e31513 100644 --- a/src/components/map/MapView.svelte +++ b/src/components/map/MapView.svelte @@ -179,6 +179,11 @@ } const boundingBox = getBoundingBox(); + if (!boundingBox) { + // A provider can briefly have no valid extent while initializing or + // tearing down. Never turn that into a Null Island stop request. + return null; + } const key = cacheKey(zoomLevel, boundingBox); if (stopsCache.has(key)) { @@ -252,6 +257,7 @@ async function loadStopsAndAddMarkers(lat, lng, firstCall = false, zoomLevel = 15) { const stopsData = await loadStopsForLocation(lat, lng, zoomLevel, firstCall); + if (!stopsData) return; const newStops = stopsData.data.list; const routeReference = stopsData.data.references.routes || []; @@ -396,6 +402,7 @@ } clearAllMarkers(); + mapProvider?.destroy?.(); allStopsMap.clear(); stopsCache.clear(); diff --git a/src/lib/Provider/GoogleMapProvider.svelte.js b/src/lib/Provider/GoogleMapProvider.svelte.js index 4472b507..e2587960 100644 --- a/src/lib/Provider/GoogleMapProvider.svelte.js +++ b/src/lib/Provider/GoogleMapProvider.svelte.js @@ -75,6 +75,9 @@ export default class GoogleMapProvider { lat: options.lat, lng: options.lng }); + // Keep createPolyline synchronous across providers. The geometry library is + // the only Google constructor it needs after map initialization. + await window.google.maps.importLibrary('geometry'); // Update route labels (on stops) visibility on zoom changes this.map.addListener('zoom_changed', () => { @@ -631,13 +634,10 @@ export default class GoogleMapProvider { * Creates a polyline from an encoded shape, returning `null` when the shape * can't be decoded (uniform with the OSM provider). * - * Contract note: this method is async — it returns a `Promise` - * because it lazy-loads the Google geometry library — whereas the OSM - * provider's createPolyline is synchronous (`Polyline|null`). Callers that - * need provider-agnostic behavior should `await` the result and guard - * against `null`. + * The geometry library is loaded in initMap(), so this returns a synchronous + * `Polyline|null` handle like the other providers. */ - async createPolyline(shape, options = {}) { + createPolyline(shape, options = {}) { // Backward compat: old callers pass a boolean as the second arg if (typeof options === 'boolean') { options = { withArrow: options }; @@ -645,8 +645,6 @@ export default class GoogleMapProvider { const withArrow = options.withArrow !== undefined ? options.withArrow : true; - await window.google.maps.importLibrary('geometry'); - let decodedPath; try { decodedPath = google.maps.geometry.encoding.decodePath(shape); @@ -949,4 +947,21 @@ export default class GoogleMapProvider { west: sw.lng() }; } + + destroy() { + if (!this.map) return; + this.clearAllStopMarkers(); + this.removeStopMarkers(); + this.clearVehicleMarkers(); + this.clearAllPolylines(); + this.removeUserLocationMarker(); + this.cleanupInfoWindow(); + this.closeContextMenu(); + if (this.contextMenuComponent) unmount(this.contextMenuComponent); + if (this.popupContentComponent) unmount(this.popupContentComponent); + if (window.google?.maps?.event?.clearInstanceListeners) { + window.google.maps.event.clearInstanceListeners(this.map); + } + this.map = null; + } } diff --git a/src/lib/Provider/OpenStreetMapProvider.svelte.js b/src/lib/Provider/OpenStreetMapProvider.svelte.js index c631cf70..da612712 100644 --- a/src/lib/Provider/OpenStreetMapProvider.svelte.js +++ b/src/lib/Provider/OpenStreetMapProvider.svelte.js @@ -706,11 +706,8 @@ export default class OpenStreetMapProvider { * Creates a polyline from an encoded shape, returning `null` outside the * browser, before the map is initialized, or when the shape decodes to empty. * - * Contract note: this method is synchronous (`Polyline|null`), whereas the - * Google provider's createPolyline is async (`Promise`) - * because it lazy-loads its geometry library. Both return `null` on decode - * failure; callers that need provider-agnostic behavior should `await` the - * result and guard against `null`. + * This returns a synchronous `Polyline|null` handle, matching the other map + * providers. It returns `null` on decode failure. */ createPolyline(points, options = {}) { if (!browser || !this.map) return null; @@ -1150,4 +1147,18 @@ export default class OpenStreetMapProvider { west: sw.lng }; } + + destroy() { + if (!this.map) return; + this.clearAllStopMarkers(); + this.removeStopMarkers(); + this.clearVehicleMarkers(); + this.clearAllPolylines(); + this.removeUserLocationMarker(); + this.cleanupInfoWindow(); + this.closeContextMenu(); + this.map.remove?.(); + this.map = null; + this.maplibreLayer = null; + } } diff --git a/src/lib/types.js b/src/lib/types.js index ca49210b..c90f8bb7 100644 --- a/src/lib/types.js +++ b/src/lib/types.js @@ -6,12 +6,13 @@ */ /** - * A map provider instance, as created in MapContainer.svelte. Either the - * Leaflet/OpenStreetMap implementation or the Google Maps implementation; both - * expose the same interface (initMap, addMarker, panTo, flyTo, ...). + * A map provider instance, as created in MapContainer.svelte. Implementations + * expose standard `{lat,lng}` coordinates, WGS84 bounds or `null`, synchronous + * marker/polyline handles, and an idempotent `destroy()` lifecycle method. * * @typedef {import('./Provider/OpenStreetMapProvider.svelte.js').default - * | import('./Provider/GoogleMapProvider.svelte.js').default} MapProvider + * | import('./Provider/GoogleMapProvider.svelte.js').default + * | import('./Provider/ArcGISMapProvider.svelte.js').default} MapProvider */ /** From 55e3371b0737f183ddeeb9995625ab01a61a81a7 Mon Sep 17 00:00:00 2001 From: Soumajit Ghosh Date: Mon, 24 Aug 2026 14:06:37 +0530 Subject: [PATCH 2/5] feat: add ArcGIS map provider --- package-lock.json | 539 +++++++++++- package.json | 1 + src/assets/styles/arcgis-map.css | 45 + src/lib/Provider/ArcGISMapProvider.svelte.js | 866 +++++++++++++++++++ 4 files changed, 1447 insertions(+), 4 deletions(-) create mode 100644 src/assets/styles/arcgis-map.css create mode 100644 src/lib/Provider/ArcGISMapProvider.svelte.js diff --git a/package-lock.json b/package-lock.json index 96a2434d..4ca8135e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "onebusaway-webapp", "version": "0.0.1", "dependencies": { + "@arcgis/core": "4.33.14", "@fortawesome/free-regular-svg-icons": "^6.6.0", "@googlemaps/js-api-loader": "^1.16.8", "@maplibre/maplibre-gl-leaflet": "^0.0.22", @@ -88,6 +89,61 @@ "node": ">=6.0.0" } }, + "node_modules/@arcgis/core": { + "version": "4.33.14", + "resolved": "https://registry.npmjs.org/@arcgis/core/-/core-4.33.14.tgz", + "integrity": "sha512-2/W8el2uGPib2NO7OoCqDx0Nniu7QlJQJr5AeU+50kVktJD3HhcwNJxqg8Jiibyml3AK0rB03HW1Ypbct7gMpg==", + "license": "SEE LICENSE IN copyright.txt", + "dependencies": { + "@esri/arcgis-html-sanitizer": "~4.1.0", + "@esri/calcite-components": "^3.2.1", + "@vaadin/grid": "~24.7.6", + "@zip.js/zip.js": "~2.7.62", + "luxon": "~3.6.1", + "marked": "~15.0.12" + } + }, + "node_modules/@arcgis/core/node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@arcgis/lumina": { + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@arcgis/lumina/-/lumina-4.34.9.tgz", + "integrity": "sha512-efqO+SwR+1IYf29AATh1l2FUeypRyRINTBNkaJY+KkaFe+8gqSJ45qOmputhyzF5WTRDb7WhOYgnChjp6VYPpA==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@arcgis/toolkit": "~4.34.9", + "csstype": "^3.1.3", + "tslib": "^2.8.1" + }, + "peerDependencies": { + "@lit/context": "^1.1.5", + "lit": "^3.3.0" + }, + "peerDependenciesMeta": { + "@lit/context": { + "optional": true + } + } + }, + "node_modules/@arcgis/toolkit": { + "version": "4.34.9", + "resolved": "https://registry.npmjs.org/@arcgis/toolkit/-/toolkit-4.34.9.tgz", + "integrity": "sha512-wFST+eVnCwmg9NyICVyn9bsBnR+TlWklsGqG3L7xqSTgfXo6TuCThE7wtTb8xWxsTBkGvImqMUgpgLuwQuTQ1g==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "tslib": "^2.8.1" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.1.1.tgz", @@ -851,11 +907,54 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@esri/arcgis-html-sanitizer": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@esri/arcgis-html-sanitizer/-/arcgis-html-sanitizer-4.1.0.tgz", + "integrity": "sha512-einEveDJ/k1180NOp78PB/4Hje9eBy3dyOGLLtLn6bSkizpUfCwuYBIXOA7Y3F/k/BsTQXgKqUVwQ0eiscWMdA==", + "license": "Apache-2.0", + "dependencies": { + "xss": "1.0.13" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@esri/calcite-components": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@esri/calcite-components/-/calcite-components-3.3.3.tgz", + "integrity": "sha512-tw+EfJ3pb+Odj71W6E9GUkm8rMbNxfW1KeiI8GgsKDzhr39hMKwY+zYYFFYuO0FONxWGvAB+B8yqB0NvH7WeHw==", + "license": "SEE LICENSE.md", + "dependencies": { + "@arcgis/lumina": ">=4.34.0-next.158 <4.35.0", + "@arcgis/toolkit": ">=4.34.0-next.158 <4.35.0", + "@esri/calcite-ui-icons": "4.3.0", + "@floating-ui/dom": "^1.6.12", + "@floating-ui/utils": "^0.2.8", + "@types/sortablejs": "^1.15.8", + "color": "^5.0.0", + "composed-offset-position": "^0.0.6", + "es-toolkit": "^1.39.8", + "focus-trap": "^7.6.5", + "interactjs": "^1.10.27", + "lit": "^3.3.0", + "sortablejs": "^1.15.6", + "timezone-groups": "^0.10.4", + "type-fest": "^4.30.1" + } + }, + "node_modules/@esri/calcite-ui-icons": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@esri/calcite-ui-icons/-/calcite-ui-icons-4.3.0.tgz", + "integrity": "sha512-iOOuRurpjFxFVw6+aXW2JpSkRBrdOpBcbdibfPOmSPqMd1aoHBtYmYXetKoH9vfrXoBiPyO2PkDnczhsu/N9IA==", + "license": "SEE LICENSE.md", + "bin": { + "spriter": "bin/spriter.js" + } + }, "node_modules/@floating-ui/core": { "version": "1.6.8", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.8.tgz", "integrity": "sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==", - "dev": true, "license": "MIT", "dependencies": { "@floating-ui/utils": "^0.2.8" @@ -865,7 +964,6 @@ "version": "1.6.12", "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.12.tgz", "integrity": "sha512-NP83c0HjokcGVEMeoStg317VD9W7eDlGK7457dMBANbKA6GJZdc7rjujdgqzTaz93jkGgc5P/jeWbaCHnMNc+w==", - "dev": true, "license": "MIT", "dependencies": { "@floating-ui/core": "^1.6.0", @@ -876,7 +974,6 @@ "version": "0.2.8", "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz", "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==", - "dev": true, "license": "MIT" }, "node_modules/@formatjs/ecma402-abstract": { @@ -1197,6 +1294,12 @@ } } }, + "node_modules/@interactjs/types": { + "version": "1.10.28", + "resolved": "https://registry.npmjs.org/@interactjs/types/-/types-1.10.28.tgz", + "integrity": "sha512-vPmu4HWmsg0Fub3BPKGk7DuozkhgVK84TVkziY47v8Uh0A14gph55/vVRaQN4oZov0lGNJNK6Jyddim2qd/4vw==", + "license": "MIT" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1284,6 +1387,21 @@ "node": ">=v12.0.0" } }, + "node_modules/@lit-labs/ssr-dom-shim": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.6.0.tgz", + "integrity": "sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@lit/reactive-element": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.2.tgz", + "integrity": "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.5.0" + } + }, "node_modules/@mapbox/geojson-rewind": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", @@ -1465,6 +1583,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@open-wc/dedupe-mixin": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@open-wc/dedupe-mixin/-/dedupe-mixin-1.4.0.tgz", + "integrity": "sha512-Sj7gKl1TLcDbF7B6KUhtvr+1UCxdhMbNY5KxdU5IfMFWqL8oy1ZeAcCANjoB1TL0AJTcPmcCFsCbHf8X2jGDUA==", + "license": "MIT" + }, "node_modules/@opentelemetry/api": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", @@ -1491,6 +1615,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@polymer/polymer": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@polymer/polymer/-/polymer-3.5.2.tgz", + "integrity": "sha512-fWwImY/UH4bb2534DVSaX+Azs2yKg8slkMBHOyGeU2kKx7Xmxp6Lee0jP8p6B3d7c1gFUPB2Z976dTUtX81pQA==", + "license": "BSD-3-Clause", + "dependencies": { + "@webcomponents/shadycss": "^1.9.1" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -2289,6 +2422,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/sortablejs": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.9.tgz", + "integrity": "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==", + "license": "MIT" + }, "node_modules/@types/statuses": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", @@ -2313,6 +2452,193 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, + "node_modules/@vaadin/a11y-base": { + "version": "24.7.12", + "resolved": "https://registry.npmjs.org/@vaadin/a11y-base/-/a11y-base-24.7.12.tgz", + "integrity": "sha512-y/wWMADMmcXmY9sJ/TF+xh+HP+nE4Jc61SpW/D98c5zrLnf4cFlqTJEl8YwLDv80HtG78ieyzMHhvw9Wv96sdQ==", + "license": "Apache-2.0", + "dependencies": { + "@open-wc/dedupe-mixin": "^1.3.0", + "@polymer/polymer": "^3.0.0", + "@vaadin/component-base": "~24.7.12", + "lit": "^3.0.0" + } + }, + "node_modules/@vaadin/checkbox": { + "version": "24.7.12", + "resolved": "https://registry.npmjs.org/@vaadin/checkbox/-/checkbox-24.7.12.tgz", + "integrity": "sha512-9zERd1yc2FUhP3/NLdSITVg1QiRE9hiLbyIBRBEmkkuiU/zTRAAlDndFLeZKGDkIEtbLoz2Dcg5DoNNFnLA6qg==", + "license": "Apache-2.0", + "dependencies": { + "@open-wc/dedupe-mixin": "^1.3.0", + "@polymer/polymer": "^3.0.0", + "@vaadin/a11y-base": "~24.7.12", + "@vaadin/component-base": "~24.7.12", + "@vaadin/field-base": "~24.7.12", + "@vaadin/vaadin-lumo-styles": "~24.7.12", + "@vaadin/vaadin-material-styles": "~24.7.12", + "@vaadin/vaadin-themable-mixin": "~24.7.12", + "lit": "^3.0.0" + } + }, + "node_modules/@vaadin/component-base": { + "version": "24.7.12", + "resolved": "https://registry.npmjs.org/@vaadin/component-base/-/component-base-24.7.12.tgz", + "integrity": "sha512-GuHHRxaQKW7p5RIZxAqGEhYCSqF4CLpxXB/IPY4IW+4G/SzoIe8EqyneNnUSoKMoAFkBp0+A6tlJoWR9uvLTnQ==", + "license": "Apache-2.0", + "dependencies": { + "@open-wc/dedupe-mixin": "^1.3.0", + "@polymer/polymer": "^3.0.0", + "@vaadin/vaadin-development-mode-detector": "^2.0.0", + "@vaadin/vaadin-usage-statistics": "^2.1.0", + "lit": "^3.0.0" + } + }, + "node_modules/@vaadin/field-base": { + "version": "24.7.12", + "resolved": "https://registry.npmjs.org/@vaadin/field-base/-/field-base-24.7.12.tgz", + "integrity": "sha512-DNnFkhlShRLsZ6LJZq9VhtG+pexObp9sNkk22Lx3g9PyrpuEllTJwGw/jEUE9jyI8dsgkCHRVZ+krxE026NFsg==", + "license": "Apache-2.0", + "dependencies": { + "@open-wc/dedupe-mixin": "^1.3.0", + "@polymer/polymer": "^3.0.0", + "@vaadin/a11y-base": "~24.7.12", + "@vaadin/component-base": "~24.7.12", + "lit": "^3.0.0" + } + }, + "node_modules/@vaadin/grid": { + "version": "24.7.12", + "resolved": "https://registry.npmjs.org/@vaadin/grid/-/grid-24.7.12.tgz", + "integrity": "sha512-ks1NfM6o4XJcECETaXBrNCGtCL6nWj1yfLOAHycQ6h+P8dd1UO63ZuLEVl+Ii+kymS2xHmUgJx9EoNrPIqxdlw==", + "license": "Apache-2.0", + "dependencies": { + "@open-wc/dedupe-mixin": "^1.3.0", + "@polymer/polymer": "^3.0.0", + "@vaadin/a11y-base": "~24.7.12", + "@vaadin/checkbox": "~24.7.12", + "@vaadin/component-base": "~24.7.12", + "@vaadin/lit-renderer": "~24.7.12", + "@vaadin/text-field": "~24.7.12", + "@vaadin/vaadin-lumo-styles": "~24.7.12", + "@vaadin/vaadin-material-styles": "~24.7.12", + "@vaadin/vaadin-themable-mixin": "~24.7.12", + "lit": "^3.0.0" + } + }, + "node_modules/@vaadin/icon": { + "version": "24.7.12", + "resolved": "https://registry.npmjs.org/@vaadin/icon/-/icon-24.7.12.tgz", + "integrity": "sha512-8oLr69l22cAw2K+FNd3sCdhw+yWhU5Z1tlp8ZGUTTgyClvpe9pDIdZfBX4pQXnSx6QPNc3CrOaVZkx7n5WQqJg==", + "license": "Apache-2.0", + "dependencies": { + "@open-wc/dedupe-mixin": "^1.3.0", + "@polymer/polymer": "^3.0.0", + "@vaadin/component-base": "~24.7.12", + "@vaadin/vaadin-lumo-styles": "~24.7.12", + "@vaadin/vaadin-themable-mixin": "~24.7.12", + "lit": "^3.0.0" + } + }, + "node_modules/@vaadin/input-container": { + "version": "24.7.12", + "resolved": "https://registry.npmjs.org/@vaadin/input-container/-/input-container-24.7.12.tgz", + "integrity": "sha512-1oc21Owe9nI/t41TsdVan4b9pa8AjZZl/3t9o6X9XL/E4dHpFcj3lezlHEg92TBNzHm+6DcVPo/kZP29iahWEg==", + "license": "Apache-2.0", + "dependencies": { + "@polymer/polymer": "^3.0.0", + "@vaadin/component-base": "~24.7.12", + "@vaadin/vaadin-lumo-styles": "~24.7.12", + "@vaadin/vaadin-material-styles": "~24.7.12", + "@vaadin/vaadin-themable-mixin": "~24.7.12", + "lit": "^3.0.0" + } + }, + "node_modules/@vaadin/lit-renderer": { + "version": "24.7.12", + "resolved": "https://registry.npmjs.org/@vaadin/lit-renderer/-/lit-renderer-24.7.12.tgz", + "integrity": "sha512-AjPoLJBUuOJfmnpJpWYDoTDpH3+cXX+YJso7Acm0Ec1PeFAc9PvwGZBLlQK64juvTzejMLVFWYaU4bhn2rXVLA==", + "license": "Apache-2.0", + "dependencies": { + "lit": "^3.0.0" + } + }, + "node_modules/@vaadin/text-field": { + "version": "24.7.12", + "resolved": "https://registry.npmjs.org/@vaadin/text-field/-/text-field-24.7.12.tgz", + "integrity": "sha512-Zymu/9pAg9+FtWLBVpI1Hiev3SCc2mVLCqq7x17pSVD1Kp3Pcd/HIU3v1MivihUx1z7t9Kt0cNk84T9NDADjDw==", + "license": "Apache-2.0", + "dependencies": { + "@open-wc/dedupe-mixin": "^1.3.0", + "@polymer/polymer": "^3.0.0", + "@vaadin/a11y-base": "~24.7.12", + "@vaadin/component-base": "~24.7.12", + "@vaadin/field-base": "~24.7.12", + "@vaadin/input-container": "~24.7.12", + "@vaadin/vaadin-lumo-styles": "~24.7.12", + "@vaadin/vaadin-material-styles": "~24.7.12", + "@vaadin/vaadin-themable-mixin": "~24.7.12", + "lit": "^3.0.0" + } + }, + "node_modules/@vaadin/vaadin-development-mode-detector": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@vaadin/vaadin-development-mode-detector/-/vaadin-development-mode-detector-2.0.7.tgz", + "integrity": "sha512-9FhVhr0ynSR3X2ao+vaIEttcNU5XfzCbxtmYOV8uIRnUCtNgbvMOIcyGBvntsX9I5kvIP2dV3cFAOG9SILJzEA==", + "license": "Apache-2.0" + }, + "node_modules/@vaadin/vaadin-lumo-styles": { + "version": "24.7.12", + "resolved": "https://registry.npmjs.org/@vaadin/vaadin-lumo-styles/-/vaadin-lumo-styles-24.7.12.tgz", + "integrity": "sha512-KMpOEb5uB/JVBGF94pQb5mIbtb6/6tZ2QzfZ1II4/JpKsp7pgpySy5tZFoZrb8M8miOj01sAkSaH99N0DST8dA==", + "license": "Apache-2.0", + "dependencies": { + "@polymer/polymer": "^3.0.0", + "@vaadin/component-base": "~24.7.12", + "@vaadin/icon": "~24.7.12", + "@vaadin/vaadin-themable-mixin": "~24.7.12" + } + }, + "node_modules/@vaadin/vaadin-material-styles": { + "version": "24.7.12", + "resolved": "https://registry.npmjs.org/@vaadin/vaadin-material-styles/-/vaadin-material-styles-24.7.12.tgz", + "integrity": "sha512-WXXA2I+tOt1yxHYn57Boc2fdyLD7kIj5jX8GQMVkBW9nHN9oRfKO8beurmE2Am6Tz/HEMBtybx9ysY1k39TwBQ==", + "license": "Apache-2.0", + "dependencies": { + "@polymer/polymer": "^3.0.0", + "@vaadin/component-base": "~24.7.12", + "@vaadin/vaadin-themable-mixin": "~24.7.12" + } + }, + "node_modules/@vaadin/vaadin-themable-mixin": { + "version": "24.7.12", + "resolved": "https://registry.npmjs.org/@vaadin/vaadin-themable-mixin/-/vaadin-themable-mixin-24.7.12.tgz", + "integrity": "sha512-BkpnhYj3olUzNlgIhu518//tHJsoCBWabUmfdQCN8MTdT2AjkHDZNjoRy+dL1sbJyhl9EFCACXP+f8q1Qxawsw==", + "license": "Apache-2.0", + "dependencies": { + "@open-wc/dedupe-mixin": "^1.3.0", + "lit": "^3.0.0" + } + }, + "node_modules/@vaadin/vaadin-usage-statistics": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@vaadin/vaadin-usage-statistics/-/vaadin-usage-statistics-2.1.3.tgz", + "integrity": "sha512-8r4TNknD7OJQADe3VygeofFR7UNAXZ2/jjBFP5dgI8+2uMfnuGYgbuHivasKr9WSQ64sPej6m8rDoM1uSllXjQ==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@vaadin/vaadin-development-mode-detector": "^2.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/@vitest/coverage-v8": { "version": "2.1.8", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.8.tgz", @@ -2469,6 +2795,12 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@webcomponents/shadycss": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@webcomponents/shadycss/-/shadycss-1.11.2.tgz", + "integrity": "sha512-vRq+GniJAYSBmTRnhCYPAPq6THYqovJ/gzGThWbgEZUQaBccndGTi1hdiUP15HzEco0I6t4RCtXyX0rsSmwgPw==", + "license": "BSD-3-Clause" + }, "node_modules/@yr/monotone-cubic-spline": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz", @@ -2476,6 +2808,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@zip.js/zip.js": { + "version": "2.7.73", + "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.7.73.tgz", + "integrity": "sha512-I2UP8/rdQE5hTtVVL08B7P8XuwXiKuuMUPjNuFOVL/9b+8IsExR9S5jz2H58u0rJjU4M1BikLgqEMG8gZJZVBw==", + "license": "BSD-3-Clause", + "engines": { + "bun": ">=0.7.0", + "deno": ">=1.0.0", + "node": ">=16.5.0" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -3074,6 +3417,19 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/color": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", + "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", + "license": "MIT", + "dependencies": { + "color-convert": "^3.1.3", + "color-string": "^2.1.3" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3092,6 +3448,48 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/color-string": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", + "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-string/node_modules/color-name": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.1.tgz", + "integrity": "sha512-p2FdgwVx1a9yWBHP2wI0VgShkDpgN4kZISkxdNipGBJWpa5G6b04OINlVWCyJj0JmfvcPrgqt95E9k8yvaOJFg==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/color/node_modules/color-convert": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", + "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=14.6" + } + }, + "node_modules/color/node_modules/color-name": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.1.tgz", + "integrity": "sha512-p2FdgwVx1a9yWBHP2wI0VgShkDpgN4kZISkxdNipGBJWpa5G6b04OINlVWCyJj0JmfvcPrgqt95E9k8yvaOJFg==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -3120,6 +3518,15 @@ "dev": true, "license": "MIT" }, + "node_modules/composed-offset-position": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/composed-offset-position/-/composed-offset-position-0.0.6.tgz", + "integrity": "sha512-Q7dLompI6lUwd7LWyIcP66r4WcS9u7AL2h8HaeipiRfCRPLMWqRx8fYsjb4OHi6UQFifO7XtNC2IlEJ1ozIFxw==", + "license": "MIT", + "peerDependencies": { + "@floating-ui/utils": "^0.2.5" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3170,6 +3577,12 @@ "node": ">=4" } }, + "node_modules/cssfilter": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cssfilter/-/cssfilter-0.0.10.tgz", + "integrity": "sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==", + "license": "MIT" + }, "node_modules/cssstyle": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.3.0.tgz", @@ -3183,6 +3596,12 @@ "node": ">=18" } }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, "node_modules/d": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", @@ -3394,6 +3813,18 @@ "dev": true, "license": "MIT" }, + "node_modules/es-toolkit": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.51.0.tgz", + "integrity": "sha512-zC2lQGkM7QX+Gm6iM3+WIdZJzthsEd14LvRNJneSO2hzyz/zNBENR8+YXWo1cKxgPBtV6ksPYHELbcwBRzmdCw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, "node_modules/es5-ext": { "version": "0.10.64", "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", @@ -4082,6 +4513,15 @@ "tailwindcss": "^3.3.2" } }, + "node_modules/focus-trap": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz", + "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", + "license": "MIT", + "dependencies": { + "tabbable": "^6.4.0" + } + }, "node_modules/foreground-child": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", @@ -4555,6 +4995,15 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/interactjs": { + "version": "1.10.28", + "resolved": "https://registry.npmjs.org/interactjs/-/interactjs-1.10.28.tgz", + "integrity": "sha512-QCa4ksTPd2p/FZ4I6hrH6fogjLqru1TxYywRiKYNuLZtrlAxTIYUa/MYsE7PnUgWZJU3SrGVDjiz065BeaXYng==", + "license": "MIT", + "dependencies": { + "@interactjs/types": "1.10.28" + } + }, "node_modules/intl-messageformat": { "version": "10.7.7", "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.7.tgz", @@ -5034,6 +5483,37 @@ "uc.micro": "^2.0.0" } }, + "node_modules/lit": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.3.tgz", + "integrity": "sha512-fycuvZg/hkpozL00lm1pEJH5nN/lr9ZXd6mJI2HSN4+Bzc+LDNdEApJ6HFbPkdFNHLvOplIIuJvxkS4XUxqirw==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit/reactive-element": "^2.1.0", + "lit-element": "^4.2.0", + "lit-html": "^3.3.0" + } + }, + "node_modules/lit-element": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.2.tgz", + "integrity": "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.5.0", + "@lit/reactive-element": "^2.1.0", + "lit-html": "^3.3.0" + } + }, + "node_modules/lit-html": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.3.tgz", + "integrity": "sha512-el8M6jK2o3RXBnrSHX3ZKrsN8zEV63pSExTO1wYJz7QndGYZ8353e2a5PPX+qHe2aGayfnchQmkAojaWAREOIA==", + "license": "BSD-3-Clause", + "dependencies": { + "@types/trusted-types": "^2.0.2" + } + }, "node_modules/locate-character": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", @@ -5097,6 +5577,15 @@ "es5-ext": "~0.10.2" } }, + "node_modules/luxon": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.6.1.tgz", + "integrity": "sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", @@ -6634,6 +7123,12 @@ "node": ">=18" } }, + "node_modules/sortablejs": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz", + "integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==", + "license": "MIT" + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -7143,6 +7638,12 @@ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true }, + "node_modules/tabbable": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", + "integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==", + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "2.5.5", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.5.5.tgz", @@ -7370,6 +7871,15 @@ "node": ">=0.12" } }, + "node_modules/timezone-groups": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/timezone-groups/-/timezone-groups-0.10.4.tgz", + "integrity": "sha512-AnkJYrbb7uPkDCEqGeVJiawZNiwVlSkkeX4jZg1gTEguClhyX+/Ezn07KB6DT29tG3UN418ldmS/W6KqGOTDjg==", + "license": "MIT", + "engines": { + "node": ">=18.12.0" + } + }, "node_modules/tiny-glob": { "version": "0.2.9", "resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz", @@ -7533,7 +8043,6 @@ "version": "4.41.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=16" @@ -8485,6 +8994,28 @@ "integrity": "sha512-nquOebG4sngPmGPICTS5EnxqhKbCmz5Ox5hsszI2T6U5qdrJizBc+0ilYSEjTSzU0yZcmvppztXe/5Al5fUwdg==", "license": "Apache-2.0" }, + "node_modules/xss": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/xss/-/xss-1.0.13.tgz", + "integrity": "sha512-clu7dxTm1e8Mo5fz3n/oW3UCXBfV89xZ72jM8yzo1vR/pIS0w3sgB3XV2H8Vm6zfGnHL0FzvLJPJEBhd86/z4Q==", + "license": "MIT", + "dependencies": { + "commander": "^2.20.3", + "cssfilter": "0.0.10" + }, + "bin": { + "xss": "bin/xss" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/xss/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index aee4b888..73b89d60 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ }, "type": "module", "dependencies": { + "@arcgis/core": "4.33.14", "@fortawesome/free-regular-svg-icons": "^6.6.0", "@googlemaps/js-api-loader": "^1.16.8", "@maplibre/maplibre-gl-leaflet": "^0.0.22", diff --git a/src/assets/styles/arcgis-map.css b/src/assets/styles/arcgis-map.css new file mode 100644 index 00000000..5a1e7053 --- /dev/null +++ b/src/assets/styles/arcgis-map.css @@ -0,0 +1,45 @@ +.arcgis-overlay-container { + position: absolute; + inset: 0; + pointer-events: none; + overflow: hidden; + z-index: 10; +} + +.arcgis-stop-marker-overlay, +.arcgis-pin-marker-overlay { + position: absolute; + pointer-events: auto; + will-change: transform; +} + +/* + * Wayfinder mounts its own Svelte content in ArcGIS popups. Keep the ArcGIS + * positioning/pointer, but remove the GIS-specific action bar and make the + * outer shell behave like the compact popups used by the other providers. + */ +.esri-popup__main-container { + border-radius: 0.5rem; + max-width: min(21rem, calc(100vw - 2rem)); + min-height: 0; + max-height: min(28rem, calc(100vh - 2rem)); + overflow: visible; +} + +.esri-popup__content { + margin: 0; + padding: 0; + overflow: visible; +} + +.esri-features__footer, +.esri-features__header:has(.esri-features__header-actions:empty) { + display: none; +} + +.dark .esri-popup__main-container, +.dark .esri-popup__pointer-direction, +.dark .esri-features__header { + background-color: #1f2937; + color: #f3f4f6; +} diff --git a/src/lib/Provider/ArcGISMapProvider.svelte.js b/src/lib/Provider/ArcGISMapProvider.svelte.js new file mode 100644 index 00000000..926f98ce --- /dev/null +++ b/src/lib/Provider/ArcGISMapProvider.svelte.js @@ -0,0 +1,866 @@ +import { browser } from '$app/environment'; +import StopMarker from '$components/map/StopMarker.svelte'; +import PopupContent from '$components/map/PopupContent.svelte'; +import VehiclePopupContent from '$components/map/VehiclePopupContent.svelte'; +import ContextMenuPopup from '$components/map/ContextMenuPopup.svelte'; +import TripPlanPinMarker from '$components/trip-planner/tripPlanPinMarker.svelte'; +import { faBus } from '@fortawesome/free-solid-svg-icons'; +import { + RouteType, + routePriorities, + prioritizedRouteTypeForDisplay, + SHOW_ROUTE_LABELS_AT_ZOOM +} from '$config/routeConfig'; +import { COLORS } from '$lib/colors'; +import { polylineArrowColor } from '$lib/colorUtils'; +import { createVehicleIconSvg, iconHeight, iconWidth } from '$lib/MapHelpers/generateVehicleIcon'; +import { animateMarkerTo, cancelMarkerAnimation } from '$lib/MapHelpers/animateMarker'; +import { buildVehiclePopupData } from '$lib/vehicleUtils'; +import { ROUTE_PANE } from '$lib/mapPanes.js'; +import PolylineUtil from 'polyline-encoded'; +import { mount, unmount } from 'svelte'; +import './../../assets/styles/arcgis-map.css'; + +const DEFAULT_BASEMAP = 'streets-navigation-vector'; +const DARK_BASEMAP = 'dark-gray-vector'; + +function chooseStopIcon(stop, requestedIcon) { + if (requestedIcon) return requestedIcon; + if (!stop.routes?.length) return faBus; + + const routeTypes = stop.routes.map((route) => route.type); + for (const priority of routePriorities) { + if (routeTypes.includes(priority)) return prioritizedRouteTypeForDisplay(priority); + } + return prioritizedRouteTypeForDisplay(RouteType.UNKNOWN); +} + +function colorWithOpacity(color, opacity = 1) { + const alpha = Number.isFinite(opacity) ? Math.max(0, Math.min(1, opacity)) : 1; + if (Array.isArray(color)) return [...color.slice(0, 3), alpha]; + if (typeof color !== 'string') return color; + + const hex = color.replace('#', ''); + const expanded = hex.length === 3 ? [...hex].map((part) => part + part).join('') : hex; + if (!/^[0-9a-f]{6}$/i.test(expanded)) return color; + return [ + Number.parseInt(expanded.slice(0, 2), 16), + Number.parseInt(expanded.slice(2, 4), 16), + Number.parseInt(expanded.slice(4, 6), 16), + alpha + ]; +} + +/** ArcGIS Maps SDK implementation of Wayfinder's map-provider contract. */ +export default class ArcGISMapProvider { + constructor(apiKey, customBasemapUrl, handleStopMarkerSelect) { + this.apiKey = apiKey; + this.customBasemapUrl = customBasemapUrl; + this.handleStopMarkerSelect = handleStopMarkerSelect; + this.map = null; + this.view = null; + this.overlayContainer = null; + this.markersMap = new Map(); + this.stopsMap = new Map(); + this.stopMarkers = []; + this.vehicleMarkers = []; + this.pinMarkers = []; + this.polylines = []; + this.userLocationMarker = null; + this.popupContentComponent = null; + this.contextMenuComponent = null; + this.activePopupComponent = null; + this.globalInfoWindow = null; + this.showStopsRoutesAtZoom = SHOW_ROUTE_LABELS_AT_ZOOM; + this.routeLabelsVisible = false; + this._darkTheme = false; + this._dimmed = false; + this._positionFrame = null; + this._destroyed = false; + this.handles = new Set(); + this._basemapLayerOpacities = new Map(); + } + + async initMap(element, options) { + if (!browser) return; + this._destroyed = false; + + await import('@arcgis/core/assets/esri/themes/light/main.css'); + const modules = await Promise.all([ + import('@arcgis/core/Map.js'), + import('@arcgis/core/views/MapView.js'), + import('@arcgis/core/Basemap.js'), + import('@arcgis/core/layers/VectorTileLayer.js'), + import('@arcgis/core/layers/GraphicsLayer.js'), + import('@arcgis/core/Graphic.js'), + import('@arcgis/core/geometry/Point.js'), + import('@arcgis/core/geometry/Polyline.js'), + import('@arcgis/core/geometry/Extent.js'), + import('@arcgis/core/symbols/SimpleLineSymbol.js'), + import('@arcgis/core/symbols/SimpleMarkerSymbol.js'), + import('@arcgis/core/symbols/PictureMarkerSymbol.js'), + import('@arcgis/core/symbols/CIMSymbol.js'), + import('@arcgis/core/config.js'), + import('@arcgis/core/core/reactiveUtils.js'), + import('@arcgis/core/geometry/support/webMercatorUtils.js'), + import('@arcgis/core/geometry/projection.js') + ]); + [ + { default: this.Map }, + { default: this.MapView }, + { default: this.Basemap }, + { default: this.VectorTileLayer }, + { default: this.GraphicsLayer }, + { default: this.Graphic }, + { default: this.Point }, + { default: this.Polyline }, + { default: this.Extent }, + { default: this.SimpleLineSymbol }, + { default: this.SimpleMarkerSymbol }, + { default: this.PictureMarkerSymbol }, + { default: this.CIMSymbol }, + { default: this.arcgisConfig }, + this.reactiveUtils, + this.webMercatorUtils, + this.projection + ] = modules; + + if (this.apiKey) this.arcgisConfig.apiKey = this.apiKey; + await this.projection.load(); + + try { + const basemap = this.customBasemapUrl + ? new this.Basemap({ + baseLayers: [new this.VectorTileLayer({ url: this.customBasemapUrl })] + }) + : DEFAULT_BASEMAP; + this.routeStopLayer = new this.GraphicsLayer({ title: 'Wayfinder route stops' }); + this.vehicleLayer = new this.GraphicsLayer({ title: 'Wayfinder vehicles' }); + this.userLocationLayer = new this.GraphicsLayer({ title: 'Wayfinder user location' }); + this.routeCasingLayer = new this.GraphicsLayer({ title: 'Wayfinder route casings' }); + this.routeLineLayer = new this.GraphicsLayer({ title: 'Wayfinder routes' }); + this.routePromotedLayer = new this.GraphicsLayer({ title: 'Wayfinder promoted routes' }); + this.map = new this.Map({ + basemap, + layers: [ + this.routeCasingLayer, + this.routeLineLayer, + this.routePromotedLayer, + this.routeStopLayer, + this.vehicleLayer, + this.userLocationLayer + ] + }); + this.view = new this.MapView({ + container: element, + map: this.map, + center: [options.lng, options.lat], + zoom: 14, + ui: { components: ['zoom', 'attribution'] }, + // The default popup docks itself on narrow map views. Wayfinder's map can + // be narrow while the browser is not (for example beside the trip pane), + // which made a small context menu turn into a full-width sheet. + popup: { + dockEnabled: false, + dockOptions: { breakpoint: false, buttonEnabled: false }, + actions: [], + autoCloseEnabled: true, + defaultPopupTemplateEnabled: false, + highlightEnabled: false, + visibleElements: { + closeButton: true, + collapseButton: false, + heading: false, + actionBar: false, + featureNavigation: false, + featureMenuHeading: false, + featureListLayerTitle: false + } + }, + constraints: { rotationEnabled: false } + }); + await this.view.when(); + this._ensureOverlayContainer(); + this._registerSharedHandles(); + } catch (error) { + this.destroy(); + throw error; + } + } + + _registerSharedHandles() { + this._trackHandle( + this.reactiveUtils.watch( + () => [this.view?.extent, this.view?.zoom, this.view?.stationary], + () => this._requestPositionUpdate() + ) + ); + this._trackHandle( + this.reactiveUtils.watch( + () => this.view?.popup?.visible, + (visible) => { + if (!visible) this._cleanupPopupComponent(); + } + ) + ); + this.mapClickHandle = this._trackHandle( + this.view.on('click', async (event) => { + try { + const response = await this.view?.hitTest(event); + this._handleHitTestResults(response?.results ?? []); + } catch (error) { + if (this.view && !this._destroyed) console.warn('ArcGIS hitTest failed', error); + } + }) + ); + } + + _trackHandle(handle) { + if (handle?.remove) this.handles.add(handle); + return handle; + } + + _removeHandles() { + for (const handle of this.handles) { + try { + handle.remove(); + } catch { + // SDK handles can already be removed while a failed view is torn down. + } + } + this.handles.clear(); + } + + _ensureOverlayContainer() { + if (this.overlayContainer || !this.view?.container) return this.overlayContainer; + this.overlayContainer = document.createElement('div'); + this.overlayContainer.className = 'arcgis-overlay-container'; + this.view.container.appendChild(this.overlayContainer); + return this.overlayContainer; + } + + _requestPositionUpdate() { + if (this._positionFrame != null) return; + if (typeof requestAnimationFrame !== 'function') { + this._updateAllMarkerPositions(); + return; + } + this._positionFrame = requestAnimationFrame(() => { + this._positionFrame = null; + this._updateAllMarkerPositions(); + }); + } + + _updateAllMarkerPositions() { + for (const marker of this.markersMap.values()) this._updateMarkerPosition(marker); + for (const marker of this.pinMarkers) this._updateMarkerPosition(marker); + } + + _updateMarkerPosition(marker) { + if (!marker?.element || !this.view) return; + const screenPoint = this.view.toScreen(marker.point); + if (!screenPoint || !Number.isFinite(screenPoint.x) || !Number.isFinite(screenPoint.y)) { + marker.element.style.display = 'none'; + return; + } + marker.element.style.display = ''; + marker.element.style.transform = `translate3d(${screenPoint.x}px, ${screenPoint.y}px, 0) translate(-50%, -50%)`; + } + + eventListeners(_mapInstance, debouncedLoadMarkers) { + if (!this.view || this.viewportLoadHandle) return; + this.viewportLoadHandle = this._trackHandle( + this.reactiveUtils.watch( + () => this.view?.stationary, + (stationary, previous) => { + if (stationary && previous === false) debouncedLoadMarkers(); + } + ) + ); + } + + addMarker(options) { + if (!browser || !this.view || this.markersMap.has(options.stop.id)) { + return this.markersMap.get(options.stop.id) ?? null; + } + const element = document.createElement('div'); + element.className = 'arcgis-stop-marker-overlay'; + const props = $state({ + stop: options.stop, + icon: chooseStopIcon(options.stop, options.icon), + onClick: options.onClick, + isHighlighted: options.isHighlighted ?? false, + showRoutesLabel: this.getZoom() >= this.showStopsRoutesAtZoom, + emphasis: options.emphasis ?? 'full', + dotColor: options.dotColor ?? null + }); + const component = mount(StopMarker, { target: element, props }); + this._ensureOverlayContainer().appendChild(element); + const marker = { + id: options.stop.id, + point: new this.Point({ longitude: options.position.lng, latitude: options.position.lat }), + element, + component, + props + }; + this.markersMap.set(marker.id, marker); + this._updateMarkerPosition(marker); + return marker; + } + + removeMarker(marker) { + if (!marker) return; + if (marker.component) unmount(marker.component); + marker.element?.remove(); + for (const [id, stored] of this.markersMap) { + if (stored === marker) this.markersMap.delete(id); + } + } + + hasMarker(stopId) { + return this.markersMap.has(stopId); + } + + getMarker(stopId) { + return this.markersMap.get(stopId); + } + + clearAllStopMarkers() { + for (const marker of [...this.markersMap.values()]) this.removeMarker(marker); + this.markersMap.clear(); + } + + updateMarkersRouteLabelVisibility() { + const visible = this.getZoom() >= this.showStopsRoutesAtZoom; + if (visible === this.routeLabelsVisible) return; + this.routeLabelsVisible = visible; + for (const marker of this.markersMap.values()) marker.props.showRoutesLabel = visible; + } + + highlightMarker(stopId) { + const marker = this.markersMap.get(stopId); + if (marker) marker.props.isHighlighted = true; + } + + unHighlightMarker(stopId) { + const marker = this.markersMap.get(stopId); + if (marker) marker.props.isHighlighted = false; + } + + setStopEmphasis(byStopId, defaultEmphasis = 'full', selectedStopId = null) { + for (const [stopId, marker] of this.markersMap) { + const tier = byStopId.get(stopId); + marker.props.emphasis = + stopId === selectedStopId ? 'full' : (tier?.emphasis ?? defaultEmphasis); + marker.props.dotColor = stopId === selectedStopId ? null : (tier?.dotColor ?? null); + } + } + + resetStopEmphasis() { + for (const marker of this.markersMap.values()) { + marker.props.emphasis = 'full'; + marker.props.dotColor = null; + } + } + + addStopRouteMarker(stop, stopTime = null) { + if (!this.routeStopLayer) return null; + const graphic = new this.Graphic({ + geometry: new this.Point({ longitude: stop.lon, latitude: stop.lat }), + symbol: new this.SimpleMarkerSymbol({ + style: 'circle', + color: [255, 255, 255, 1], + size: 10, + outline: { color: [0, 0, 0, 1], width: 1 } + }), + attributes: { kind: 'route-stop', stopId: stop.id, stopTime } + }); + this.routeStopLayer.add(graphic); + this.stopMarkers.push(graphic); + this.stopsMap.set(stop.id, stop); + return graphic; + } + + removeStopMarkers() { + this.routeStopLayer?.removeMany(this.stopMarkers); + this.stopMarkers = []; + } + + _handleHitTestResults(results) { + const hit = results.find(({ graphic }) => + [this.routeStopLayer, this.vehicleLayer].includes(graphic?.layer) + ); + const attributes = hit?.graphic?.attributes; + if (!attributes) return; + if (attributes.kind === 'route-stop') { + const stop = this.stopsMap.get(attributes.stopId); + if (stop) this.openStopMarker(stop, attributes.stopTime ?? null); + } else if (attributes.kind === 'vehicle') { + const marker = this.vehicleMarkers.find((item) => item.graphic === hit.graphic); + if (marker) this._openVehiclePopup(marker); + } + } + + _cleanupPopupComponent() { + if (this.activePopupComponent) unmount(this.activePopupComponent); + if (this.contextMenuComponent === this.activePopupComponent) { + this.contextMenuComponent = null; + } + this.activePopupComponent = null; + this.popupContentComponent = null; + this.globalInfoWindow = null; + } + + _setPopupChrome({ closeButton }) { + const popup = this.view?.popup; + if (!popup) return; + + popup.dockEnabled = false; + popup.actions?.removeAll?.(); + Object.assign(popup.visibleElements ?? (popup.visibleElements = {}), { + closeButton, + collapseButton: false, + heading: false, + actionBar: false, + featureNavigation: false, + featureMenuHeading: false, + featureListLayerTitle: false + }); + } + + openStopMarker(stop, stopTime = null) { + if (!this.view) return; + this.cleanupInfoWindow(); + this._setPopupChrome({ closeButton: true }); + const container = document.createElement('div'); + this.popupContentComponent = mount(PopupContent, { + target: container, + props: { + stopName: stop.name, + arrivalTime: stopTime?.arrivalTime ?? null, + handleStopMarkerSelect: () => this.handleStopMarkerSelect(stop) + } + }); + this.activePopupComponent = this.popupContentComponent; + this.globalInfoWindow = this.view.popup; + this.view.openPopup({ + location: { longitude: stop.lon, latitude: stop.lat }, + content: container + }); + } + + updatePopupContent(stop, arrivalTime = null) { + if (!this.popupContentComponent || !this.view?.popup?.visible) return; + this.openStopMarker(stop, { arrivalTime }); + } + + cleanupInfoWindow() { + this._cleanupPopupComponent(); + if (this.view?.popup?.visible) this.view.popup.close(); + } + + addPinMarker(position, text) { + if (!this.view) return null; + const element = document.createElement('div'); + element.className = 'arcgis-pin-marker-overlay'; + const component = mount(TripPlanPinMarker, { target: element, props: { text } }); + this._ensureOverlayContainer().appendChild(element); + const marker = { + point: new this.Point({ longitude: position.lng, latitude: position.lat }), + element, + component + }; + this.pinMarkers.push(marker); + this._updateMarkerPosition(marker); + return marker; + } + + removePinMarker(marker) { + if (!marker) return; + if (marker.component) unmount(marker.component); + marker.element?.remove(); + this.pinMarkers = this.pinMarkers.filter((item) => item !== marker); + } + + addVehicleMarker(vehicle, activeTrip, routeType, isHighlighted = false, routeColor = undefined) { + if (!this.vehicleLayer) return null; + const color = vehicle.predicted ? routeColor : COLORS.VEHICLE_REAL_TIME_OFF; + const icon = createVehicleIconSvg(vehicle.orientation, color, routeType, isHighlighted); + const graphic = new this.Graphic({ + geometry: new this.Point({ longitude: vehicle.position.lon, latitude: vehicle.position.lat }), + symbol: new this.PictureMarkerSymbol({ + url: `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(icon)}`, + width: `${iconWidth}px`, + height: `${iconHeight}px` + }), + attributes: { kind: 'vehicle', vehicleId: vehicle.vehicleId } + }); + const vehicleData = $state(buildVehiclePopupData(vehicle, activeTrip, this.stopsMap)); + const marker = { graphic, vehicleData }; + this.vehicleLayer.add(graphic); + this.vehicleMarkers.push(marker); + return marker; + } + + _openVehiclePopup(marker) { + if (!this.view) return; + this.cleanupInfoWindow(); + this._setPopupChrome({ closeButton: true }); + const container = document.createElement('div'); + this.activePopupComponent = mount(VehiclePopupContent, { + target: container, + props: marker.vehicleData + }); + this.view.openPopup({ location: marker.graphic.geometry, content: container }); + } + + updateVehicleMarker( + marker, + vehicleStatus, + activeTrip, + routeType, + isHighlighted = false, + routeColor = undefined + ) { + if (!marker?.graphic || !this.view) return; + const current = marker.graphic.geometry; + animateMarkerTo( + marker, + { lat: current.latitude, lng: current.longitude }, + { lat: vehicleStatus.position.lat, lng: vehicleStatus.position.lon }, + (lat, lng) => { + marker.graphic.geometry = new this.Point({ longitude: lng, latitude: lat }); + }, + { routePaths: this._getRoutePaths() } + ); + const color = vehicleStatus.predicted ? routeColor : COLORS.VEHICLE_REAL_TIME_OFF; + marker.graphic.symbol = new this.PictureMarkerSymbol({ + url: `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(createVehicleIconSvg(vehicleStatus.orientation, color, routeType, isHighlighted))}`, + width: `${iconWidth}px`, + height: `${iconHeight}px` + }); + Object.assign( + marker.vehicleData, + buildVehiclePopupData(vehicleStatus, activeTrip, this.stopsMap) + ); + } + + removeVehicleMarker(marker) { + if (!marker) return; + cancelMarkerAnimation(marker); + this.vehicleLayer?.remove(marker.graphic); + this.vehicleMarkers = this.vehicleMarkers.filter((item) => item !== marker); + } + + clearVehicleMarkers() { + for (const marker of [...this.vehicleMarkers]) this.removeVehicleMarker(marker); + } + + _getRoutePaths() { + return this.polylines + .map((polyline) => polyline.geometry?.paths?.[0] ?? []) + .filter((path) => path.length >= 2) + .map((path) => path.map(([lng, lat]) => ({ lat, lng }))); + } + + addUserLocationMarker(latLng) { + if (!this.userLocationLayer) return null; + if (this.userLocationMarker) { + this.userLocationMarker.geometry = new this.Point({ + longitude: latLng.lng, + latitude: latLng.lat + }); + return this.userLocationMarker; + } + this.userLocationMarker = new this.Graphic({ + geometry: new this.Point({ longitude: latLng.lng, latitude: latLng.lat }), + symbol: new this.SimpleMarkerSymbol({ + style: 'circle', + color: '#007BFF', + size: 16, + outline: { color: '#FFFFFF', width: 2 } + }) + }); + this.userLocationLayer.add(this.userLocationMarker); + return this.userLocationMarker; + } + + removeUserLocationMarker() { + if (this.userLocationMarker) this.userLocationLayer?.remove(this.userLocationMarker); + this.userLocationMarker = null; + } + + setCenter(latLng) { + if (this.view) this.view.center = [latLng.lng, latLng.lat]; + } + + getCenter() { + if (!this.view?.center) return { lat: 0, lng: 0 }; + return { lat: this.view.center.latitude, lng: this.view.center.longitude }; + } + + getZoom() { + return this.view?.zoom ?? 0; + } + + setTheme(theme) { + this._darkTheme = theme === 'dark'; + if (!this.map || this.customBasemapUrl) return; + this.map.basemap = this._darkTheme ? DARK_BASEMAP : DEFAULT_BASEMAP; + this.setBasemapDimmed(this._dimmed); + } + + setBasemapDimmed(dimmed) { + this._dimmed = dimmed; + const layers = this.map?.basemap?.baseLayers; + if (!layers) return; + layers.forEach((layer) => { + if (!this._basemapLayerOpacities.has(layer)) + this._basemapLayerOpacities.set(layer, layer.opacity ?? 1); + layer.opacity = dimmed + ? Math.min(this._basemapLayerOpacities.get(layer), 0.6) + : this._basemapLayerOpacities.get(layer); + }); + } + + _createRouteSymbol(color, weight, opacity, withArrow) { + const symbolColor = colorWithOpacity(color, opacity); + if (!withArrow) + return new this.SimpleLineSymbol({ color: symbolColor, width: weight, style: 'solid' }); + const arrowColor = colorWithOpacity(polylineArrowColor(color), opacity); + return new this.CIMSymbol({ + data: { + type: 'CIMSymbolReference', + data: { + type: 'CIMLineSymbol', + symbolLayers: [ + { type: 'CIMSolidStroke', enable: true, width: weight, color: symbolColor }, + { + type: 'CIMVectorMarker', + enable: true, + markerPlacement: { + type: 'CIMMarkerPlacementAlongLineSameSize', + placementTemplate: [50], + endings: 'WithMarkers' + }, + markerGraphics: [ + { + type: 'CIMMarkerGraphic', + geometry: { + rings: [ + [ + [0, 6], + [6, -6], + [-6, -6], + [0, 6] + ] + ] + }, + symbol: { + type: 'CIMPolygonSymbol', + symbolLayers: [{ type: 'CIMSolidFill', enable: true, color: arrowColor }] + } + } + ] + } + ] + } + } + }); + } + + createPolyline(shape, options = {}) { + if (!this.routeLineLayer) return null; + if (typeof options === 'boolean') options = { withArrow: options }; + let decoded; + try { + decoded = PolylineUtil.decode(shape); + } catch (error) { + console.error('Failed to decode polyline:', error?.message); + return null; + } + if (!decoded?.length) return null; + const paths = decoded.map(([lat, lng]) => [lng, lat]); + const geometry = new this.Polyline({ paths: [paths], spatialReference: { wkid: 4326 } }); + const weight = options.weight || 4; + const color = options.color || COLORS.POLYLINE; + const withArrow = options.withArrow ?? true; + const graphic = new this.Graphic({ + geometry, + symbol: this._createRouteSymbol(color, weight, options.opacity ?? 1, withArrow) + }); + if (options.casing) { + graphic._casing = new this.Graphic({ + geometry, + symbol: new this.SimpleLineSymbol({ + color: '#ffffff', + width: weight + 5, + style: 'solid', + opacity: 0.95 + }) + }); + this.routeCasingLayer.add(graphic._casing); + } + graphic._layer = + options.pane === ROUTE_PANE.PROMOTED ? this.routePromotedLayer : this.routeLineLayer; + graphic._layer.add(graphic); + this.polylines.push(graphic); + return graphic; + } + + setPolylineLayer(polyline, pane) { + if (!polyline) return; + const target = pane === ROUTE_PANE.PROMOTED ? this.routePromotedLayer : this.routeLineLayer; + if (!target || polyline._layer === target) return; + polyline._layer?.remove(polyline); + target.add(polyline); + polyline._layer = target; + } + + removePolyline(polyline) { + if (!polyline) return null; + polyline._layer?.remove(polyline); + if (polyline._casing) this.routeCasingLayer?.remove(polyline._casing); + this.polylines = this.polylines.filter((item) => item !== polyline); + return null; + } + + clearAllPolylines() { + for (const polyline of [...this.polylines]) this.removePolyline(polyline); + } + + getPolylinesCount() { + return this.polylines.length; + } + + panTo(lat, lng) { + this.view?.goTo({ center: [lng, lat] }).catch(() => {}); + } + + flyTo(lat, lng, zoom = 15, options = {}) { + if (!this.view) return; + this.view + .goTo({ center: [lng, lat], zoom }, { animate: options.animate ?? true }) + .catch(() => {}); + } + + setZoom(zoom) { + if (this.view) this.view.zoom = zoom; + } + + async fitToPolylines(options = {}) { + if (!this.view || !this.polylines.length) return false; + try { + await this.view.goTo(this.polylines, { duration: options.duration ?? 700 }); + if (this.view.zoom > (options.maxZoom ?? 16)) this.view.zoom = options.maxZoom ?? 16; + return true; + } catch { + return false; + } + } + + revealPolylines() {} + + addListener(event, callback) { + if (!this.view) return null; + const watchable = new Set(['zoom', 'center', 'extent', 'stationary', 'updating']); + return this._trackHandle( + watchable.has(event) + ? this.reactiveUtils.watch(() => this.view?.[event], callback) + : this.view.on(event, callback) + ); + } + + enableContextMenu() { + if (!this.view || this.contextMenuHandle) return; + this.contextMenuHandle = this._trackHandle( + this.view.on('immediate-click', (event) => { + if (event.button === 2 && event.mapPoint) this.showContextMenu(event.mapPoint); + }) + ); + } + + showContextMenu(point) { + if (!this.view) return; + this.cleanupInfoWindow(); + // Context actions already close after a selection and match the existing + // OSM behaviour, so omit the otherwise useful popup close control here. + this._setPopupChrome({ closeButton: false }); + const container = document.createElement('div'); + const dispatchAndClose = (type) => { + window.dispatchEvent( + new CustomEvent('contextMenuTripPlan', { + detail: { type, lat: point.latitude, lng: point.longitude } + }) + ); + this.closeContextMenu(); + }; + this.contextMenuComponent = mount(ContextMenuPopup, { + target: container, + props: { + onStartHere: () => dispatchAndClose('from'), + onEndHere: () => dispatchAndClose('to') + } + }); + this.activePopupComponent = this.contextMenuComponent; + this.view.openPopup({ location: point, content: container }); + } + + closeContextMenu() { + if (!this.contextMenuComponent) return; + this._cleanupPopupComponent(); + this.view?.popup?.close(); + } + + getBoundingBox() { + const extent = this.view?.extent; + if (!extent) return null; + let geographic = extent; + try { + if (!extent.spatialReference?.isWGS84) { + geographic = this.webMercatorUtils.isWebMercator(extent) + ? this.webMercatorUtils.webMercatorToGeographic(extent) + : this.projection.project(extent, { wkid: 4326 }); + } + } catch { + return null; + } + const bounds = { + north: geographic?.ymax, + south: geographic?.ymin, + east: geographic?.xmax, + west: geographic?.xmin + }; + return Object.values(bounds).every(Number.isFinite) ? bounds : null; + } + + destroy() { + if (this._destroyed) return; + this._destroyed = true; + if (this._positionFrame != null && typeof cancelAnimationFrame === 'function') + cancelAnimationFrame(this._positionFrame); + this._positionFrame = null; + this._removeHandles(); + this.cleanupInfoWindow(); + this.closeContextMenu(); + this.clearAllStopMarkers(); + this.removeStopMarkers(); + this.clearVehicleMarkers(); + for (const marker of [...this.pinMarkers]) this.removePinMarker(marker); + this.clearAllPolylines(); + this.removeUserLocationMarker(); + this.overlayContainer?.remove(); + this.overlayContainer = null; + this.view?.destroy(); + this.view = null; + this.map = null; + this.routeStopLayer = null; + this.vehicleLayer = null; + this.userLocationLayer = null; + this.routeCasingLayer = null; + this.routeLineLayer = null; + this.routePromotedLayer = null; + this.stopsMap.clear(); + this._basemapLayerOpacities.clear(); + } +} From c94c903e2034266e03e2eb980efb880b1904ba8d Mon Sep 17 00:00:00 2001 From: Soumajit Ghosh Date: Mon, 24 Aug 2026 14:06:42 +0530 Subject: [PATCH 3/5] feat: configure ArcGIS provider and basemaps --- .env.example | 5 +++++ README.md | 4 +++- env-schema.json | 16 ++++++++++++++-- src/components/MapContainer.svelte | 18 ++++++++---------- src/config/mapSource.js | 3 ++- src/lib/mapProviderFactory.js | 22 ++++++++++++++++++++++ vitest-setup.js | 4 +++- 7 files changed, 57 insertions(+), 15 deletions(-) create mode 100644 src/lib/mapProviderFactory.js diff --git a/.env.example b/.env.example index 1370e3f9..4b21397f 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,11 @@ PRIVATE_MANIFEST_ICON_512_URL="" PUBLIC_APPLE_TOUCH_ICON_URL="" PUBLIC_OBA_MAP_PROVIDER="osm" +# ArcGIS configuration. The key is optional for public/basic basemaps but +# required by secured ArcGIS services. A custom basemap remains unchanged in dark mode. +PUBLIC_ARCGIS_API_KEY="" +PUBLIC_ARCGIS_CUSTOM_BASEMAP_URL="" + PUBLIC_OBA_REGION_CENTER_LAT=47.60728155903877 PUBLIC_OBA_REGION_CENTER_LNG=-122.3339240843084 PUBLIC_OBA_REGION_NAME="Puget Sound" diff --git a/README.md b/README.md index aa801e92..af548034 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,9 @@ Use these in Tailwind classes: `bg-primary-500`, `text-primary-700`, `border-pri ### Maps - `PUBLIC_OBA_GOOGLE_MAPS_API_KEY` - string: (optional) Your Google API key. -- `PUBLIC_OBA_MAP_PROVIDER` - string: Use "osm" for OpenStreetMap or "google" for Google Maps. +- `PUBLIC_OBA_MAP_PROVIDER` - string: Use "osm" for OpenStreetMap, "google" for Google Maps, or "arcgis" for ArcGIS Maps SDK. +- `PUBLIC_ARCGIS_API_KEY` - string: (optional) ArcGIS API key. Public/basic ArcGIS basemaps work without one; secured services require one. +- `PUBLIC_ARCGIS_CUSTOM_BASEMAP_URL` - string: (optional) ArcGIS vector-tile basemap URL. When configured, it is retained in both light and dark mode. ### Geocoding diff --git a/env-schema.json b/env-schema.json index 545e8b6a..93cfbaef 100644 --- a/env-schema.json +++ b/env-schema.json @@ -88,8 +88,20 @@ "PUBLIC_OBA_MAP_PROVIDER": { "required": true, "type": "enum", - "enum": ["osm", "google"], - "description": "Map provider: 'osm' for OpenStreetMap/Leaflet, 'google' for Google Maps" + "enum": ["osm", "google", "arcgis"], + "description": "Map provider: 'osm' for OpenStreetMap/Leaflet, 'google' for Google Maps, or 'arcgis' for ArcGIS" + }, + "PUBLIC_ARCGIS_API_KEY": { + "required": false, + "type": "string", + "allowEmpty": true, + "description": "Optional ArcGIS API key; required for secured ArcGIS services" + }, + "PUBLIC_ARCGIS_CUSTOM_BASEMAP_URL": { + "required": false, + "type": "url", + "allowEmpty": true, + "description": "Optional ArcGIS vector-tile basemap URL; preserved across theme changes" }, "PUBLIC_OBA_REGION_CENTER_LAT": { "required": true, diff --git a/src/components/MapContainer.svelte b/src/components/MapContainer.svelte index 8a3a3c72..6c6baaf4 100644 --- a/src/components/MapContainer.svelte +++ b/src/components/MapContainer.svelte @@ -1,15 +1,15 @@ diff --git a/src/config/mapSource.js b/src/config/mapSource.js index 25df061b..536fc413 100644 --- a/src/config/mapSource.js +++ b/src/config/mapSource.js @@ -1,4 +1,5 @@ export const MapSource = { Google: 'google', - OpenStreetMap: 'osm' + OpenStreetMap: 'osm', + ArcGIS: 'arcgis' }; diff --git a/src/lib/mapProviderFactory.js b/src/lib/mapProviderFactory.js new file mode 100644 index 00000000..685c4f31 --- /dev/null +++ b/src/lib/mapProviderFactory.js @@ -0,0 +1,22 @@ +import GoogleMapProvider from '$lib/Provider/GoogleMapProvider.svelte'; +import OpenStreetMapProvider from '$lib/Provider/OpenStreetMapProvider.svelte'; +import ArcGISMapProvider from '$lib/Provider/ArcGISMapProvider.svelte'; +import { MapSource } from '$config/mapSource.js'; + +/** Creates the configured provider and keeps invalid deployment configuration usable. */ +export function createMapProvider(source, config, handleStopMarkerSelect) { + if (source === MapSource.Google) { + return new GoogleMapProvider(config.googleApiKey, handleStopMarkerSelect); + } + if (source === MapSource.ArcGIS) { + return new ArcGISMapProvider( + config.arcgisApiKey, + config.arcgisCustomBasemapUrl, + handleStopMarkerSelect + ); + } + if (source !== MapSource.OpenStreetMap) { + console.error(`Unknown map provider: ${source}; falling back to OSM.`); + } + return new OpenStreetMapProvider(handleStopMarkerSelect); +} diff --git a/vitest-setup.js b/vitest-setup.js index 893b845e..52e90a24 100644 --- a/vitest-setup.js +++ b/vitest-setup.js @@ -46,7 +46,9 @@ vi.mock('$env/dynamic/public', () => ({ PUBLIC_ANALYTICS_PROVIDER: 'none', PUBLIC_ANALYTICS_DOMAIN: '', PUBLIC_ANALYTICS_API_HOST: '', - PUBLIC_ANALYTICS_WEBSITE_ID: '' + PUBLIC_ANALYTICS_WEBSITE_ID: '', + PUBLIC_ARCGIS_API_KEY: '', + PUBLIC_ARCGIS_CUSTOM_BASEMAP_URL: '' } })); From 8aa976f254204038d3ecef0f13d913a2b79137a5 Mon Sep 17 00:00:00 2001 From: Soumajit Ghosh Date: Mon, 24 Aug 2026 14:06:46 +0530 Subject: [PATCH 4/5] test: cover ArcGIS provider behavior --- src/tests/lib/ArcGISMapProvider.test.js | 313 +++++++++++++++++++++++ src/tests/lib/mapProviderFactory.test.js | 42 +++ 2 files changed, 355 insertions(+) create mode 100644 src/tests/lib/ArcGISMapProvider.test.js create mode 100644 src/tests/lib/mapProviderFactory.test.js diff --git a/src/tests/lib/ArcGISMapProvider.test.js b/src/tests/lib/ArcGISMapProvider.test.js new file mode 100644 index 00000000..c2720fc1 --- /dev/null +++ b/src/tests/lib/ArcGISMapProvider.test.js @@ -0,0 +1,313 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('$app/environment', () => ({ + browser: true, + dev: false, + building: false, + version: 'test' +})); +vi.mock('@arcgis/core/assets/esri/themes/light/main.css', () => ({})); + +const sdk = vi.hoisted(() => { + class Handle { + remove = vi.fn(); + } + class GraphicsLayer { + constructor(properties = {}) { + Object.assign(this, properties); + this.graphics = []; + } + add(graphic) { + graphic.layer = this; + this.graphics.push(graphic); + } + remove(graphic) { + this.graphics = this.graphics.filter((item) => item !== graphic); + } + removeMany(graphics) { + graphics.forEach((graphic) => this.remove(graphic)); + } + } + class Graphic { + constructor(properties) { + Object.assign(this, properties); + } + } + class Point { + constructor(properties) { + Object.assign(this, properties); + } + } + class Polyline { + constructor(properties) { + Object.assign(this, properties); + } + } + class Symbol { + constructor(properties) { + Object.assign(this, properties); + } + } + class Map { + constructor(properties) { + Object.assign(this, properties); + } + } + class Basemap { + constructor(properties) { + Object.assign(this, properties); + } + } + class VectorTileLayer { + constructor(properties) { + Object.assign(this, properties); + this.opacity = 1; + } + } + class MapView { + static whenResult = Promise.resolve(); + constructor(properties) { + Object.assign(this, properties); + this.center = { latitude: properties.center[1], longitude: properties.center[0] }; + this.zoom = properties.zoom; + this.extent = { + xmin: -123, + ymin: 47, + xmax: -122, + ymax: 48, + spatialReference: { isWGS84: true } + }; + this.popup = { + ...properties.popup, + visible: false, + close: vi.fn(() => (this.popup.visible = false)) + }; + this.on = vi.fn(() => new Handle()); + this.hitTest = vi.fn(); + this.goTo = vi.fn().mockResolvedValue(); + this.destroy = vi.fn(); + this.toScreen = vi.fn((point) => ({ x: point.longitude, y: point.latitude })); + } + when() { + return MapView.whenResult; + } + openPopup = vi.fn((options) => { + this.popup.visible = true; + this.popup.content = options.content; + }); + } + const reactiveUtils = { watch: vi.fn(() => new Handle()) }; + const projection = { load: vi.fn().mockResolvedValue(), project: vi.fn((value) => value) }; + return { + Handle, + GraphicsLayer, + Graphic, + Point, + Polyline, + Symbol, + Map, + Basemap, + VectorTileLayer, + MapView, + reactiveUtils, + projection, + webMercatorUtils: { + isWebMercator: vi.fn(() => true), + webMercatorToGeographic: vi.fn((extent) => ({ + ...extent, + xmin: -122.4, + ymin: 47.5, + xmax: -122.2, + ymax: 47.7 + })) + } + }; +}); + +vi.mock('@arcgis/core/Map.js', () => ({ default: sdk.Map })); +vi.mock('@arcgis/core/views/MapView.js', () => ({ default: sdk.MapView })); +vi.mock('@arcgis/core/Basemap.js', () => ({ default: sdk.Basemap })); +vi.mock('@arcgis/core/layers/VectorTileLayer.js', () => ({ default: sdk.VectorTileLayer })); +vi.mock('@arcgis/core/layers/GraphicsLayer.js', () => ({ default: sdk.GraphicsLayer })); +vi.mock('@arcgis/core/Graphic.js', () => ({ default: sdk.Graphic })); +vi.mock('@arcgis/core/geometry/Point.js', () => ({ default: sdk.Point })); +vi.mock('@arcgis/core/geometry/Polyline.js', () => ({ default: sdk.Polyline })); +vi.mock('@arcgis/core/geometry/Extent.js', () => ({ default: class {} })); +vi.mock('@arcgis/core/symbols/SimpleLineSymbol.js', () => ({ default: sdk.Symbol })); +vi.mock('@arcgis/core/symbols/SimpleMarkerSymbol.js', () => ({ default: sdk.Symbol })); +vi.mock('@arcgis/core/symbols/PictureMarkerSymbol.js', () => ({ default: sdk.Symbol })); +vi.mock('@arcgis/core/symbols/CIMSymbol.js', () => ({ default: sdk.Symbol })); +vi.mock('@arcgis/core/config.js', () => ({ default: {} })); +vi.mock('@arcgis/core/core/reactiveUtils.js', () => sdk.reactiveUtils); +vi.mock('@arcgis/core/geometry/support/webMercatorUtils.js', () => sdk.webMercatorUtils); +vi.mock('@arcgis/core/geometry/projection.js', () => sdk.projection); + +import ArcGISMapProvider from '$lib/Provider/ArcGISMapProvider.svelte.js'; + +const SHAPE = '_p~iF~ps|U_ulLnnqC_mqNvxq`@'; + +async function initializedProvider({ customBasemapUrl = '' } = {}) { + const provider = new ArcGISMapProvider('key', customBasemapUrl, vi.fn()); + await provider.initMap(document.createElement('div'), { lat: 47.6, lng: -122.3 }); + return provider; +} + +describe('ArcGISMapProvider', () => { + beforeEach(() => { + sdk.MapView.whenResult = Promise.resolve(); + sdk.reactiveUtils.watch.mockClear(); + sdk.projection.load.mockClear(); + }); + + test('initializes optional API key, custom basemap, shared handlers, and overlay', async () => { + const provider = await initializedProvider({ + customBasemapUrl: 'https://tiles.example.test/style.json' + }); + expect(provider.map.basemap.baseLayers[0].url).toBe('https://tiles.example.test/style.json'); + expect(provider.overlayContainer.className).toBe('arcgis-overlay-container'); + expect(provider.view.on).toHaveBeenCalledTimes(1); + expect(sdk.reactiveUtils.watch).toHaveBeenCalledTimes(2); + expect(provider.view.popup.dockOptions).toEqual({ breakpoint: false, buttonEnabled: false }); + expect(provider.view.popup.visibleElements).toMatchObject({ + actionBar: false, + collapseButton: false, + closeButton: true + }); + }); + + test('cleans up partial state when view.when rejects', async () => { + sdk.MapView.whenResult = Promise.reject(new Error('WebGL unavailable')); + const provider = new ArcGISMapProvider('', '', vi.fn()); + await expect( + provider.initMap(document.createElement('div'), { lat: 1, lng: 2 }) + ).rejects.toThrow('WebGL unavailable'); + expect(provider.view).toBeNull(); + expect(provider.map).toBeNull(); + }); + + test('preserves a custom basemap during theme changes and dims only its base layer', async () => { + const provider = await initializedProvider({ + customBasemapUrl: 'https://tiles.example.test/style.json' + }); + const basemap = provider.map.basemap; + provider.setTheme('dark'); + expect(provider.map.basemap).toBe(basemap); + provider.setBasemapDimmed(true); + expect(basemap.baseLayers[0].opacity).toBe(0.6); + provider.setBasemapDimmed(false); + expect(basemap.baseLayers[0].opacity).toBe(1); + }); + + test('keeps the context menu compact and closes its mounted content cleanly', async () => { + const provider = await initializedProvider(); + provider.showContextMenu({ latitude: 47.6, longitude: -122.3 }); + + expect(provider.view.popup.visibleElements).toMatchObject({ + closeButton: false, + actionBar: false, + collapseButton: false + }); + expect(provider.view.popup.dockEnabled).toBe(false); + expect(provider.contextMenuComponent).not.toBeNull(); + + provider.closeContextMenu(); + expect(provider.contextMenuComponent).toBeNull(); + expect(provider.view.popup.close).toHaveBeenCalledTimes(1); + }); + + test('returns WGS84 bounds and null when no extent is available', async () => { + const provider = await initializedProvider(); + provider.view.extent = { + xmin: 0, + ymin: 0, + xmax: 0, + ymax: 0, + spatialReference: { isWGS84: false } + }; + expect(provider.getBoundingBox()).toEqual({ + north: 47.7, + south: 47.5, + east: -122.2, + west: -122.4 + }); + provider.view.extent = null; + expect(provider.getBoundingBox()).toBeNull(); + }); + + test('creates synchronous, WGS84-converted polylines with opacity, casing, and arrows', async () => { + const provider = await initializedProvider(); + const polyline = provider.createPolyline(SHAPE, { + color: '#112233', + opacity: 0.5, + weight: 7, + casing: true + }); + expect(polyline).not.toBeInstanceOf(Promise); + expect(polyline.geometry.paths[0][0]).toEqual([-120.2, 38.5]); + expect(polyline.symbol.data.data.symbolLayers).toHaveLength(2); + expect(polyline._casing).toBeTruthy(); + provider.removePolyline(polyline); + expect(provider.getPolylinesCount()).toBe(0); + expect(provider.routeCasingLayer.graphics).toHaveLength(0); + }); + + test('uses the latest vehicle data for a shared hit-test popup and clears popup components on close', async () => { + const provider = await initializedProvider(); + const marker = provider.addVehicleMarker( + { + vehicleId: 'vehicle-1', + position: { lat: 47.6, lon: -122.3 }, + orientation: 0, + predicted: true, + lastUpdateTime: 1, + nextStop: 'old-stop' + }, + { tripHeadsign: 'Old destination' }, + 3 + ); + provider.updateVehicleMarker( + marker, + { + vehicleId: 'vehicle-1', + position: { lat: 47.61, lon: -122.31 }, + orientation: 90, + predicted: true, + lastUpdateTime: 2, + nextStop: 'new-stop' + }, + { tripHeadsign: 'New destination' }, + 3 + ); + expect(marker.vehicleData.nextDestination).toBe('New destination'); + + const clickHandler = provider.view.on.mock.calls[0][1]; + provider.view.hitTest.mockResolvedValue({ results: [{ graphic: marker.graphic }] }); + await clickHandler({}); + expect(provider.view.openPopup).toHaveBeenCalledWith( + expect.objectContaining({ location: marker.graphic.geometry }) + ); + + const popupWatch = sdk.reactiveUtils.watch.mock.calls[1][1]; + popupWatch(false); + expect(provider.activePopupComponent).toBeNull(); + }); + + test('hides off-screen HTML markers, catches hit-test errors, and safely destroys twice', async () => { + const provider = await initializedProvider(); + const marker = provider.addMarker({ + stop: { id: 's1', name: 'Stop', routes: [] }, + position: { lat: 47.6, lng: -122.3 } + }); + provider.view.toScreen.mockReturnValue(null); + provider._updateMarkerPosition(marker); + expect(marker.element.style.display).toBe('none'); + const clickHandler = provider.view.on.mock.calls[0][1]; + provider.view.hitTest.mockRejectedValue(new Error('destroyed')); + await clickHandler({}); + expect(console.warn).toHaveBeenCalledWith('ArcGIS hitTest failed', expect.any(Error)); + const handles = [...provider.handles]; + provider.destroy(); + provider.destroy(); + expect(handles.every((handle) => handle.remove.mock.calls.length === 1)).toBe(true); + }); +}); diff --git a/src/tests/lib/mapProviderFactory.test.js b/src/tests/lib/mapProviderFactory.test.js new file mode 100644 index 00000000..dcba14ec --- /dev/null +++ b/src/tests/lib/mapProviderFactory.test.js @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const providers = vi.hoisted(() => ({ google: vi.fn(), osm: vi.fn(), arcgis: vi.fn() })); + +vi.mock('$lib/Provider/GoogleMapProvider.svelte', () => ({ default: providers.google })); +vi.mock('$lib/Provider/OpenStreetMapProvider.svelte', () => ({ default: providers.osm })); +vi.mock('$lib/Provider/ArcGISMapProvider.svelte', () => ({ default: providers.arcgis })); + +import { createMapProvider } from '$lib/mapProviderFactory.js'; + +describe('createMapProvider', () => { + const handler = vi.fn(); + const config = { + googleApiKey: 'google-key', + arcgisApiKey: 'arcgis-key', + arcgisCustomBasemapUrl: 'https://tiles.example.test/style.json' + }; + + beforeEach(() => { + providers.google.mockReset(); + providers.osm.mockReset(); + providers.arcgis.mockReset(); + console.error.mockClear(); + }); + + test('selects ArcGIS and passes dynamic public configuration', () => { + createMapProvider('arcgis', config, handler); + expect(providers.arcgis).toHaveBeenCalledWith( + config.arcgisApiKey, + config.arcgisCustomBasemapUrl, + handler + ); + }); + + test('falls back to OSM for an unknown provider', () => { + createMapProvider('not-a-provider', config, handler); + expect(providers.osm).toHaveBeenCalledWith(handler); + expect(console.error).toHaveBeenCalledWith( + 'Unknown map provider: not-a-provider; falling back to OSM.' + ); + }); +}); From bbdd65b6103e54582acaa810d1ac2f3f6a3a416c Mon Sep 17 00:00:00 2001 From: Soumajit Ghosh Date: Mon, 24 Aug 2026 14:21:41 +0530 Subject: [PATCH 5/5] fix: address map provider review feedback --- src/components/map/MapView.svelte | 13 +++++- src/lib/Provider/ArcGISMapProvider.svelte.js | 39 ++++++++++++++---- .../Provider/OpenStreetMapProvider.svelte.js | 5 +++ src/lib/mapProviderFactory.js | 6 +-- src/lib/utils.js | 18 ++++++-- src/tests/lib/ArcGISMapProvider.test.js | 41 +++++++++++++++++-- src/tests/lib/OpenStreetMapProvider.test.js | 19 +++++++++ src/tests/lib/mapProviderFactory.test.js | 6 +-- src/tests/lib/utils.test.js | 11 +++++ 9 files changed, 135 insertions(+), 23 deletions(-) diff --git a/src/components/map/MapView.svelte b/src/components/map/MapView.svelte index c1e31513..4617f2c5 100644 --- a/src/components/map/MapView.svelte +++ b/src/components/map/MapView.svelte @@ -92,6 +92,8 @@ let mapMode = $state(startInTripPlanMode ? Modes.TRIP_PLAN : Modes.NORMAL); let modeChangeTimeout = null; let pendingMarkerBatch = null; + let debouncedLoadMarkers = null; + let isDestroyed = false; $effect(() => { let newMode; @@ -217,6 +219,7 @@ lat: mapCenterLat, lng: mapCenterLng }); + if (isDestroyed) return; mapInstance = mapProvider; @@ -231,8 +234,8 @@ await loadStopsAndAddMarkers(mapCenterLat, mapCenterLng, true); } - const debouncedLoadMarkers = debounce(async () => { - if (mapMode !== Modes.NORMAL) { + debouncedLoadMarkers = debounce(async () => { + if (isDestroyed || mapMode !== Modes.NORMAL || !mapInstance) { return; } @@ -363,7 +366,9 @@ let planTripHandler, tabSwitchHandler; onMount(async () => { + isDestroyed = false; await initMap(); + if (isDestroyed) return; isMapLoaded.set(true); if (browser) { const darkMode = document.documentElement.classList.contains('dark'); @@ -385,6 +390,10 @@ }); onDestroy(() => { + isDestroyed = true; + debouncedLoadMarkers?.cancel?.(); + debouncedLoadMarkers = null; + if (browser) { window.removeEventListener('themeChange', handleThemeChange); diff --git a/src/lib/Provider/ArcGISMapProvider.svelte.js b/src/lib/Provider/ArcGISMapProvider.svelte.js index 926f98ce..3135b527 100644 --- a/src/lib/Provider/ArcGISMapProvider.svelte.js +++ b/src/lib/Provider/ArcGISMapProvider.svelte.js @@ -312,9 +312,7 @@ export default class ArcGISMapProvider { if (!marker) return; if (marker.component) unmount(marker.component); marker.element?.remove(); - for (const [id, stored] of this.markersMap) { - if (stored === marker) this.markersMap.delete(id); - } + if (this.markersMap.get(marker.id) === marker) this.markersMap.delete(marker.id); } hasMarker(stopId) { @@ -607,17 +605,38 @@ export default class ArcGISMapProvider { this._darkTheme = theme === 'dark'; if (!this.map || this.customBasemapUrl) return; this.map.basemap = this._darkTheme ? DARK_BASEMAP : DEFAULT_BASEMAP; - this.setBasemapDimmed(this._dimmed); + this._basemapLayerOpacities.clear(); + this._applyBasemapDimmingWhenReady(this.map.basemap); } setBasemapDimmed(dimmed) { this._dimmed = dimmed; - const layers = this.map?.basemap?.baseLayers; + this._applyBasemapDimmingWhenReady(this.map?.basemap); + } + + _applyBasemapDimmingWhenReady(basemap) { + if (!basemap) return; + const apply = () => { + if (this.map?.basemap !== basemap) return; + this._applyBasemapDimming(basemap.baseLayers); + }; + const loading = basemap.loadAll?.(); + if (loading?.then) { + loading.then(apply).catch(() => { + // A failed basemap is surfaced by the ArcGIS view; avoid an unhandled + // rejection from this best-effort visual treatment. + }); + } else { + apply(); + } + } + + _applyBasemapDimming(layers) { if (!layers) return; layers.forEach((layer) => { if (!this._basemapLayerOpacities.has(layer)) this._basemapLayerOpacities.set(layer, layer.opacity ?? 1); - layer.opacity = dimmed + layer.opacity = this._dimmed ? Math.min(this._basemapLayerOpacities.get(layer), 0.6) : this._basemapLayerOpacities.get(layer); }); @@ -693,10 +712,9 @@ export default class ArcGISMapProvider { graphic._casing = new this.Graphic({ geometry, symbol: new this.SimpleLineSymbol({ - color: '#ffffff', + color: colorWithOpacity('#ffffff', 0.95), width: weight + 5, - style: 'solid', - opacity: 0.95 + style: 'solid' }) }); this.routeCasingLayer.add(graphic._casing); @@ -841,6 +859,9 @@ export default class ArcGISMapProvider { cancelAnimationFrame(this._positionFrame); this._positionFrame = null; this._removeHandles(); + this.viewportLoadHandle = null; + this.contextMenuHandle = null; + this.mapClickHandle = null; this.cleanupInfoWindow(); this.closeContextMenu(); this.clearAllStopMarkers(); diff --git a/src/lib/Provider/OpenStreetMapProvider.svelte.js b/src/lib/Provider/OpenStreetMapProvider.svelte.js index da612712..3e3b84ca 100644 --- a/src/lib/Provider/OpenStreetMapProvider.svelte.js +++ b/src/lib/Provider/OpenStreetMapProvider.svelte.js @@ -430,6 +430,11 @@ export default class OpenStreetMapProvider { if (this.globalInfoWindow) { this.globalInfoWindow.close(); } + if (this.popupContentComponent) { + unmount(this.popupContentComponent); + } + this.popupContentComponent = null; + this.globalInfoWindow = null; } removeStopMarker(marker) { diff --git a/src/lib/mapProviderFactory.js b/src/lib/mapProviderFactory.js index 685c4f31..df9c6607 100644 --- a/src/lib/mapProviderFactory.js +++ b/src/lib/mapProviderFactory.js @@ -1,6 +1,6 @@ -import GoogleMapProvider from '$lib/Provider/GoogleMapProvider.svelte'; -import OpenStreetMapProvider from '$lib/Provider/OpenStreetMapProvider.svelte'; -import ArcGISMapProvider from '$lib/Provider/ArcGISMapProvider.svelte'; +import GoogleMapProvider from '$lib/Provider/GoogleMapProvider.svelte.js'; +import OpenStreetMapProvider from '$lib/Provider/OpenStreetMapProvider.svelte.js'; +import ArcGISMapProvider from '$lib/Provider/ArcGISMapProvider.svelte.js'; import { MapSource } from '$config/mapSource.js'; /** Creates the configured provider and keeps invalid deployment configuration usable. */ diff --git a/src/lib/utils.js b/src/lib/utils.js index 97f55414..acd3742a 100644 --- a/src/lib/utils.js +++ b/src/lib/utils.js @@ -1,10 +1,22 @@ export function debounce(func, wait) { - let timeout; + let timeout = null; - return function (...args) { + function debounced(...args) { clearTimeout(timeout); - timeout = setTimeout(() => func.apply(this, args), wait); + timeout = setTimeout(() => { + timeout = null; + func.apply(this, args); + }, wait); + } + + // Consumers that own a lifecycle (such as a map view) must be able to + // discard a queued call before its target is torn down. + debounced.cancel = () => { + if (timeout != null) clearTimeout(timeout); + timeout = null; }; + + return debounced; } /** diff --git a/src/tests/lib/ArcGISMapProvider.test.js b/src/tests/lib/ArcGISMapProvider.test.js index c2720fc1..6c1368a3 100644 --- a/src/tests/lib/ArcGISMapProvider.test.js +++ b/src/tests/lib/ArcGISMapProvider.test.js @@ -111,6 +111,7 @@ const sdk = vi.hoisted(() => { MapView, reactiveUtils, projection, + arcgisConfig: {}, webMercatorUtils: { isWebMercator: vi.fn(() => true), webMercatorToGeographic: vi.fn((extent) => ({ @@ -137,7 +138,7 @@ vi.mock('@arcgis/core/symbols/SimpleLineSymbol.js', () => ({ default: sdk.Symbol vi.mock('@arcgis/core/symbols/SimpleMarkerSymbol.js', () => ({ default: sdk.Symbol })); vi.mock('@arcgis/core/symbols/PictureMarkerSymbol.js', () => ({ default: sdk.Symbol })); vi.mock('@arcgis/core/symbols/CIMSymbol.js', () => ({ default: sdk.Symbol })); -vi.mock('@arcgis/core/config.js', () => ({ default: {} })); +vi.mock('@arcgis/core/config.js', () => ({ default: sdk.arcgisConfig })); vi.mock('@arcgis/core/core/reactiveUtils.js', () => sdk.reactiveUtils); vi.mock('@arcgis/core/geometry/support/webMercatorUtils.js', () => sdk.webMercatorUtils); vi.mock('@arcgis/core/geometry/projection.js', () => sdk.projection); @@ -146,8 +147,8 @@ import ArcGISMapProvider from '$lib/Provider/ArcGISMapProvider.svelte.js'; const SHAPE = '_p~iF~ps|U_ulLnnqC_mqNvxq`@'; -async function initializedProvider({ customBasemapUrl = '' } = {}) { - const provider = new ArcGISMapProvider('key', customBasemapUrl, vi.fn()); +async function initializedProvider({ apiKey = 'key', customBasemapUrl = '' } = {}) { + const provider = new ArcGISMapProvider(apiKey, customBasemapUrl, vi.fn()); await provider.initMap(document.createElement('div'), { lat: 47.6, lng: -122.3 }); return provider; } @@ -157,6 +158,7 @@ describe('ArcGISMapProvider', () => { sdk.MapView.whenResult = Promise.resolve(); sdk.reactiveUtils.watch.mockClear(); sdk.projection.load.mockClear(); + delete sdk.arcgisConfig.apiKey; }); test('initializes optional API key, custom basemap, shared handlers, and overlay', async () => { @@ -173,6 +175,12 @@ describe('ArcGISMapProvider', () => { collapseButton: false, closeButton: true }); + expect(sdk.arcgisConfig.apiKey).toBe('key'); + }); + + test('does not set a global ArcGIS API key when none is configured', async () => { + await initializedProvider({ apiKey: '' }); + expect(sdk.arcgisConfig.apiKey).toBeUndefined(); }); test('cleans up partial state when view.when rejects', async () => { @@ -198,6 +206,28 @@ describe('ArcGISMapProvider', () => { expect(basemap.baseLayers[0].opacity).toBe(1); }); + test('applies dimming after an asynchronously loaded basemap is ready', async () => { + const provider = await initializedProvider(); + const layer = { opacity: 1 }; + let resolveLoad; + provider.map.basemap = { + baseLayers: [layer], + loadAll: vi.fn( + () => + new Promise((resolve) => { + resolveLoad = resolve; + }) + ) + }; + + provider.setBasemapDimmed(true); + expect(layer.opacity).toBe(1); + resolveLoad(); + await Promise.resolve(); + + expect(layer.opacity).toBe(0.6); + }); + test('keeps the context menu compact and closes its mounted content cleanly', async () => { const provider = await initializedProvider(); provider.showContextMenu({ latitude: 47.6, longitude: -122.3 }); @@ -294,6 +324,8 @@ describe('ArcGISMapProvider', () => { test('hides off-screen HTML markers, catches hit-test errors, and safely destroys twice', async () => { const provider = await initializedProvider(); + provider.eventListeners(provider, vi.fn()); + provider.enableContextMenu(); const marker = provider.addMarker({ stop: { id: 's1', name: 'Stop', routes: [] }, position: { lat: 47.6, lng: -122.3 } @@ -309,5 +341,8 @@ describe('ArcGISMapProvider', () => { provider.destroy(); provider.destroy(); expect(handles.every((handle) => handle.remove.mock.calls.length === 1)).toBe(true); + expect(provider.viewportLoadHandle).toBeNull(); + expect(provider.contextMenuHandle).toBeNull(); + expect(provider.mapClickHandle).toBeNull(); }); }); diff --git a/src/tests/lib/OpenStreetMapProvider.test.js b/src/tests/lib/OpenStreetMapProvider.test.js index 2952ff12..262e3c57 100644 --- a/src/tests/lib/OpenStreetMapProvider.test.js +++ b/src/tests/lib/OpenStreetMapProvider.test.js @@ -1,4 +1,5 @@ import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { unmount } from 'svelte'; import OpenStreetMapProvider, { toLeafletPadding } from '$lib/Provider/OpenStreetMapProvider.svelte.js'; @@ -920,3 +921,21 @@ describe('toLeafletPadding / fitToPolylines padding', () => { ); }); }); + +describe('destroy', () => { + test('unmounts and clears the active stop popup before removing the map', () => { + const provider = new OpenStreetMapProvider(vi.fn()); + const popup = { close: vi.fn() }; + const popupComponent = {}; + provider.map = { remove: vi.fn() }; + provider.globalInfoWindow = popup; + provider.popupContentComponent = popupComponent; + + provider.destroy(); + + expect(popup.close).toHaveBeenCalledOnce(); + expect(unmount).toHaveBeenCalledWith(popupComponent); + expect(provider.globalInfoWindow).toBeNull(); + expect(provider.popupContentComponent).toBeNull(); + }); +}); diff --git a/src/tests/lib/mapProviderFactory.test.js b/src/tests/lib/mapProviderFactory.test.js index dcba14ec..76ed127f 100644 --- a/src/tests/lib/mapProviderFactory.test.js +++ b/src/tests/lib/mapProviderFactory.test.js @@ -2,9 +2,9 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; const providers = vi.hoisted(() => ({ google: vi.fn(), osm: vi.fn(), arcgis: vi.fn() })); -vi.mock('$lib/Provider/GoogleMapProvider.svelte', () => ({ default: providers.google })); -vi.mock('$lib/Provider/OpenStreetMapProvider.svelte', () => ({ default: providers.osm })); -vi.mock('$lib/Provider/ArcGISMapProvider.svelte', () => ({ default: providers.arcgis })); +vi.mock('$lib/Provider/GoogleMapProvider.svelte.js', () => ({ default: providers.google })); +vi.mock('$lib/Provider/OpenStreetMapProvider.svelte.js', () => ({ default: providers.osm })); +vi.mock('$lib/Provider/ArcGISMapProvider.svelte.js', () => ({ default: providers.arcgis })); import { createMapProvider } from '$lib/mapProviderFactory.js'; diff --git a/src/tests/lib/utils.test.js b/src/tests/lib/utils.test.js index f7a6090b..90d16dfb 100644 --- a/src/tests/lib/utils.test.js +++ b/src/tests/lib/utils.test.js @@ -104,6 +104,17 @@ describe('debounce', () => { expect(mockFn).toBeCalledTimes(1); }); + + it('cancels a queued invocation', () => { + const mockFn = vi.fn(); + const debouncedFn = debounce(mockFn, 100); + + debouncedFn(); + debouncedFn.cancel(); + vi.advanceTimersByTime(100); + + expect(mockFn).not.toHaveBeenCalled(); + }); }); describe('removeAgencyPrefix', () => {