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/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/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/components/map/MapView.svelte b/src/components/map/MapView.svelte
index 730a7042..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;
@@ -179,6 +181,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)) {
@@ -212,6 +219,7 @@
lat: mapCenterLat,
lng: mapCenterLng
});
+ if (isDestroyed) return;
mapInstance = mapProvider;
@@ -226,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;
}
@@ -252,6 +260,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 || [];
@@ -357,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');
@@ -379,6 +390,10 @@
});
onDestroy(() => {
+ isDestroyed = true;
+ debouncedLoadMarkers?.cancel?.();
+ debouncedLoadMarkers = null;
+
if (browser) {
window.removeEventListener('themeChange', handleThemeChange);
@@ -396,6 +411,7 @@
}
clearAllMarkers();
+ mapProvider?.destroy?.();
allStopsMap.clear();
stopsCache.clear();
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/Provider/ArcGISMapProvider.svelte.js b/src/lib/Provider/ArcGISMapProvider.svelte.js
new file mode 100644
index 00000000..3135b527
--- /dev/null
+++ b/src/lib/Provider/ArcGISMapProvider.svelte.js
@@ -0,0 +1,887 @@
+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();
+ if (this.markersMap.get(marker.id) === marker) this.markersMap.delete(marker.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._basemapLayerOpacities.clear();
+ this._applyBasemapDimmingWhenReady(this.map.basemap);
+ }
+
+ setBasemapDimmed(dimmed) {
+ this._dimmed = dimmed;
+ 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 = this._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: colorWithOpacity('#ffffff', 0.95),
+ width: weight + 5,
+ style: 'solid'
+ })
+ });
+ 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.viewportLoadHandle = null;
+ this.contextMenuHandle = null;
+ this.mapClickHandle = null;
+ 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();
+ }
+}
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..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) {
@@ -706,11 +711,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 +1152,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/mapProviderFactory.js b/src/lib/mapProviderFactory.js
new file mode 100644
index 00000000..df9c6607
--- /dev/null
+++ b/src/lib/mapProviderFactory.js
@@ -0,0 +1,22 @@
+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. */
+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/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
*/
/**
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
new file mode 100644
index 00000000..6c1368a3
--- /dev/null
+++ b/src/tests/lib/ArcGISMapProvider.test.js
@@ -0,0 +1,348 @@
+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,
+ arcgisConfig: {},
+ 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: 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);
+
+import ArcGISMapProvider from '$lib/Provider/ArcGISMapProvider.svelte.js';
+
+const SHAPE = '_p~iF~ps|U_ulLnnqC_mqNvxq`@';
+
+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;
+}
+
+describe('ArcGISMapProvider', () => {
+ beforeEach(() => {
+ 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 () => {
+ 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
+ });
+ 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 () => {
+ 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('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 });
+
+ 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();
+ provider.eventListeners(provider, vi.fn());
+ provider.enableContextMenu();
+ 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);
+ 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
new file mode 100644
index 00000000..76ed127f
--- /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.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';
+
+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.'
+ );
+ });
+});
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', () => {
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: ''
}
}));