feat(geo-map): add live traffic visualization and traffic-aware routing with pluggable providers and UI integration
17
changelog.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-02-05 - 1.1.0 - feat(geo-map)
|
||||
add live traffic visualization and traffic-aware routing with pluggable providers and UI integration
|
||||
|
||||
- Introduce TrafficController and traffic layer rendering (auto-refresh, zoom gating, legend).
|
||||
- Add traffic providers: HereTrafficProvider (HERE API) and ValhallaTrafficProvider (self-hosted) with ITrafficProvider interface and config types.
|
||||
- Integrate traffic-aware routing into NavigationController: fetch and render traffic-aware routes, show congestion level and delay in the navigation panel.
|
||||
- Add toolbar/header UI: traffic toggle button, traffic legend, new icons, overlay grid and header toolbar layout; update demo and readme.hints.md with usage and provider examples.
|
||||
- Add new source files: geo-map.traffic.ts and geo-map.traffic.providers.ts; export types from index.ts and update icons and styles (trafficStyles, headerToolbarStyles).
|
||||
- Update component styles (overlay grid, toolbar, traffic legend) and refactor parts of dees-geo-map for modular controllers.
|
||||
- Include new header toolbar artwork assets and update demo text; bump devDependencies: @git.zone/tswatch ^3.1.0 and @types/node ^25.2.1.
|
||||
|
||||
## 2026-02-05 - 1.0.0 - initial
|
||||
Initial release.
|
||||
|
||||
- Initial commit: project scaffold and first files
|
||||
BIN
header-toolbar-fixed.png
Normal file
|
After Width: | Height: | Size: 125 KiB |
BIN
header-toolbar-full.png
Normal file
|
After Width: | Height: | Size: 125 KiB |
BIN
header-toolbar-layout.png
Normal file
|
After Width: | Height: | Size: 125 KiB |
BIN
header-toolbar-native.png
Normal file
|
After Width: | Height: | Size: 302 KiB |
BIN
header-toolbar-v3.png
Normal file
|
After Width: | Height: | Size: 127 KiB |
BIN
map-layout-test.png
Normal file
|
After Width: | Height: | Size: 951 KiB |
BIN
map-no-toolbar-test.png
Normal file
|
After Width: | Height: | Size: 968 KiB |
@@ -27,9 +27,9 @@
|
||||
"@git.zone/tsbuild": "^4.1.2",
|
||||
"@git.zone/tsbundle": "^2.8.3",
|
||||
"@git.zone/tstest": "^3.1.8",
|
||||
"@git.zone/tswatch": "^3.0.1",
|
||||
"@git.zone/tswatch": "^3.1.0",
|
||||
"@push.rocks/projectinfo": "^5.0.2",
|
||||
"@types/node": "^25.2.0"
|
||||
"@types/node": "^25.2.1"
|
||||
},
|
||||
"files": [
|
||||
"ts/**/*",
|
||||
|
||||
96
pnpm-lock.yaml
generated
@@ -40,14 +40,14 @@ importers:
|
||||
specifier: ^3.1.8
|
||||
version: 3.1.8(@push.rocks/smartserve@2.0.1)(socks@2.8.7)(typescript@5.9.3)
|
||||
'@git.zone/tswatch':
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.1(@tiptap/pm@2.27.2)
|
||||
specifier: ^3.1.0
|
||||
version: 3.1.0(@tiptap/pm@2.27.2)
|
||||
'@push.rocks/projectinfo':
|
||||
specifier: ^5.0.2
|
||||
version: 5.0.2
|
||||
'@types/node':
|
||||
specifier: ^25.2.0
|
||||
version: 25.2.0
|
||||
specifier: ^25.2.1
|
||||
version: 25.2.1
|
||||
|
||||
packages:
|
||||
|
||||
@@ -263,6 +263,9 @@ packages:
|
||||
'@cloudflare/workers-types@4.20260203.0':
|
||||
resolution: {integrity: sha512-XD2uglpGbVppjXXLuAdalKkcTi/i4TyQSx0w/ijJbvrR1Cfm7zNkxtvFBNy3tBNxZOiFIJtw5bszifQB1eow6A==}
|
||||
|
||||
'@cloudflare/workers-types@4.20260205.0':
|
||||
resolution: {integrity: sha512-LTnpvcodmiuMwxmbrO2Fd0+Avbm2UVLLJxT8J2pRWPfoM44gmbIecXwOPZmDAMeadKWrBsQ+B0sloQAhUu5fpA==}
|
||||
|
||||
'@configvault.io/interfaces@1.0.17':
|
||||
resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==}
|
||||
|
||||
@@ -486,8 +489,8 @@ packages:
|
||||
resolution: {integrity: sha512-nmiLGeOkKMkLDyIk5BUBLx5ExskFbKHKlPdrWCARPVFkU4cAAiuIyJWVfLwISoS0TO/zSInLqArPwIc76yvaNw==}
|
||||
hasBin: true
|
||||
|
||||
'@git.zone/tswatch@3.0.1':
|
||||
resolution: {integrity: sha512-vrAkKM5ff/e1BLNkrIRXnTIkMyjl/uW49c1cYaw2nYGloM6/wT1FSwYjwh6BcDkHIYMnzS30SOy9jSYRptW/iw==}
|
||||
'@git.zone/tswatch@3.1.0':
|
||||
resolution: {integrity: sha512-R2ZI+j1OKVgd0zTbtGtJjyt7r2kF0Z4nl8neolHuQL+jpr16i2NHVfVK92uIeeZDnJSqo5vf7Syt0XeQ4rz2HA==}
|
||||
hasBin: true
|
||||
|
||||
'@happy-dom/global-registrator@15.11.7':
|
||||
@@ -1029,6 +1032,9 @@ packages:
|
||||
'@push.rocks/taskbuffer@3.5.0':
|
||||
resolution: {integrity: sha512-Y9WwIEIyp6oVFdj06j84tfrZIvjhbMb3DF52rYxlTeYLk3W7RPhSg1bGPCbtkXWeKdBrSe37V90BkOG7Qq8Pqg==}
|
||||
|
||||
'@push.rocks/taskbuffer@4.2.0':
|
||||
resolution: {integrity: sha512-ttoBe5y/WXkAo5/wSMcC/Y4Zbyw4XG8kwAsEaqnAPCxa3M9MI1oV/yM1e9gU1IH97HVPidzbTxRU5/PcHDdUsg==}
|
||||
|
||||
'@push.rocks/webrequest@3.0.37':
|
||||
resolution: {integrity: sha512-fLN7kP6GeHFxE4UH4r9C9pjcQb0QkJxHeAMwXvbOqB9hh0MFNKhtGU7GoaTn8SVRGRMPc9UqZVNwo6u5l8Wn0A==}
|
||||
|
||||
@@ -1748,11 +1754,11 @@ packages:
|
||||
'@types/node-forge@1.3.14':
|
||||
resolution: {integrity: sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==}
|
||||
|
||||
'@types/node@22.19.8':
|
||||
resolution: {integrity: sha512-ebO/Yl+EAvVe8DnMfi+iaAyIqYdK0q/q0y0rw82INWEKJOBe6b/P3YWE8NW7oOlF/nXFNrHwhARrN/hdgDkraA==}
|
||||
'@types/node@22.19.9':
|
||||
resolution: {integrity: sha512-PD03/U8g1F9T9MI+1OBisaIARhSzeidsUjQaf51fOxrfjeiKN9bLVO06lHuHYjxdnqLWJijJHfqXPSJri2EM2A==}
|
||||
|
||||
'@types/node@25.2.0':
|
||||
resolution: {integrity: sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==}
|
||||
'@types/node@25.2.1':
|
||||
resolution: {integrity: sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg==}
|
||||
|
||||
'@types/ping@0.4.4':
|
||||
resolution: {integrity: sha512-ifvo6w2f5eJYlXm+HiVx67iJe8WZp87sfa683nlqED5Vnt9Z93onkokNoWqOG21EaE8fMxyKPobE+mkPEyxsdw==}
|
||||
@@ -4114,7 +4120,7 @@ snapshots:
|
||||
'@api.global/typedrequest': 3.2.5
|
||||
'@api.global/typedrequest-interfaces': 3.0.19
|
||||
'@api.global/typedsocket': 4.1.0(@push.rocks/smartserve@2.0.1)
|
||||
'@cloudflare/workers-types': 4.20260203.0
|
||||
'@cloudflare/workers-types': 4.20260205.0
|
||||
'@design.estate/dees-catalog': 3.42.0(@tiptap/pm@2.27.2)
|
||||
'@design.estate/dees-comms': 1.0.30
|
||||
'@push.rocks/lik': 6.2.2
|
||||
@@ -4689,6 +4695,8 @@ snapshots:
|
||||
|
||||
'@cloudflare/workers-types@4.20260203.0': {}
|
||||
|
||||
'@cloudflare/workers-types@4.20260205.0': {}
|
||||
|
||||
'@configvault.io/interfaces@1.0.17':
|
||||
dependencies:
|
||||
'@api.global/typedrequest-interfaces': 3.0.19
|
||||
@@ -5021,7 +5029,7 @@ snapshots:
|
||||
- utf-8-validate
|
||||
- vue
|
||||
|
||||
'@git.zone/tswatch@3.0.1(@tiptap/pm@2.27.2)':
|
||||
'@git.zone/tswatch@3.1.0(@tiptap/pm@2.27.2)':
|
||||
dependencies:
|
||||
'@api.global/typedserver': 8.3.0(@tiptap/pm@2.27.2)
|
||||
'@git.zone/tsbundle': 2.8.3
|
||||
@@ -5037,7 +5045,7 @@ snapshots:
|
||||
'@push.rocks/smartlog-destination-local': 9.0.2
|
||||
'@push.rocks/smartshell': 3.3.0
|
||||
'@push.rocks/smartwatch': 6.3.0
|
||||
'@push.rocks/taskbuffer': 3.5.0
|
||||
'@push.rocks/taskbuffer': 4.2.0
|
||||
transitivePeerDependencies:
|
||||
- '@nuxt/kit'
|
||||
- '@swc/helpers'
|
||||
@@ -5070,7 +5078,7 @@ snapshots:
|
||||
'@inquirer/figures': 1.0.15
|
||||
'@inquirer/type': 2.0.0
|
||||
'@types/mute-stream': 0.0.4
|
||||
'@types/node': 22.19.8
|
||||
'@types/node': 22.19.9
|
||||
'@types/wrap-ansi': 3.0.0
|
||||
ansi-escapes: 4.3.2
|
||||
cli-width: 4.1.0
|
||||
@@ -6215,6 +6223,22 @@ snapshots:
|
||||
- supports-color
|
||||
- vue
|
||||
|
||||
'@push.rocks/taskbuffer@4.2.0':
|
||||
dependencies:
|
||||
'@design.estate/dees-element': 2.1.6
|
||||
'@push.rocks/lik': 6.2.2
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
'@push.rocks/smartlog': 3.1.10
|
||||
'@push.rocks/smartpromise': 4.2.3
|
||||
'@push.rocks/smartrx': 3.0.10
|
||||
'@push.rocks/smarttime': 4.1.1
|
||||
'@push.rocks/smartunique': 3.0.9
|
||||
transitivePeerDependencies:
|
||||
- '@nuxt/kit'
|
||||
- react
|
||||
- supports-color
|
||||
- vue
|
||||
|
||||
'@push.rocks/webrequest@3.0.37':
|
||||
dependencies:
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
@@ -6942,27 +6966,27 @@ snapshots:
|
||||
|
||||
'@types/bn.js@5.2.0':
|
||||
dependencies:
|
||||
'@types/node': 25.2.0
|
||||
'@types/node': 25.2.1
|
||||
|
||||
'@types/body-parser@1.19.6':
|
||||
dependencies:
|
||||
'@types/connect': 3.4.38
|
||||
'@types/node': 25.2.0
|
||||
'@types/node': 25.2.1
|
||||
|
||||
'@types/buffer-json@2.0.3': {}
|
||||
|
||||
'@types/clean-css@4.2.11':
|
||||
dependencies:
|
||||
'@types/node': 25.2.0
|
||||
'@types/node': 25.2.1
|
||||
source-map: 0.6.1
|
||||
|
||||
'@types/connect@3.4.38':
|
||||
dependencies:
|
||||
'@types/node': 25.2.0
|
||||
'@types/node': 25.2.1
|
||||
|
||||
'@types/cors@2.8.19':
|
||||
dependencies:
|
||||
'@types/node': 25.2.0
|
||||
'@types/node': 25.2.1
|
||||
|
||||
'@types/debug@4.1.12':
|
||||
dependencies:
|
||||
@@ -6970,7 +6994,7 @@ snapshots:
|
||||
|
||||
'@types/dns-packet@5.6.5':
|
||||
dependencies:
|
||||
'@types/node': 25.2.0
|
||||
'@types/node': 25.2.1
|
||||
|
||||
'@types/elliptic@6.4.18':
|
||||
dependencies:
|
||||
@@ -6978,7 +7002,7 @@ snapshots:
|
||||
|
||||
'@types/express-serve-static-core@5.1.1':
|
||||
dependencies:
|
||||
'@types/node': 25.2.0
|
||||
'@types/node': 25.2.1
|
||||
'@types/qs': 6.14.0
|
||||
'@types/range-parser': 1.2.7
|
||||
'@types/send': 1.2.1
|
||||
@@ -6991,19 +7015,19 @@ snapshots:
|
||||
|
||||
'@types/from2@2.3.6':
|
||||
dependencies:
|
||||
'@types/node': 25.2.0
|
||||
'@types/node': 25.2.1
|
||||
|
||||
'@types/fs-extra@11.0.4':
|
||||
dependencies:
|
||||
'@types/jsonfile': 6.1.4
|
||||
'@types/node': 25.2.0
|
||||
'@types/node': 25.2.1
|
||||
|
||||
'@types/geojson@7946.0.16': {}
|
||||
|
||||
'@types/glob@8.1.0':
|
||||
dependencies:
|
||||
'@types/minimatch': 5.1.2
|
||||
'@types/node': 25.2.0
|
||||
'@types/node': 25.2.1
|
||||
|
||||
'@types/hast@3.0.4':
|
||||
dependencies:
|
||||
@@ -7025,7 +7049,7 @@ snapshots:
|
||||
|
||||
'@types/jsonfile@6.1.4':
|
||||
dependencies:
|
||||
'@types/node': 25.2.0
|
||||
'@types/node': 25.2.1
|
||||
|
||||
'@types/linkify-it@5.0.0': {}
|
||||
|
||||
@@ -7048,17 +7072,17 @@ snapshots:
|
||||
|
||||
'@types/mute-stream@0.0.4':
|
||||
dependencies:
|
||||
'@types/node': 25.2.0
|
||||
'@types/node': 25.2.1
|
||||
|
||||
'@types/node-forge@1.3.14':
|
||||
dependencies:
|
||||
'@types/node': 25.2.0
|
||||
'@types/node': 25.2.1
|
||||
|
||||
'@types/node@22.19.8':
|
||||
'@types/node@22.19.9':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/node@25.2.0':
|
||||
'@types/node@25.2.1':
|
||||
dependencies:
|
||||
undici-types: 7.16.0
|
||||
|
||||
@@ -7076,12 +7100,12 @@ snapshots:
|
||||
|
||||
'@types/send@1.2.1':
|
||||
dependencies:
|
||||
'@types/node': 25.2.0
|
||||
'@types/node': 25.2.1
|
||||
|
||||
'@types/serve-static@2.2.0':
|
||||
dependencies:
|
||||
'@types/http-errors': 2.0.5
|
||||
'@types/node': 25.2.0
|
||||
'@types/node': 25.2.1
|
||||
|
||||
'@types/supercluster@7.1.3':
|
||||
dependencies:
|
||||
@@ -7091,11 +7115,11 @@ snapshots:
|
||||
|
||||
'@types/tar-stream@3.1.4':
|
||||
dependencies:
|
||||
'@types/node': 25.2.0
|
||||
'@types/node': 25.2.1
|
||||
|
||||
'@types/through2@2.0.41':
|
||||
dependencies:
|
||||
'@types/node': 25.2.0
|
||||
'@types/node': 25.2.1
|
||||
|
||||
'@types/trusted-types@2.0.7': {}
|
||||
|
||||
@@ -7121,11 +7145,11 @@ snapshots:
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 25.2.0
|
||||
'@types/node': 25.2.1
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
dependencies:
|
||||
'@types/node': 25.2.0
|
||||
'@types/node': 25.2.1
|
||||
optional: true
|
||||
|
||||
'@ungap/structured-clone@1.3.0': {}
|
||||
@@ -7559,7 +7583,7 @@ snapshots:
|
||||
engine.io@6.6.4:
|
||||
dependencies:
|
||||
'@types/cors': 2.8.19
|
||||
'@types/node': 25.2.0
|
||||
'@types/node': 25.2.1
|
||||
accepts: 1.3.8
|
||||
base64id: 2.0.0
|
||||
cookie: 0.7.2
|
||||
|
||||
104
readme.hints.md
@@ -23,6 +23,9 @@ Geospatial web components library using MapLibre GL JS for map rendering and ter
|
||||
| `showSearch` | `boolean` | `false` | Show address search input |
|
||||
| `showNavigation` | `boolean` | `false` | Show A-to-B navigation panel |
|
||||
| `navigationMode` | `'driving' \| 'walking' \| 'cycling'` | `'driving'` | Transport mode for routing |
|
||||
| `showTraffic` | `boolean` | `false` | Enable traffic layer visualization |
|
||||
| `trafficApiKey` | `string` | `''` | HERE API key for traffic data |
|
||||
| `trafficProvider` | `ITrafficProvider` | `null` | Custom traffic data provider |
|
||||
|
||||
### Drawing Tools (TDrawTool)
|
||||
- `point` - Draw points
|
||||
@@ -41,6 +44,7 @@ Geospatial web components library using MapLibre GL JS for map rendering and ter
|
||||
- `draw-finish` - Fired when a shape is completed
|
||||
- `address-selected` - Fired when a search result is selected
|
||||
- `route-calculated` - Fired when a navigation route is calculated (includes route, startPoint, endPoint, mode)
|
||||
- `traffic-updated` - Fired when traffic data is refreshed
|
||||
|
||||
### Public Methods
|
||||
- `getFeatures()` - Get all drawn features
|
||||
@@ -57,11 +61,19 @@ Geospatial web components library using MapLibre GL JS for map rendering and ter
|
||||
- `setNavigationStart(coords, address?)` - Set navigation start point
|
||||
- `setNavigationEnd(coords, address?)` - Set navigation end point
|
||||
- `clearNavigation()` - Clear all navigation state
|
||||
- `enableTraffic()` - Enable traffic visualization
|
||||
- `disableTraffic()` - Disable traffic visualization
|
||||
- `toggleTraffic()` - Toggle traffic visualization
|
||||
- `refreshTraffic()` - Refresh traffic data
|
||||
- `setTrafficProvider(provider)` - Set custom traffic provider
|
||||
- `supportsTrafficRouting()` - Check if traffic-aware routing is available
|
||||
- `getTrafficController()` - Get the TrafficController instance
|
||||
|
||||
### Context Menu
|
||||
Right-click on the map to access a context menu with the following options:
|
||||
- **Drag to Draw** - Toggle between drag mode (click-drag for circles/rectangles) and two-click mode
|
||||
- **Globe View** - Toggle between globe (3D sphere) and Mercator (flat) projection
|
||||
- **Show Traffic** - Toggle traffic layer (requires configured traffic provider)
|
||||
- **Clear All Features** - Remove all drawn features from the map
|
||||
- **Fit to Features** - Zoom and pan to show all drawn features
|
||||
|
||||
@@ -71,6 +83,45 @@ The navigation panel (`showNavigation={true}`) provides A-to-B routing using OSR
|
||||
- **Point selection**: Type an address or click on the map
|
||||
- **Route display**: Blue line overlay with turn-by-turn directions
|
||||
- **API**: Uses free OSRM API (https://router.project-osrm.org) with fair-use rate limit
|
||||
- **Traffic-aware routing**: When a traffic provider is configured, shows duration with/without traffic
|
||||
|
||||
### Traffic Feature
|
||||
Live traffic visualization with pluggable provider architecture:
|
||||
|
||||
#### HERE Traffic Provider (Recommended)
|
||||
```typescript
|
||||
// Using API key property
|
||||
<dees-geo-map trafficApiKey="YOUR_HERE_API_KEY" showTraffic></dees-geo-map>
|
||||
|
||||
// Or programmatically
|
||||
import { HereTrafficProvider } from '@design.estate/dees-catalog-geo';
|
||||
const map = document.querySelector('dees-geo-map');
|
||||
const provider = new HereTrafficProvider();
|
||||
provider.configure({ apiKey: 'YOUR_HERE_API_KEY' });
|
||||
map.setTrafficProvider(provider);
|
||||
map.enableTraffic();
|
||||
```
|
||||
|
||||
**Free Tier**: 250,000 transactions/month (no credit card required)
|
||||
Sign up at: https://developer.here.com
|
||||
|
||||
#### Valhalla Traffic Provider (Self-Hosted)
|
||||
```typescript
|
||||
import { ValhallaTrafficProvider } from '@design.estate/dees-catalog-geo';
|
||||
const provider = new ValhallaTrafficProvider();
|
||||
provider.configure({
|
||||
serverUrl: 'https://your-valhalla-server.com',
|
||||
trafficDataUrl: 'https://your-traffic-data-endpoint.com' // optional
|
||||
});
|
||||
map.setTrafficProvider(provider);
|
||||
```
|
||||
|
||||
#### Traffic Color Legend
|
||||
- 🟢 Green - Free flow
|
||||
- 🟡 Yellow - Light congestion
|
||||
- 🟠 Orange - Moderate congestion
|
||||
- 🔴 Red - Heavy congestion
|
||||
- 🔴 Dark Red - Severe/stopped
|
||||
|
||||
## Development
|
||||
- `pnpm install` - Install dependencies
|
||||
@@ -90,11 +141,13 @@ ts_web/
|
||||
├── index.ts
|
||||
└── dees-geo-map/
|
||||
├── index.ts # Exports main + modules
|
||||
├── dees-geo-map.ts # Main component (~550 lines)
|
||||
├── dees-geo-map.ts # Main component
|
||||
├── dees-geo-map.demo.ts # Demo function
|
||||
├── geo-map.icons.ts # Icon SVG definitions (~60 lines)
|
||||
├── geo-map.search.ts # SearchController class (~180 lines)
|
||||
└── geo-map.navigation.ts # NavigationController class (~530 lines)
|
||||
├── geo-map.icons.ts # Icon SVG definitions
|
||||
├── geo-map.search.ts # SearchController class
|
||||
├── geo-map.navigation.ts # NavigationController class
|
||||
├── geo-map.traffic.ts # TrafficController class
|
||||
└── geo-map.traffic.providers.ts # Traffic provider implementations
|
||||
```
|
||||
|
||||
## Modular Architecture
|
||||
@@ -117,6 +170,18 @@ Contains all SVG icon definitions as a `GEO_MAP_ICONS` record and a `renderIcon(
|
||||
- Map click mode for point selection
|
||||
- Turn-by-turn directions rendering
|
||||
- Route overlay on map
|
||||
- Traffic-aware routing integration (shows congestion level and delay)
|
||||
|
||||
### geo-map.traffic.ts
|
||||
`TrafficController` class for live traffic visualization:
|
||||
- Traffic layer rendering with color-coded congestion
|
||||
- Auto-refresh logic with configurable interval
|
||||
- Supports pluggable traffic providers
|
||||
|
||||
### geo-map.traffic.providers.ts
|
||||
Traffic data provider implementations:
|
||||
- `HereTrafficProvider` - HERE Traffic API v7 (freemium)
|
||||
- `ValhallaTrafficProvider` - Self-hosted Valhalla server
|
||||
|
||||
### Usage of Controllers
|
||||
```typescript
|
||||
@@ -142,6 +207,37 @@ const nav = new NavigationController({
|
||||
- Terra-draw requires the separate maplibre-gl-adapter package
|
||||
- The component uses Shadow DOM for style encapsulation
|
||||
|
||||
### UI Layout
|
||||
|
||||
The component uses a header toolbar above the map for a cleaner layout:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ HEADER TOOLBAR │
|
||||
│ [Draw Tools] | [Search Bar] | [Nav Toggle] [Traffic] [Zoom +/-] │
|
||||
├──────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ [Navigation] MAP │
|
||||
│ (toggleable) │
|
||||
│ │
|
||||
│ [Traffic Legend] [Feature Count] │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Header Toolbar Sections:**
|
||||
- **Left**: Draw tools (Point, Line, Polygon, Rectangle, Circle, Freehand, Select, Clear)
|
||||
- **Center**: Search bar (expandable width)
|
||||
- **Right**: Navigation toggle, Traffic toggle, Zoom in/out buttons
|
||||
|
||||
**Map Overlays:**
|
||||
- Navigation panel: Toggleable overlay on top-left of map
|
||||
- Traffic legend: Bottom-left overlay (when traffic enabled)
|
||||
- Feature count: Bottom-left overlay (when features exist)
|
||||
|
||||
**Z-index hierarchy:**
|
||||
- `z-index: 20` - Dropdowns (search results, nav search results)
|
||||
- `z-index: 5` - Map overlays (navigation panel, traffic legend, feature count)
|
||||
|
||||
### Shadow DOM & Terra-Draw Drawing Fix
|
||||
Terra-draw's event listeners normally intercept map events through MapLibre's canvas element. In Shadow DOM contexts, these events are scoped locally and don't propagate correctly, causing terra-draw handlers to fail while MapLibre's drag handlers continue working.
|
||||
|
||||
|
||||
BIN
search-results-test.png
Normal file
|
After Width: | Height: | Size: 296 KiB |
@@ -1,9 +1,8 @@
|
||||
/**
|
||||
* This file is auto-generated by git.zone build tools.
|
||||
* Do not modify manually.
|
||||
* autocreated commitinfo by @push.rocks/commitinfo
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@design.estate/dees-catalog-geo',
|
||||
version: '1.0.0',
|
||||
description: 'A geospatial web components library with MapLibre GL JS maps and terra-draw drawing tools',
|
||||
};
|
||||
version: '1.1.0',
|
||||
description: 'A geospatial web components library with MapLibre GL JS maps and terra-draw drawing tools'
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ export const geoComponentStyles = css`
|
||||
*/
|
||||
export const mapContainerStyles = css`
|
||||
.map-container {
|
||||
--geo-overlay-inset: 12px;
|
||||
--geo-overlay-gap: 8px;
|
||||
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -37,6 +40,58 @@ export const mapContainerStyles = css`
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
/* Overlay grid for UI elements */
|
||||
.map-overlay {
|
||||
position: absolute;
|
||||
inset: var(--geo-overlay-inset);
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
grid-template-areas:
|
||||
"top-left . top-right"
|
||||
". . ."
|
||||
"bottom-left . bottom-right";
|
||||
gap: var(--geo-overlay-gap);
|
||||
}
|
||||
|
||||
.map-overlay > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.overlay-top-left {
|
||||
grid-area: top-left;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--geo-overlay-gap);
|
||||
}
|
||||
|
||||
.overlay-top-right {
|
||||
grid-area: top-right;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: var(--geo-overlay-gap);
|
||||
}
|
||||
|
||||
.overlay-bottom-left {
|
||||
grid-area: bottom-left;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--geo-overlay-gap);
|
||||
}
|
||||
|
||||
.overlay-bottom-right {
|
||||
grid-area: bottom-right;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: var(--geo-overlay-gap);
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
@@ -44,10 +99,6 @@ export const mapContainerStyles = css`
|
||||
*/
|
||||
export const toolbarStyles = css`
|
||||
.toolbar {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
@@ -57,6 +108,7 @@ export const toolbarStyles = css`
|
||||
border-radius: 8px;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.toolbar-group {
|
||||
@@ -111,15 +163,11 @@ export const toolbarStyles = css`
|
||||
*/
|
||||
export const searchStyles = css`
|
||||
.search-container {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 10;
|
||||
width: 280px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--geo-toolbar-bg, rgba(30, 30, 30, 0.9));
|
||||
@@ -127,7 +175,6 @@ export const searchStyles = css`
|
||||
border-radius: 8px;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.search-input-wrapper:focus-within {
|
||||
@@ -224,6 +271,7 @@ export const searchStyles = css`
|
||||
border-radius: 8px;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.search-results:empty {
|
||||
@@ -274,10 +322,6 @@ export const searchStyles = css`
|
||||
*/
|
||||
export const navigationStyles = css`
|
||||
.navigation-panel {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 60px;
|
||||
z-index: 10;
|
||||
width: 300px;
|
||||
background: var(--geo-toolbar-bg, rgba(30, 30, 30, 0.95));
|
||||
border: 1px solid var(--geo-toolbar-border, rgba(255, 255, 255, 0.1));
|
||||
@@ -285,6 +329,7 @@ export const navigationStyles = css`
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
overflow: hidden;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.nav-header {
|
||||
@@ -484,7 +529,7 @@ export const navigationStyles = css`
|
||||
background: var(--geo-toolbar-bg, rgba(30, 30, 30, 0.98));
|
||||
border: 1px solid var(--geo-toolbar-border, rgba(255, 255, 255, 0.1));
|
||||
border-radius: 6px;
|
||||
z-index: 100;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.nav-search-result {
|
||||
@@ -686,4 +731,330 @@ export const navigationStyles = css`
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Traffic-aware route info */
|
||||
.nav-traffic-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(255, 152, 0, 0.1);
|
||||
border-radius: 6px;
|
||||
margin: 8px 12px;
|
||||
}
|
||||
|
||||
.nav-traffic-info.low {
|
||||
background: rgba(0, 200, 83, 0.1);
|
||||
}
|
||||
|
||||
.nav-traffic-info.moderate {
|
||||
background: rgba(255, 235, 59, 0.15);
|
||||
}
|
||||
|
||||
.nav-traffic-info.heavy {
|
||||
background: rgba(255, 152, 0, 0.15);
|
||||
}
|
||||
|
||||
.nav-traffic-info.severe {
|
||||
background: rgba(244, 67, 54, 0.15);
|
||||
}
|
||||
|
||||
.nav-traffic-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-traffic-indicator.low {
|
||||
background: #00c853;
|
||||
}
|
||||
|
||||
.nav-traffic-indicator.moderate {
|
||||
background: #ffeb3b;
|
||||
}
|
||||
|
||||
.nav-traffic-indicator.heavy {
|
||||
background: #ff9800;
|
||||
}
|
||||
|
||||
.nav-traffic-indicator.severe {
|
||||
background: #f44336;
|
||||
}
|
||||
|
||||
.nav-traffic-text {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.nav-traffic-delay {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin-left: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Traffic control styles
|
||||
*/
|
||||
/**
|
||||
* Header toolbar styles for toolbar above map
|
||||
*/
|
||||
export const headerToolbarStyles = css`
|
||||
.geo-component {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
background: var(--geo-toolbar-bg, rgba(30, 30, 30, 0.95));
|
||||
border-bottom: 1px solid var(--geo-toolbar-border, rgba(255, 255, 255, 0.1));
|
||||
flex-shrink: 0;
|
||||
min-height: 52px;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar-center {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-toolbar .toolbar-divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
/* Header toolbar button styles */
|
||||
.header-toolbar .tool-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--geo-text, #fff);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.header-toolbar .tool-button:hover {
|
||||
background: var(--geo-tool-hover, rgba(255, 255, 255, 0.1));
|
||||
}
|
||||
|
||||
.header-toolbar .tool-button.active {
|
||||
background: var(--geo-tool-active, #0084ff);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.header-toolbar .tool-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.header-toolbar .tool-button svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* Search container in header */
|
||||
.header-toolbar .search-container {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
/* Map container takes remaining space */
|
||||
.geo-component .map-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Traffic control styles
|
||||
*/
|
||||
export const trafficStyles = css`
|
||||
.traffic-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.traffic-toggle-btn {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--geo-toolbar-bg, rgba(30, 30, 30, 0.9));
|
||||
border: 1px solid var(--geo-toolbar-border, rgba(255, 255, 255, 0.1));
|
||||
color: var(--geo-text, #fff);
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.traffic-toggle-btn:hover:not(:disabled) {
|
||||
background: var(--geo-tool-hover, rgba(255, 255, 255, 0.15));
|
||||
}
|
||||
|
||||
.traffic-toggle-btn.active {
|
||||
background: var(--geo-tool-active, #0084ff);
|
||||
border-color: var(--geo-tool-active, #0084ff);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.traffic-toggle-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.traffic-toggle-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.traffic-loading-indicator {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
right: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: var(--geo-toolbar-bg, rgba(30, 30, 30, 0.9));
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.traffic-loading-indicator svg {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.traffic-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: var(--geo-toolbar-bg, rgba(30, 30, 30, 0.9));
|
||||
border: 1px solid var(--geo-toolbar-border, rgba(255, 255, 255, 0.1));
|
||||
border-radius: 6px;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.traffic-status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #00c853;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.traffic-status-text {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.traffic-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: #f44336;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.traffic-error svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* Traffic Legend */
|
||||
.traffic-legend {
|
||||
padding: 10px 12px;
|
||||
background: var(--geo-toolbar-bg, rgba(30, 30, 30, 0.9));
|
||||
border: 1px solid var(--geo-toolbar-border, rgba(255, 255, 255, 0.1));
|
||||
border-radius: 8px;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.traffic-legend-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.traffic-legend-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.traffic-legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.traffic-legend-color {
|
||||
width: 16px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.traffic-legend-updated {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -274,6 +274,11 @@ export const demoFunc = () => html`
|
||||
.showNavigation=${true}
|
||||
></dees-geo-map>
|
||||
</div>
|
||||
|
||||
<p class="demo-description" style="margin-top: 12px; font-size: 12px; opacity: 0.7;">
|
||||
<strong>Traffic:</strong> To enable live traffic, set the <code>trafficApiKey</code> property with your HERE API key
|
||||
(free tier: 250k requests/month at <a href="https://developer.here.com" target="_blank" style="color: inherit;">developer.here.com</a>).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
css,
|
||||
} from '@design.estate/dees-element';
|
||||
import { DeesContextmenu } from '@design.estate/dees-catalog';
|
||||
import { geoComponentStyles, mapContainerStyles, toolbarStyles, searchStyles, navigationStyles } from '../../00componentstyles.js';
|
||||
import { geoComponentStyles, mapContainerStyles, toolbarStyles, searchStyles, navigationStyles, trafficStyles, headerToolbarStyles } from '../../00componentstyles.js';
|
||||
|
||||
// MapLibre imports
|
||||
import maplibregl from 'maplibre-gl';
|
||||
@@ -33,6 +33,8 @@ import { TerraDrawMapLibreGLAdapter } from 'terra-draw-maplibre-gl-adapter';
|
||||
import { renderIcon } from './geo-map.icons.js';
|
||||
import { SearchController, type INominatimResult, type IAddressSelectedEvent } from './geo-map.search.js';
|
||||
import { NavigationController, type TNavigationMode, type INavigationState, type IRouteCalculatedEvent } from './geo-map.navigation.js';
|
||||
import { TrafficController } from './geo-map.traffic.js';
|
||||
import { HereTrafficProvider, type ITrafficProvider } from './geo-map.traffic.providers.js';
|
||||
|
||||
// Re-export types for external consumers
|
||||
export type { INominatimResult, IAddressSelectedEvent } from './geo-map.search.js';
|
||||
@@ -44,6 +46,7 @@ export type {
|
||||
IOSRMStep,
|
||||
IRouteCalculatedEvent,
|
||||
} from './geo-map.navigation.js';
|
||||
export type { ITrafficProvider, ITrafficFlowData, ITrafficAwareRoute } from './geo-map.traffic.providers.js';
|
||||
|
||||
export type TDrawTool = 'polygon' | 'rectangle' | 'point' | 'linestring' | 'circle' | 'freehand' | 'select' | 'static';
|
||||
|
||||
@@ -114,6 +117,16 @@ export class DeesGeoMap extends DeesElement {
|
||||
@property({ type: String })
|
||||
accessor navigationMode: TNavigationMode = 'driving';
|
||||
|
||||
// Traffic properties
|
||||
@property({ type: Boolean })
|
||||
accessor showTraffic: boolean = false;
|
||||
|
||||
@property({ type: Object })
|
||||
accessor trafficProvider: ITrafficProvider | null = null;
|
||||
|
||||
@property({ type: String })
|
||||
accessor trafficApiKey: string = '';
|
||||
|
||||
// ─── State ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@state()
|
||||
@@ -125,9 +138,13 @@ export class DeesGeoMap extends DeesElement {
|
||||
@state()
|
||||
private accessor isMapReady: boolean = false;
|
||||
|
||||
@state()
|
||||
private accessor isNavigationOpen: boolean = true;
|
||||
|
||||
// Controllers
|
||||
private searchController: SearchController | null = null;
|
||||
private navigationController: NavigationController | null = null;
|
||||
private trafficController: TrafficController | null = null;
|
||||
|
||||
// ─── Styles ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -138,6 +155,8 @@ export class DeesGeoMap extends DeesElement {
|
||||
toolbarStyles,
|
||||
searchStyles,
|
||||
navigationStyles,
|
||||
trafficStyles,
|
||||
headerToolbarStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
@@ -163,51 +182,7 @@ export class DeesGeoMap extends DeesElement {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.toolbar-title {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
padding: 0 4px 4px;
|
||||
}
|
||||
|
||||
.tool-button {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tool-button::after {
|
||||
content: attr(title);
|
||||
position: absolute;
|
||||
left: calc(100% + 8px);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
padding: 4px 8px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease, visibility 0.15s ease;
|
||||
}
|
||||
|
||||
.tool-button:hover::after {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.feature-count {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
left: 12px;
|
||||
z-index: 10;
|
||||
padding: 6px 12px;
|
||||
background: rgba(30, 30, 30, 0.9);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
@@ -217,22 +192,6 @@ export class DeesGeoMap extends DeesElement {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.zoom-controls {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
right: 12px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
background: rgba(30, 30, 30, 0.9);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -269,6 +228,23 @@ export class DeesGeoMap extends DeesElement {
|
||||
if (changedProperties.has('navigationMode') && this.navigationController) {
|
||||
this.navigationController.navigationMode = this.navigationMode;
|
||||
}
|
||||
// Traffic property changes
|
||||
if (changedProperties.has('showTraffic') && this.trafficController) {
|
||||
if (this.showTraffic) {
|
||||
this.trafficController.enable();
|
||||
} else {
|
||||
this.trafficController.disable();
|
||||
}
|
||||
}
|
||||
if (changedProperties.has('trafficProvider') && this.trafficController && this.trafficProvider) {
|
||||
this.trafficController.setProvider(this.trafficProvider);
|
||||
}
|
||||
if (changedProperties.has('trafficApiKey') && this.trafficController && this.trafficApiKey) {
|
||||
// Auto-configure HERE provider if API key is provided
|
||||
const hereProvider = new HereTrafficProvider();
|
||||
hereProvider.configure({ apiKey: this.trafficApiKey });
|
||||
this.trafficController.setProvider(hereProvider);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Controller Initialization ──────────────────────────────────────────────
|
||||
@@ -312,8 +288,30 @@ export class DeesGeoMap extends DeesElement {
|
||||
},
|
||||
onRequestUpdate: () => this.requestUpdate(),
|
||||
getMap: () => this.map,
|
||||
// Connect traffic controller for traffic-aware routing
|
||||
getTrafficRoute: async (start, end, mode) => {
|
||||
if (this.trafficController?.supportsTrafficRouting()) {
|
||||
return this.trafficController.fetchRouteWithTraffic(start, end, mode);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
this.navigationController.navigationMode = this.navigationMode;
|
||||
|
||||
// Initialize traffic controller
|
||||
this.trafficController = new TrafficController({
|
||||
onRequestUpdate: () => this.requestUpdate(),
|
||||
getMap: () => this.map,
|
||||
});
|
||||
|
||||
// Configure traffic provider if API key or provider is set
|
||||
if (this.trafficProvider) {
|
||||
this.trafficController.setProvider(this.trafficProvider);
|
||||
} else if (this.trafficApiKey) {
|
||||
const hereProvider = new HereTrafficProvider();
|
||||
hereProvider.configure({ apiKey: this.trafficApiKey });
|
||||
this.trafficController.setProvider(hereProvider);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Map Initialization ─────────────────────────────────────────────────────
|
||||
@@ -342,6 +340,12 @@ export class DeesGeoMap extends DeesElement {
|
||||
this.map!.setProjection({ type: this.projection });
|
||||
|
||||
this.initializeTerraDraw();
|
||||
|
||||
// Enable traffic if configured
|
||||
if (this.showTraffic && this.trafficController) {
|
||||
this.trafficController.enable();
|
||||
}
|
||||
|
||||
this.dispatchEvent(new CustomEvent('map-ready', { detail: { map: this.map } }));
|
||||
});
|
||||
|
||||
@@ -353,6 +357,11 @@ export class DeesGeoMap extends DeesElement {
|
||||
zoom: this.map?.getZoom(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Refresh traffic data when map moves
|
||||
if (this.trafficController) {
|
||||
this.trafficController.handleMapMoveEnd();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle clicks for navigation point selection
|
||||
@@ -672,6 +681,61 @@ export class DeesGeoMap extends DeesElement {
|
||||
return this.navigationController?.navigationState ?? null;
|
||||
}
|
||||
|
||||
// ─── Traffic Public Methods ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Enable traffic visualization
|
||||
*/
|
||||
public enableTraffic(): void {
|
||||
this.showTraffic = true;
|
||||
this.trafficController?.enable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable traffic visualization
|
||||
*/
|
||||
public disableTraffic(): void {
|
||||
this.showTraffic = false;
|
||||
this.trafficController?.disable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle traffic visualization
|
||||
*/
|
||||
public toggleTraffic(): void {
|
||||
this.showTraffic = !this.showTraffic;
|
||||
this.trafficController?.toggle();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh traffic data
|
||||
*/
|
||||
public async refreshTraffic(): Promise<void> {
|
||||
await this.trafficController?.refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set traffic provider
|
||||
*/
|
||||
public setTrafficProvider(provider: ITrafficProvider): void {
|
||||
this.trafficProvider = provider;
|
||||
this.trafficController?.setProvider(provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if traffic-aware routing is available
|
||||
*/
|
||||
public supportsTrafficRouting(): boolean {
|
||||
return this.trafficController?.supportsTrafficRouting() ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get traffic controller for advanced usage
|
||||
*/
|
||||
public getTrafficController(): TrafficController | null {
|
||||
return this.trafficController;
|
||||
}
|
||||
|
||||
// ─── Private Methods ────────────────────────────────────────────────────────
|
||||
|
||||
private ensureMaplibreCssLoaded() {
|
||||
@@ -690,8 +754,9 @@ export class DeesGeoMap extends DeesElement {
|
||||
this.draw.stop();
|
||||
this.draw = null;
|
||||
}
|
||||
// Clean up navigation controller
|
||||
// Clean up controllers
|
||||
this.navigationController?.cleanup();
|
||||
this.trafficController?.cleanup();
|
||||
|
||||
if (this.map) {
|
||||
this.map.remove();
|
||||
@@ -723,6 +788,8 @@ export class DeesGeoMap extends DeesElement {
|
||||
|
||||
private handleMapContextMenu(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
const hasTrafficProvider = this.trafficController?.provider?.isConfigured ?? false;
|
||||
|
||||
DeesContextmenu.openContextMenuWithOptions(e, [
|
||||
{
|
||||
name: this.dragToDraw ? '✓ Drag to Draw' : 'Drag to Draw',
|
||||
@@ -739,6 +806,19 @@ export class DeesGeoMap extends DeesElement {
|
||||
},
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
name: this.showTraffic ? '✓ Show Traffic' : 'Show Traffic',
|
||||
iconName: 'lucide:traffic-cone',
|
||||
action: async () => {
|
||||
if (hasTrafficProvider) {
|
||||
this.toggleTraffic();
|
||||
} else {
|
||||
console.warn('[dees-geo-map] No traffic provider configured. Set trafficApiKey or trafficProvider property.');
|
||||
}
|
||||
},
|
||||
disabled: !hasTrafficProvider,
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
name: 'Clear All Features',
|
||||
iconName: 'lucide:trash2',
|
||||
@@ -752,26 +832,88 @@ export class DeesGeoMap extends DeesElement {
|
||||
]);
|
||||
}
|
||||
|
||||
private toggleNavigation(): void {
|
||||
this.isNavigationOpen = !this.isNavigationOpen;
|
||||
}
|
||||
|
||||
// ─── Render ─────────────────────────────────────────────────────────────────
|
||||
|
||||
public render(): TemplateResult {
|
||||
const featureCount = this.draw?.getSnapshot().length || 0;
|
||||
const hasTrafficProvider = this.trafficController?.provider?.isConfigured ?? false;
|
||||
const showTrafficControls = Boolean(hasTrafficProvider || this.trafficApiKey || this.trafficProvider);
|
||||
|
||||
return html`
|
||||
<div class="geo-component">
|
||||
<!-- Header Toolbar Above Map -->
|
||||
${this.renderHeaderToolbar(showTrafficControls)}
|
||||
|
||||
<!-- Map Container -->
|
||||
<div class="map-container" @contextmenu=${(e: MouseEvent) => this.handleMapContextMenu(e)}>
|
||||
<div class="map-wrapper"></div>
|
||||
|
||||
${this.showToolbar ? this.renderToolbar() : ''}
|
||||
${this.showSearch && this.searchController ? this.searchController.render() : ''}
|
||||
${this.showNavigation && this.navigationController ? this.navigationController.render() : ''}
|
||||
<div class="map-overlay">
|
||||
<!-- Top Left: Navigation Panel (toggleable) -->
|
||||
<div class="overlay-top-left">
|
||||
${this.showNavigation && this.isNavigationOpen && this.navigationController
|
||||
? this.navigationController.render()
|
||||
: ''}
|
||||
</div>
|
||||
|
||||
<!-- Top Right: Empty now (controls moved to header) -->
|
||||
<div class="overlay-top-right"></div>
|
||||
|
||||
<!-- Bottom Left: Traffic Legend + Feature Count -->
|
||||
<div class="overlay-bottom-left">
|
||||
${this.showTraffic && this.trafficController ? this.trafficController.renderLegend() : ''}
|
||||
${featureCount > 0 ? html`
|
||||
<div class="feature-count">
|
||||
${featureCount} feature${featureCount !== 1 ? 's' : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<div class="zoom-controls">
|
||||
<!-- Bottom Right: Empty now (zoom moved to header) -->
|
||||
<div class="overlay-bottom-right"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderHeaderToolbar(showTrafficControls: boolean): TemplateResult {
|
||||
return html`
|
||||
<div class="header-toolbar">
|
||||
<!-- Left: Draw Tools -->
|
||||
<div class="toolbar-left">
|
||||
${this.showToolbar ? html`
|
||||
${this.renderDrawTools()}
|
||||
<div class="toolbar-divider"></div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<!-- Center: Search Bar -->
|
||||
<div class="toolbar-center">
|
||||
${this.showSearch && this.searchController
|
||||
? this.searchController.render()
|
||||
: ''}
|
||||
</div>
|
||||
|
||||
<!-- Right: Navigation Toggle + Traffic Toggle + Zoom Controls -->
|
||||
<div class="toolbar-right">
|
||||
${this.showNavigation ? html`
|
||||
<button
|
||||
class="tool-button ${this.isNavigationOpen ? 'active' : ''}"
|
||||
title="Navigation"
|
||||
@click=${() => this.toggleNavigation()}
|
||||
>
|
||||
${renderIcon('navigation')}
|
||||
</button>
|
||||
` : ''}
|
||||
${showTrafficControls && this.trafficController
|
||||
? this.trafficController.render()
|
||||
: ''}
|
||||
<div class="toolbar-divider"></div>
|
||||
<button
|
||||
class="tool-button"
|
||||
title="Zoom in"
|
||||
@@ -791,7 +933,7 @@ export class DeesGeoMap extends DeesElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderToolbar(): TemplateResult {
|
||||
private renderDrawTools(): TemplateResult {
|
||||
const tools: { id: TDrawTool; icon: string; label: string }[] = [
|
||||
{ id: 'point', icon: 'point', label: 'Point' },
|
||||
{ id: 'linestring', icon: 'line', label: 'Line' },
|
||||
@@ -802,9 +944,6 @@ export class DeesGeoMap extends DeesElement {
|
||||
];
|
||||
|
||||
return html`
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-title">Draw</div>
|
||||
<div class="toolbar-group">
|
||||
${tools.map(tool => html`
|
||||
<button
|
||||
class="tool-button ${this.activeTool === tool.id ? 'active' : ''}"
|
||||
@@ -815,12 +954,7 @@ export class DeesGeoMap extends DeesElement {
|
||||
${renderIcon(tool.icon)}
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
|
||||
<div class="toolbar-divider"></div>
|
||||
<div class="toolbar-title">Edit</div>
|
||||
|
||||
<div class="toolbar-group">
|
||||
<button
|
||||
class="tool-button ${this.activeTool === 'select' ? 'active' : ''}"
|
||||
title="Select & Edit"
|
||||
@@ -837,8 +971,7 @@ export class DeesGeoMap extends DeesElement {
|
||||
>
|
||||
${renderIcon('trash')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -36,6 +36,11 @@ export const GEO_MAP_ICONS: Record<string, TemplateResult> = {
|
||||
clock: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>`,
|
||||
ruler: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.3 8.7 8.7 21.3c-1 1-2.5 1-3.4 0l-2.6-2.6c-1-1-1-2.5 0-3.4L15.3 2.7c1-1 2.5-1 3.4 0l2.6 2.6c1 1 1 2.5 0 3.4Z"/><path d="m7.5 10.5 2 2"/><path d="m10.5 7.5 2 2"/><path d="m13.5 4.5 2 2"/><path d="m4.5 13.5 2 2"/></svg>`,
|
||||
error: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4"/><path d="M12 16h.01"/></svg>`,
|
||||
|
||||
// Traffic
|
||||
traffic: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="3" width="12" height="18" rx="2"/><circle cx="12" cy="7" r="1.5" fill="currentColor"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/><circle cx="12" cy="17" r="1.5" fill="currentColor"/></svg>`,
|
||||
trafficLight: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 17H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h5"/><path d="M15 7h5a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2h-5"/><path d="M12 4v4"/><path d="M12 16v4"/><circle cx="12" cy="12" r="3"/></svg>`,
|
||||
congestion: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h4"/><path d="M9 12h6"/><path d="M18 12h4"/><circle cx="6" cy="12" r="2" fill="currentColor"/><circle cx="12" cy="12" r="2" fill="currentColor"/><circle cx="18" cy="12" r="2" fill="currentColor"/></svg>`,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@ import { html, type TemplateResult } from '@design.estate/dees-element';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import { renderIcon } from './geo-map.icons.js';
|
||||
import { type INominatimResult } from './geo-map.search.js';
|
||||
import type { ITrafficAwareRoute } from './geo-map.traffic.providers.js';
|
||||
|
||||
// ─── Navigation/Routing Types ────────────────────────────────────────────────
|
||||
|
||||
@@ -39,6 +40,7 @@ export interface INavigationState {
|
||||
startAddress: string;
|
||||
endAddress: string;
|
||||
route: IOSRMRoute | null;
|
||||
trafficRoute: ITrafficAwareRoute | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
@@ -57,6 +59,12 @@ export interface INavigationControllerCallbacks {
|
||||
onRouteCalculated: (event: IRouteCalculatedEvent) => void;
|
||||
onRequestUpdate: () => void;
|
||||
getMap: () => maplibregl.Map | null;
|
||||
/** Optional callback to fetch traffic-aware route */
|
||||
getTrafficRoute?: (
|
||||
start: [number, number],
|
||||
end: [number, number],
|
||||
mode: TNavigationMode
|
||||
) => Promise<ITrafficAwareRoute | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,6 +79,7 @@ export class NavigationController {
|
||||
startAddress: '',
|
||||
endAddress: '',
|
||||
route: null,
|
||||
trafficRoute: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
@@ -154,20 +163,30 @@ export class NavigationController {
|
||||
...this.navigationState,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
trafficRoute: null,
|
||||
};
|
||||
this.callbacks.onRequestUpdate();
|
||||
|
||||
try {
|
||||
const route = await this.fetchRoute(startPoint, endPoint, this.navigationMode);
|
||||
// Fetch both regular route and traffic-aware route in parallel
|
||||
const [route, trafficRoute] = await Promise.all([
|
||||
this.fetchRoute(startPoint, endPoint, this.navigationMode),
|
||||
this.callbacks.getTrafficRoute
|
||||
? this.callbacks.getTrafficRoute(startPoint, endPoint, this.navigationMode)
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
|
||||
if (route) {
|
||||
this.navigationState = {
|
||||
...this.navigationState,
|
||||
route,
|
||||
trafficRoute,
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
this.renderRouteOnMap(route);
|
||||
// Use traffic route geometry if available, otherwise use regular route
|
||||
const routeToRender = trafficRoute || route;
|
||||
this.renderRouteOnMap(routeToRender);
|
||||
|
||||
// Dispatch route-calculated event
|
||||
this.callbacks.onRouteCalculated({
|
||||
@@ -178,7 +197,7 @@ export class NavigationController {
|
||||
});
|
||||
|
||||
// Fit map to route bounds
|
||||
this.fitToRoute(route);
|
||||
this.fitToRoute(routeToRender);
|
||||
}
|
||||
} catch (error) {
|
||||
this.navigationState = {
|
||||
@@ -244,6 +263,7 @@ export class NavigationController {
|
||||
startAddress: '',
|
||||
endAddress: '',
|
||||
route: null,
|
||||
trafficRoute: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
@@ -679,6 +699,19 @@ export class NavigationController {
|
||||
return icons[key] || icons[type] || '➡';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get congestion label for display
|
||||
*/
|
||||
public getCongestionLabel(level: 'low' | 'moderate' | 'heavy' | 'severe'): string {
|
||||
const labels = {
|
||||
low: 'Light traffic',
|
||||
moderate: 'Moderate traffic',
|
||||
heavy: 'Heavy traffic',
|
||||
severe: 'Severe congestion',
|
||||
};
|
||||
return labels[level];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format step instruction for display
|
||||
*/
|
||||
@@ -754,13 +787,14 @@ export class NavigationController {
|
||||
|
||||
/**
|
||||
* Render the navigation panel
|
||||
* @param extraClass - Optional CSS class to add to the panel for positioning
|
||||
*/
|
||||
public render(): TemplateResult {
|
||||
public render(extraClass?: string): TemplateResult {
|
||||
const { route, isLoading, error, startPoint, endPoint } = this.navigationState;
|
||||
const canCalculate = startPoint && endPoint && !isLoading;
|
||||
|
||||
return html`
|
||||
<div class="navigation-panel">
|
||||
<div class="navigation-panel ${extraClass || ''}">
|
||||
<div class="nav-header">
|
||||
<div class="nav-header-icon">${renderIcon('navigation')}</div>
|
||||
<span class="nav-header-title">Navigation</span>
|
||||
@@ -833,10 +867,22 @@ export class NavigationController {
|
||||
</div>
|
||||
<div class="nav-summary-item">
|
||||
${renderIcon('clock')}
|
||||
<span>${this.formatDuration(route.duration)}</span>
|
||||
<span>${this.formatDuration(this.navigationState.trafficRoute?.duration ?? route.duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this.navigationState.trafficRoute ? html`
|
||||
<div class="nav-traffic-info ${this.navigationState.trafficRoute.congestionLevel}">
|
||||
<span class="nav-traffic-indicator ${this.navigationState.trafficRoute.congestionLevel}"></span>
|
||||
<span class="nav-traffic-text">${this.getCongestionLabel(this.navigationState.trafficRoute.congestionLevel)}</span>
|
||||
${this.navigationState.trafficRoute.duration > this.navigationState.trafficRoute.durationWithoutTraffic ? html`
|
||||
<span class="nav-traffic-delay">
|
||||
+${this.formatDuration(this.navigationState.trafficRoute.duration - this.navigationState.trafficRoute.durationWithoutTraffic)} due to traffic
|
||||
</span>
|
||||
` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="nav-steps">
|
||||
${this.renderTurnByTurn(route)}
|
||||
</div>
|
||||
|
||||
@@ -254,10 +254,11 @@ export class SearchController {
|
||||
|
||||
/**
|
||||
* Render the search component
|
||||
* @param extraClass - Optional CSS class to add to the container for positioning
|
||||
*/
|
||||
public render(containerRef?: Element | null): TemplateResult {
|
||||
public render(extraClass?: string): TemplateResult {
|
||||
return html`
|
||||
<div class="search-container">
|
||||
<div class="search-container ${extraClass || ''}">
|
||||
<div class="search-input-wrapper">
|
||||
<div class="search-icon">
|
||||
${renderIcon('search')}
|
||||
|
||||
@@ -0,0 +1,766 @@
|
||||
import type { TNavigationMode, IOSRMLeg } from './geo-map.navigation.js';
|
||||
|
||||
// ─── Traffic Data Types ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A segment of road with traffic flow data
|
||||
*/
|
||||
export interface ITrafficFlowSegment {
|
||||
/** GeoJSON LineString geometry for the segment */
|
||||
geometry: GeoJSON.LineString;
|
||||
/** Congestion level from 0 (free flow) to 1 (blocked) */
|
||||
congestion: number;
|
||||
/** Current speed in km/h */
|
||||
speed: number;
|
||||
/** Free-flow (normal) speed in km/h */
|
||||
freeFlowSpeed: number;
|
||||
/** Confidence/quality of the data (0-1) */
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Traffic flow data for a geographic area
|
||||
*/
|
||||
export interface ITrafficFlowData {
|
||||
/** Array of road segments with traffic data */
|
||||
segments: ITrafficFlowSegment[];
|
||||
/** Timestamp when the data was fetched */
|
||||
timestamp: Date;
|
||||
/** Bounding box [west, south, east, north] */
|
||||
bounds: [number, number, number, number];
|
||||
}
|
||||
|
||||
/**
|
||||
* A route with traffic-aware duration estimates
|
||||
*/
|
||||
export interface ITrafficAwareRoute {
|
||||
/** Route geometry */
|
||||
geometry: GeoJSON.LineString;
|
||||
/** Total distance in meters */
|
||||
distance: number;
|
||||
/** Duration with current traffic in seconds */
|
||||
duration: number;
|
||||
/** Duration without traffic (free-flow) in seconds */
|
||||
durationWithoutTraffic: number;
|
||||
/** Overall congestion level for the route */
|
||||
congestionLevel: 'low' | 'moderate' | 'heavy' | 'severe';
|
||||
/** Route legs with turn-by-turn directions */
|
||||
legs: IOSRMLeg[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration options for traffic providers
|
||||
*/
|
||||
export interface ITrafficProviderConfig {
|
||||
/** API key (for providers that require authentication) */
|
||||
apiKey?: string;
|
||||
/** Server URL (for self-hosted providers) */
|
||||
serverUrl?: string;
|
||||
/** Refresh interval in milliseconds */
|
||||
refreshInterval?: number;
|
||||
/** Whether to include traffic incidents */
|
||||
includeIncidents?: boolean;
|
||||
/** Additional provider-specific options */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ─── Traffic Provider Interface ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Interface for traffic data providers
|
||||
*/
|
||||
export interface ITrafficProvider {
|
||||
/** Provider name */
|
||||
readonly name: string;
|
||||
|
||||
/** Whether the provider is configured and ready to use */
|
||||
readonly isConfigured: boolean;
|
||||
|
||||
/**
|
||||
* Fetch traffic flow data for a geographic area
|
||||
* @param bounds - [west, south, east, north] bounding box
|
||||
* @returns Traffic flow data or null if unavailable
|
||||
*/
|
||||
fetchTrafficFlow(bounds: [number, number, number, number]): Promise<ITrafficFlowData | null>;
|
||||
|
||||
/**
|
||||
* Fetch a route with traffic-aware duration estimates (optional)
|
||||
* @param start - Start coordinates [lng, lat]
|
||||
* @param end - End coordinates [lng, lat]
|
||||
* @param mode - Navigation mode
|
||||
* @returns Traffic-aware route or null if not supported
|
||||
*/
|
||||
fetchRouteWithTraffic?(
|
||||
start: [number, number],
|
||||
end: [number, number],
|
||||
mode: TNavigationMode
|
||||
): Promise<ITrafficAwareRoute | null>;
|
||||
|
||||
/**
|
||||
* Configure the provider with options
|
||||
* @param options - Provider configuration
|
||||
*/
|
||||
configure(options: ITrafficProviderConfig): void;
|
||||
}
|
||||
|
||||
// ─── HERE Traffic Provider ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Traffic provider using HERE Traffic API v7 (freemium)
|
||||
*
|
||||
* Free tier includes:
|
||||
* - 250,000 transactions/month
|
||||
* - Traffic flow and incidents
|
||||
* - No credit card required
|
||||
*
|
||||
* @see https://developer.here.com/documentation/traffic-api/dev_guide/index.html
|
||||
*/
|
||||
export class HereTrafficProvider implements ITrafficProvider {
|
||||
public readonly name = 'HERE Traffic API';
|
||||
private apiKey: string = '';
|
||||
private includeIncidents: boolean = true;
|
||||
|
||||
public get isConfigured(): boolean {
|
||||
return this.apiKey.length > 0;
|
||||
}
|
||||
|
||||
public configure(options: ITrafficProviderConfig): void {
|
||||
if (options.apiKey) {
|
||||
this.apiKey = options.apiKey;
|
||||
}
|
||||
if (typeof options.includeIncidents === 'boolean') {
|
||||
this.includeIncidents = options.includeIncidents;
|
||||
}
|
||||
}
|
||||
|
||||
public async fetchTrafficFlow(
|
||||
bounds: [number, number, number, number]
|
||||
): Promise<ITrafficFlowData | null> {
|
||||
if (!this.isConfigured) {
|
||||
console.warn('[HereTrafficProvider] No API key configured');
|
||||
return null;
|
||||
}
|
||||
|
||||
const [west, south, east, north] = bounds;
|
||||
|
||||
// HERE Traffic Flow API endpoint
|
||||
// Format: bbox=west,south,east,north
|
||||
const bbox = `${west},${south},${east},${north}`;
|
||||
const url = `https://data.traffic.hereapi.com/v7/flow?in=bbox:${bbox}&apiKey=${this.apiKey}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
console.error('[HereTrafficProvider] Invalid or expired API key');
|
||||
} else if (response.status === 429) {
|
||||
console.warn('[HereTrafficProvider] Rate limit exceeded');
|
||||
} else {
|
||||
console.error(`[HereTrafficProvider] API error: ${response.status}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return this.parseHereTrafficFlow(data, bounds);
|
||||
} catch (error) {
|
||||
console.error('[HereTrafficProvider] Fetch error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse HERE Traffic API response into our standard format
|
||||
*/
|
||||
private parseHereTrafficFlow(
|
||||
data: IHereTrafficFlowResponse,
|
||||
bounds: [number, number, number, number]
|
||||
): ITrafficFlowData {
|
||||
const segments: ITrafficFlowSegment[] = [];
|
||||
|
||||
if (data.results) {
|
||||
for (const result of data.results) {
|
||||
if (!result.location?.shape?.links) continue;
|
||||
|
||||
for (const link of result.location.shape.links) {
|
||||
// Parse the polyline points
|
||||
const coordinates = this.parseHerePolyline(link.points);
|
||||
if (coordinates.length < 2) continue;
|
||||
|
||||
// Get traffic data from currentFlow
|
||||
const flow = result.currentFlow;
|
||||
const freeFlow = flow?.freeFlow || flow?.speed || 50;
|
||||
const currentSpeed = flow?.speed || freeFlow;
|
||||
const jamFactor = flow?.jamFactor || 0; // 0-10 scale
|
||||
|
||||
segments.push({
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates,
|
||||
},
|
||||
congestion: Math.min(jamFactor / 10, 1), // Normalize to 0-1
|
||||
speed: currentSpeed,
|
||||
freeFlowSpeed: freeFlow,
|
||||
confidence: flow?.confidence || 0.5,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
segments,
|
||||
timestamp: new Date(),
|
||||
bounds,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse HERE polyline format to coordinates array
|
||||
* HERE uses lat,lng format, we need lng,lat
|
||||
*/
|
||||
private parseHerePolyline(points: IHerePoint[]): [number, number][] {
|
||||
return points.map(point => [point.lng, point.lat] as [number, number]);
|
||||
}
|
||||
|
||||
public async fetchRouteWithTraffic(
|
||||
start: [number, number],
|
||||
end: [number, number],
|
||||
mode: TNavigationMode
|
||||
): Promise<ITrafficAwareRoute | null> {
|
||||
if (!this.isConfigured) {
|
||||
console.warn('[HereTrafficProvider] No API key configured');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Map mode to HERE transport mode
|
||||
const transportMode = mode === 'cycling' ? 'bicycle'
|
||||
: mode === 'walking' ? 'pedestrian'
|
||||
: 'car';
|
||||
|
||||
// HERE Routing API v8 with traffic
|
||||
const url = new URL('https://router.hereapi.com/v8/routes');
|
||||
url.searchParams.set('apiKey', this.apiKey);
|
||||
url.searchParams.set('origin', `${start[1]},${start[0]}`); // HERE uses lat,lng
|
||||
url.searchParams.set('destination', `${end[1]},${end[0]}`);
|
||||
url.searchParams.set('transportMode', transportMode);
|
||||
url.searchParams.set('return', 'polyline,summary,turnByTurnActions');
|
||||
|
||||
// Include traffic for driving mode
|
||||
if (mode === 'driving') {
|
||||
url.searchParams.set('departureTime', 'now'); // Enables live traffic
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString());
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`[HereTrafficProvider] Routing error: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json() as IHereRoutingResponse;
|
||||
return this.parseHereRoute(data);
|
||||
} catch (error) {
|
||||
console.error('[HereTrafficProvider] Routing fetch error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse HERE Routing API response
|
||||
*/
|
||||
private parseHereRoute(data: IHereRoutingResponse): ITrafficAwareRoute | null {
|
||||
if (!data.routes || data.routes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const route = data.routes[0];
|
||||
const section = route.sections?.[0];
|
||||
if (!section) return null;
|
||||
|
||||
// Decode the polyline
|
||||
const coordinates = this.decodeFlexiblePolyline(section.polyline);
|
||||
|
||||
// Calculate congestion level based on delay
|
||||
const baseDuration = section.summary?.baseDuration || section.summary?.duration || 0;
|
||||
const duration = section.summary?.duration || baseDuration;
|
||||
const delayRatio = baseDuration > 0 ? (duration - baseDuration) / baseDuration : 0;
|
||||
|
||||
let congestionLevel: 'low' | 'moderate' | 'heavy' | 'severe';
|
||||
if (delayRatio < 0.1) congestionLevel = 'low';
|
||||
else if (delayRatio < 0.3) congestionLevel = 'moderate';
|
||||
else if (delayRatio < 0.5) congestionLevel = 'heavy';
|
||||
else congestionLevel = 'severe';
|
||||
|
||||
// Convert HERE actions to OSRM-style legs/steps
|
||||
const legs = this.convertToOsrmLegs(section);
|
||||
|
||||
return {
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates,
|
||||
},
|
||||
distance: section.summary?.length || 0,
|
||||
duration,
|
||||
durationWithoutTraffic: baseDuration,
|
||||
congestionLevel,
|
||||
legs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode HERE's flexible polyline format
|
||||
* @see https://github.com/heremaps/flexible-polyline
|
||||
*/
|
||||
private decodeFlexiblePolyline(encoded: string): [number, number][] {
|
||||
// Simplified decoder - for full support, use @here/flexpolyline package
|
||||
const coordinates: [number, number][] = [];
|
||||
|
||||
let index = 0;
|
||||
let lat = 0;
|
||||
let lng = 0;
|
||||
|
||||
// Skip header byte (version and precision info)
|
||||
const header = this.decodeUnsignedValue(encoded, index);
|
||||
index = header.nextIndex;
|
||||
const precision = Math.pow(10, header.value & 15);
|
||||
|
||||
while (index < encoded.length) {
|
||||
const latResult = this.decodeSignedValue(encoded, index);
|
||||
index = latResult.nextIndex;
|
||||
lat += latResult.value;
|
||||
|
||||
const lngResult = this.decodeSignedValue(encoded, index);
|
||||
index = lngResult.nextIndex;
|
||||
lng += lngResult.value;
|
||||
|
||||
coordinates.push([lng / precision, lat / precision]);
|
||||
}
|
||||
|
||||
return coordinates;
|
||||
}
|
||||
|
||||
private decodeUnsignedValue(encoded: string, startIndex: number): { value: number; nextIndex: number } {
|
||||
let result = 0;
|
||||
let shift = 0;
|
||||
let index = startIndex;
|
||||
|
||||
while (index < encoded.length) {
|
||||
const char = encoded.charCodeAt(index) - 45;
|
||||
index++;
|
||||
result |= (char & 31) << shift;
|
||||
if (char < 32) break;
|
||||
shift += 5;
|
||||
}
|
||||
|
||||
return { value: result, nextIndex: index };
|
||||
}
|
||||
|
||||
private decodeSignedValue(encoded: string, startIndex: number): { value: number; nextIndex: number } {
|
||||
const { value, nextIndex } = this.decodeUnsignedValue(encoded, startIndex);
|
||||
return {
|
||||
value: (value & 1) ? ~(value >> 1) : (value >> 1),
|
||||
nextIndex,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert HERE routing actions to OSRM-style legs
|
||||
*/
|
||||
private convertToOsrmLegs(section: IHereRouteSection): IOSRMLeg[] {
|
||||
const steps = (section.turnByTurnActions || []).map(action => ({
|
||||
geometry: { type: 'LineString' as const, coordinates: [] as [number, number][] },
|
||||
maneuver: {
|
||||
type: this.mapHereActionToOsrm(action.action),
|
||||
modifier: action.direction?.toLowerCase(),
|
||||
location: [action.offset || 0, 0] as [number, number], // Simplified
|
||||
},
|
||||
name: action.currentRoad?.name?.[0]?.value || action.nextRoad?.name?.[0]?.value || '',
|
||||
distance: action.length || 0,
|
||||
duration: action.duration || 0,
|
||||
driving_side: 'right',
|
||||
}));
|
||||
|
||||
return [{
|
||||
steps,
|
||||
distance: section.summary?.length || 0,
|
||||
duration: section.summary?.duration || 0,
|
||||
}];
|
||||
}
|
||||
|
||||
/**
|
||||
* Map HERE action types to OSRM maneuver types
|
||||
*/
|
||||
private mapHereActionToOsrm(action: string): string {
|
||||
const mapping: Record<string, string> = {
|
||||
'depart': 'depart',
|
||||
'arrive': 'arrive',
|
||||
'turn': 'turn',
|
||||
'continue': 'continue',
|
||||
'roundaboutEnter': 'roundabout',
|
||||
'roundaboutExit': 'roundabout',
|
||||
'merge': 'merge',
|
||||
'fork': 'fork',
|
||||
'uturn': 'turn',
|
||||
};
|
||||
return mapping[action] || action;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Valhalla Traffic Provider ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Traffic provider for self-hosted Valhalla servers
|
||||
*
|
||||
* Requires:
|
||||
* - A running Valhalla server with traffic data enabled
|
||||
* - Traffic data in Valhalla's expected format
|
||||
*
|
||||
* @see https://valhalla.github.io/valhalla/
|
||||
*/
|
||||
export class ValhallaTrafficProvider implements ITrafficProvider {
|
||||
public readonly name = 'Valhalla (Self-Hosted)';
|
||||
private serverUrl: string = '';
|
||||
private trafficDataUrl: string = '';
|
||||
|
||||
public get isConfigured(): boolean {
|
||||
return this.serverUrl.length > 0;
|
||||
}
|
||||
|
||||
public configure(options: ITrafficProviderConfig): void {
|
||||
if (options.serverUrl) {
|
||||
this.serverUrl = options.serverUrl.replace(/\/$/, ''); // Remove trailing slash
|
||||
}
|
||||
if (options.trafficDataUrl && typeof options.trafficDataUrl === 'string') {
|
||||
this.trafficDataUrl = options.trafficDataUrl;
|
||||
}
|
||||
}
|
||||
|
||||
public async fetchTrafficFlow(
|
||||
bounds: [number, number, number, number]
|
||||
): Promise<ITrafficFlowData | null> {
|
||||
if (!this.isConfigured) {
|
||||
console.warn('[ValhallaTrafficProvider] No server URL configured');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Valhalla doesn't have a direct traffic flow API like HERE
|
||||
// This would require custom traffic data endpoint setup
|
||||
if (!this.trafficDataUrl) {
|
||||
console.info('[ValhallaTrafficProvider] Traffic flow visualization requires trafficDataUrl');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const [west, south, east, north] = bounds;
|
||||
const url = `${this.trafficDataUrl}?bbox=${west},${south},${east},${north}`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`[ValhallaTrafficProvider] Traffic data error: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return this.parseValhallaTrafficData(data, bounds);
|
||||
} catch (error) {
|
||||
console.error('[ValhallaTrafficProvider] Fetch error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse custom traffic data format (user-defined schema)
|
||||
*/
|
||||
private parseValhallaTrafficData(
|
||||
data: IValhallaTrafficData,
|
||||
bounds: [number, number, number, number]
|
||||
): ITrafficFlowData {
|
||||
const segments: ITrafficFlowSegment[] = [];
|
||||
|
||||
if (data.segments) {
|
||||
for (const seg of data.segments) {
|
||||
segments.push({
|
||||
geometry: seg.geometry,
|
||||
congestion: seg.congestion || 0,
|
||||
speed: seg.speed || 0,
|
||||
freeFlowSpeed: seg.freeFlowSpeed || seg.speed || 50,
|
||||
confidence: seg.confidence || 0.5,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
segments,
|
||||
timestamp: new Date(),
|
||||
bounds,
|
||||
};
|
||||
}
|
||||
|
||||
public async fetchRouteWithTraffic(
|
||||
start: [number, number],
|
||||
end: [number, number],
|
||||
mode: TNavigationMode
|
||||
): Promise<ITrafficAwareRoute | null> {
|
||||
if (!this.isConfigured) {
|
||||
console.warn('[ValhallaTrafficProvider] No server URL configured');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Map mode to Valhalla costing model
|
||||
const costing = mode === 'cycling' ? 'bicycle'
|
||||
: mode === 'walking' ? 'pedestrian'
|
||||
: 'auto';
|
||||
|
||||
const requestBody = {
|
||||
locations: [
|
||||
{ lat: start[1], lon: start[0] },
|
||||
{ lat: end[1], lon: end[0] },
|
||||
],
|
||||
costing,
|
||||
costing_options: {
|
||||
[costing]: {
|
||||
use_traffic: true, // Enable traffic consideration
|
||||
},
|
||||
},
|
||||
directions_options: {
|
||||
units: 'kilometers',
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.serverUrl}/route`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`[ValhallaTrafficProvider] Routing error: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json() as IValhallaRouteResponse;
|
||||
return this.parseValhallaRoute(data);
|
||||
} catch (error) {
|
||||
console.error('[ValhallaTrafficProvider] Routing fetch error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Valhalla routing response
|
||||
*/
|
||||
private parseValhallaRoute(data: IValhallaRouteResponse): ITrafficAwareRoute | null {
|
||||
if (!data.trip?.legs?.[0]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const leg = data.trip.legs[0];
|
||||
const summary = data.trip.summary;
|
||||
|
||||
// Decode Valhalla's polyline6 format
|
||||
const coordinates = this.decodePolyline6(leg.shape);
|
||||
|
||||
// Calculate congestion level
|
||||
const delayRatio = summary.time > 0
|
||||
? (summary.time - (summary.time * 0.9)) / summary.time // Estimate without traffic
|
||||
: 0;
|
||||
|
||||
let congestionLevel: 'low' | 'moderate' | 'heavy' | 'severe';
|
||||
if (delayRatio < 0.1) congestionLevel = 'low';
|
||||
else if (delayRatio < 0.3) congestionLevel = 'moderate';
|
||||
else if (delayRatio < 0.5) congestionLevel = 'heavy';
|
||||
else congestionLevel = 'severe';
|
||||
|
||||
// Convert Valhalla maneuvers to OSRM format
|
||||
const steps = (leg.maneuvers || []).map(maneuver => ({
|
||||
geometry: { type: 'LineString' as const, coordinates: [] as [number, number][] },
|
||||
maneuver: {
|
||||
type: this.mapValhallaManeuverToOsrm(maneuver.type),
|
||||
modifier: maneuver.modifier,
|
||||
location: [maneuver.begin_shape_index, 0] as [number, number],
|
||||
},
|
||||
name: maneuver.street_names?.[0] || '',
|
||||
distance: maneuver.length * 1000, // Convert km to meters
|
||||
duration: maneuver.time,
|
||||
driving_side: 'right',
|
||||
}));
|
||||
|
||||
return {
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates,
|
||||
},
|
||||
distance: summary.length * 1000, // Convert km to meters
|
||||
duration: summary.time,
|
||||
durationWithoutTraffic: summary.time * 0.9, // Estimate
|
||||
congestionLevel,
|
||||
legs: [{
|
||||
steps,
|
||||
distance: summary.length * 1000,
|
||||
duration: summary.time,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode polyline6 format (Valhalla uses 6 decimal precision)
|
||||
*/
|
||||
private decodePolyline6(encoded: string): [number, number][] {
|
||||
const coordinates: [number, number][] = [];
|
||||
let index = 0;
|
||||
let lat = 0;
|
||||
let lng = 0;
|
||||
|
||||
while (index < encoded.length) {
|
||||
// Decode latitude
|
||||
let shift = 0;
|
||||
let result = 0;
|
||||
let byte: number;
|
||||
|
||||
do {
|
||||
byte = encoded.charCodeAt(index++) - 63;
|
||||
result |= (byte & 0x1f) << shift;
|
||||
shift += 5;
|
||||
} while (byte >= 0x20);
|
||||
|
||||
lat += (result & 1) ? ~(result >> 1) : (result >> 1);
|
||||
|
||||
// Decode longitude
|
||||
shift = 0;
|
||||
result = 0;
|
||||
|
||||
do {
|
||||
byte = encoded.charCodeAt(index++) - 63;
|
||||
result |= (byte & 0x1f) << shift;
|
||||
shift += 5;
|
||||
} while (byte >= 0x20);
|
||||
|
||||
lng += (result & 1) ? ~(result >> 1) : (result >> 1);
|
||||
|
||||
coordinates.push([lng / 1e6, lat / 1e6]);
|
||||
}
|
||||
|
||||
return coordinates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Valhalla maneuver types to OSRM types
|
||||
*/
|
||||
private mapValhallaManeuverToOsrm(type: number): string {
|
||||
const mapping: Record<number, string> = {
|
||||
0: 'none',
|
||||
1: 'depart',
|
||||
2: 'depart',
|
||||
3: 'arrive',
|
||||
4: 'arrive',
|
||||
5: 'arrive',
|
||||
6: 'continue',
|
||||
7: 'continue',
|
||||
8: 'turn',
|
||||
9: 'turn',
|
||||
10: 'turn',
|
||||
11: 'turn',
|
||||
12: 'turn',
|
||||
13: 'turn',
|
||||
14: 'turn',
|
||||
15: 'turn',
|
||||
16: 'turn',
|
||||
17: 'roundabout',
|
||||
18: 'roundabout',
|
||||
19: 'roundabout',
|
||||
20: 'roundabout',
|
||||
21: 'fork',
|
||||
22: 'fork',
|
||||
23: 'merge',
|
||||
24: 'merge',
|
||||
25: 'merge',
|
||||
26: 'notification',
|
||||
27: 'notification',
|
||||
};
|
||||
return mapping[type] || 'continue';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── HERE API Response Types ─────────────────────────────────────────────────
|
||||
|
||||
interface IHerePoint {
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
interface IHereTrafficFlowResponse {
|
||||
results?: Array<{
|
||||
location?: {
|
||||
shape?: {
|
||||
links?: Array<{
|
||||
points: IHerePoint[];
|
||||
}>;
|
||||
};
|
||||
};
|
||||
currentFlow?: {
|
||||
speed?: number;
|
||||
freeFlow?: number;
|
||||
jamFactor?: number;
|
||||
confidence?: number;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
interface IHereRoutingResponse {
|
||||
routes?: Array<{
|
||||
sections?: IHereRouteSection[];
|
||||
}>;
|
||||
}
|
||||
|
||||
interface IHereRouteSection {
|
||||
polyline: string;
|
||||
summary?: {
|
||||
duration: number;
|
||||
baseDuration?: number;
|
||||
length: number;
|
||||
};
|
||||
turnByTurnActions?: Array<{
|
||||
action: string;
|
||||
direction?: string;
|
||||
offset?: number;
|
||||
length?: number;
|
||||
duration?: number;
|
||||
currentRoad?: { name?: Array<{ value: string }> };
|
||||
nextRoad?: { name?: Array<{ value: string }> };
|
||||
}>;
|
||||
}
|
||||
|
||||
// ─── Valhalla API Response Types ─────────────────────────────────────────────
|
||||
|
||||
interface IValhallaTrafficData {
|
||||
segments?: Array<{
|
||||
geometry: GeoJSON.LineString;
|
||||
congestion?: number;
|
||||
speed?: number;
|
||||
freeFlowSpeed?: number;
|
||||
confidence?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface IValhallaRouteResponse {
|
||||
trip?: {
|
||||
summary: {
|
||||
time: number;
|
||||
length: number;
|
||||
};
|
||||
legs?: Array<{
|
||||
shape: string;
|
||||
maneuvers?: Array<{
|
||||
type: number;
|
||||
modifier?: string;
|
||||
begin_shape_index: number;
|
||||
street_names?: string[];
|
||||
length: number;
|
||||
time: number;
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
582
ts_web/elements/00group-map/dees-geo-map/geo-map.traffic.ts
Normal file
@@ -0,0 +1,582 @@
|
||||
import { html, type TemplateResult } from '@design.estate/dees-element';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import { renderIcon } from './geo-map.icons.js';
|
||||
import type {
|
||||
ITrafficProvider,
|
||||
ITrafficFlowData,
|
||||
ITrafficFlowSegment,
|
||||
ITrafficAwareRoute,
|
||||
} from './geo-map.traffic.providers.js';
|
||||
import type { TNavigationMode } from './geo-map.navigation.js';
|
||||
|
||||
// ─── Traffic Controller Types ────────────────────────────────────────────────
|
||||
|
||||
export interface ITrafficControllerCallbacks {
|
||||
/** Called when traffic state changes, to trigger re-render */
|
||||
onRequestUpdate: () => void;
|
||||
/** Returns the MapLibre map instance */
|
||||
getMap: () => maplibregl.Map | null;
|
||||
}
|
||||
|
||||
export interface ITrafficState {
|
||||
/** Whether traffic layer is enabled */
|
||||
isEnabled: boolean;
|
||||
/** Whether data is being loaded */
|
||||
isLoading: boolean;
|
||||
/** Last update timestamp */
|
||||
lastUpdate: Date | null;
|
||||
/** Error message if fetch failed */
|
||||
error: string | null;
|
||||
/** Number of traffic segments displayed */
|
||||
segmentCount: number;
|
||||
}
|
||||
|
||||
// ─── Traffic Controller ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Controller for traffic visualization and traffic-aware routing
|
||||
*
|
||||
* Manages:
|
||||
* - Traffic flow layer on the map
|
||||
* - Auto-refresh of traffic data
|
||||
* - Traffic-aware routing through providers
|
||||
*/
|
||||
export class TrafficController {
|
||||
// ─── State ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Traffic display state */
|
||||
public trafficState: ITrafficState = {
|
||||
isEnabled: false,
|
||||
isLoading: false,
|
||||
lastUpdate: null,
|
||||
error: null,
|
||||
segmentCount: 0,
|
||||
};
|
||||
|
||||
/** Currently active traffic provider */
|
||||
public provider: ITrafficProvider | null = null;
|
||||
|
||||
// ─── Configuration ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Auto-refresh interval in milliseconds (default: 60 seconds) */
|
||||
public updateInterval: number = 60000;
|
||||
|
||||
/** Whether to automatically refresh traffic data */
|
||||
public autoRefresh: boolean = true;
|
||||
|
||||
/** Minimum zoom level to show traffic (higher zoom = more detail but more API calls) */
|
||||
public minZoomForTraffic: number = 10;
|
||||
|
||||
// ─── Private ───────────────────────────────────────────────────────────────
|
||||
|
||||
private callbacks: ITrafficControllerCallbacks;
|
||||
private refreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private currentTrafficData: ITrafficFlowData | null = null;
|
||||
|
||||
// MapLibre source/layer IDs
|
||||
private static readonly SOURCE_ID = 'traffic-flow-source';
|
||||
private static readonly LAYER_ID = 'traffic-flow-layer';
|
||||
private static readonly LAYER_OUTLINE_ID = 'traffic-flow-outline-layer';
|
||||
|
||||
constructor(callbacks: ITrafficControllerCallbacks) {
|
||||
this.callbacks = callbacks;
|
||||
}
|
||||
|
||||
// ─── Provider Management ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Set the traffic data provider
|
||||
*/
|
||||
public setProvider(provider: ITrafficProvider): void {
|
||||
this.provider = provider;
|
||||
|
||||
// If traffic is enabled, refresh data with new provider
|
||||
if (this.trafficState.isEnabled) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Enable/Disable ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Enable traffic visualization
|
||||
*/
|
||||
public enable(): void {
|
||||
if (this.trafficState.isEnabled) return;
|
||||
|
||||
this.trafficState = {
|
||||
...this.trafficState,
|
||||
isEnabled: true,
|
||||
error: null,
|
||||
};
|
||||
|
||||
// Start auto-refresh if configured
|
||||
if (this.autoRefresh) {
|
||||
this.startAutoRefresh();
|
||||
}
|
||||
|
||||
// Initial data fetch
|
||||
this.refresh();
|
||||
|
||||
this.callbacks.onRequestUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable traffic visualization
|
||||
*/
|
||||
public disable(): void {
|
||||
if (!this.trafficState.isEnabled) return;
|
||||
|
||||
this.stopAutoRefresh();
|
||||
this.removeTrafficLayer();
|
||||
|
||||
this.trafficState = {
|
||||
isEnabled: false,
|
||||
isLoading: false,
|
||||
lastUpdate: null,
|
||||
error: null,
|
||||
segmentCount: 0,
|
||||
};
|
||||
|
||||
this.currentTrafficData = null;
|
||||
this.callbacks.onRequestUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle traffic visualization
|
||||
*/
|
||||
public toggle(): void {
|
||||
if (this.trafficState.isEnabled) {
|
||||
this.disable();
|
||||
} else {
|
||||
this.enable();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Data Fetching ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Refresh traffic data from the provider
|
||||
*/
|
||||
public async refresh(): Promise<void> {
|
||||
if (!this.trafficState.isEnabled) return;
|
||||
|
||||
const map = this.callbacks.getMap();
|
||||
if (!map) return;
|
||||
|
||||
// Check zoom level
|
||||
const currentZoom = map.getZoom();
|
||||
if (currentZoom < this.minZoomForTraffic) {
|
||||
// Remove traffic layer if zoom is too low
|
||||
this.removeTrafficLayer();
|
||||
this.trafficState = {
|
||||
...this.trafficState,
|
||||
error: `Zoom in to see traffic (current: ${Math.round(currentZoom)}, required: ${this.minZoomForTraffic})`,
|
||||
segmentCount: 0,
|
||||
};
|
||||
this.callbacks.onRequestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.provider) {
|
||||
this.trafficState = {
|
||||
...this.trafficState,
|
||||
error: 'No traffic provider configured',
|
||||
};
|
||||
this.callbacks.onRequestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.provider.isConfigured) {
|
||||
this.trafficState = {
|
||||
...this.trafficState,
|
||||
error: `${this.provider.name} is not configured (missing API key?)`,
|
||||
};
|
||||
this.callbacks.onRequestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current map bounds
|
||||
const mapBounds = map.getBounds();
|
||||
const bounds: [number, number, number, number] = [
|
||||
mapBounds.getWest(),
|
||||
mapBounds.getSouth(),
|
||||
mapBounds.getEast(),
|
||||
mapBounds.getNorth(),
|
||||
];
|
||||
|
||||
this.trafficState = {
|
||||
...this.trafficState,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
};
|
||||
this.callbacks.onRequestUpdate();
|
||||
|
||||
try {
|
||||
const data = await this.provider.fetchTrafficFlow(bounds);
|
||||
|
||||
if (data) {
|
||||
this.currentTrafficData = data;
|
||||
this.renderTrafficLayer(data);
|
||||
|
||||
this.trafficState = {
|
||||
...this.trafficState,
|
||||
isLoading: false,
|
||||
lastUpdate: data.timestamp,
|
||||
segmentCount: data.segments.length,
|
||||
};
|
||||
} else {
|
||||
this.trafficState = {
|
||||
...this.trafficState,
|
||||
isLoading: false,
|
||||
error: 'No traffic data available for this area',
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
this.trafficState = {
|
||||
...this.trafficState,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch traffic data',
|
||||
};
|
||||
}
|
||||
|
||||
this.callbacks.onRequestUpdate();
|
||||
}
|
||||
|
||||
// ─── Traffic-Aware Routing ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch a route with traffic-aware duration
|
||||
* Returns null if the provider doesn't support traffic-aware routing
|
||||
*/
|
||||
public async fetchRouteWithTraffic(
|
||||
start: [number, number],
|
||||
end: [number, number],
|
||||
mode: TNavigationMode
|
||||
): Promise<ITrafficAwareRoute | null> {
|
||||
if (!this.provider || !this.provider.fetchRouteWithTraffic) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.provider.isConfigured) {
|
||||
console.warn('[TrafficController] Provider not configured for traffic routing');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.provider.fetchRouteWithTraffic(start, end, mode);
|
||||
} catch (error) {
|
||||
console.error('[TrafficController] Traffic routing error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current provider supports traffic-aware routing
|
||||
*/
|
||||
public supportsTrafficRouting(): boolean {
|
||||
return !!(this.provider?.fetchRouteWithTraffic && this.provider.isConfigured);
|
||||
}
|
||||
|
||||
// ─── Layer Management ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Render traffic data as a MapLibre layer
|
||||
*/
|
||||
private renderTrafficLayer(data: ITrafficFlowData): void {
|
||||
const map = this.callbacks.getMap();
|
||||
if (!map || data.segments.length === 0) return;
|
||||
|
||||
// Convert segments to GeoJSON FeatureCollection
|
||||
const geojson = this.createTrafficGeoJson(data.segments);
|
||||
|
||||
// Remove existing layer/source if present
|
||||
this.removeTrafficLayer();
|
||||
|
||||
// Add source
|
||||
map.addSource(TrafficController.SOURCE_ID, {
|
||||
type: 'geojson',
|
||||
data: geojson,
|
||||
});
|
||||
|
||||
// Add outline layer (for better visibility)
|
||||
map.addLayer({
|
||||
id: TrafficController.LAYER_OUTLINE_ID,
|
||||
type: 'line',
|
||||
source: TrafficController.SOURCE_ID,
|
||||
layout: {
|
||||
'line-join': 'round',
|
||||
'line-cap': 'round',
|
||||
},
|
||||
paint: {
|
||||
'line-color': '#000',
|
||||
'line-width': 6,
|
||||
'line-opacity': 0.3,
|
||||
},
|
||||
});
|
||||
|
||||
// Add main traffic layer with congestion colors
|
||||
map.addLayer({
|
||||
id: TrafficController.LAYER_ID,
|
||||
type: 'line',
|
||||
source: TrafficController.SOURCE_ID,
|
||||
layout: {
|
||||
'line-join': 'round',
|
||||
'line-cap': 'round',
|
||||
},
|
||||
paint: {
|
||||
// Color based on congestion level (0-1)
|
||||
'line-color': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['get', 'congestion'],
|
||||
0, '#00c853', // Green - free flow
|
||||
0.3, '#ffeb3b', // Yellow - light congestion
|
||||
0.6, '#ff9800', // Orange - moderate congestion
|
||||
0.8, '#f44336', // Red - heavy congestion
|
||||
1, '#b71c1c', // Dark red - severe/stopped
|
||||
],
|
||||
'line-width': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['zoom'],
|
||||
10, 2,
|
||||
14, 4,
|
||||
18, 6,
|
||||
],
|
||||
'line-opacity': 0.85,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the traffic layer with new data (without removing/recreating)
|
||||
*/
|
||||
public updateTrafficLayer(data: ITrafficFlowData): void {
|
||||
const map = this.callbacks.getMap();
|
||||
if (!map) return;
|
||||
|
||||
const source = map.getSource(TrafficController.SOURCE_ID) as maplibregl.GeoJSONSource;
|
||||
if (source) {
|
||||
const geojson = this.createTrafficGeoJson(data.segments);
|
||||
source.setData(geojson);
|
||||
this.currentTrafficData = data;
|
||||
this.trafficState = {
|
||||
...this.trafficState,
|
||||
lastUpdate: data.timestamp,
|
||||
segmentCount: data.segments.length,
|
||||
};
|
||||
} else {
|
||||
// Source doesn't exist, create full layer
|
||||
this.renderTrafficLayer(data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove traffic layer from the map
|
||||
*/
|
||||
public removeTrafficLayer(): void {
|
||||
const map = this.callbacks.getMap();
|
||||
if (!map) return;
|
||||
|
||||
if (map.getLayer(TrafficController.LAYER_ID)) {
|
||||
map.removeLayer(TrafficController.LAYER_ID);
|
||||
}
|
||||
if (map.getLayer(TrafficController.LAYER_OUTLINE_ID)) {
|
||||
map.removeLayer(TrafficController.LAYER_OUTLINE_ID);
|
||||
}
|
||||
if (map.getSource(TrafficController.SOURCE_ID)) {
|
||||
map.removeSource(TrafficController.SOURCE_ID);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert traffic segments to GeoJSON FeatureCollection
|
||||
*/
|
||||
private createTrafficGeoJson(segments: ITrafficFlowSegment[]): GeoJSON.FeatureCollection {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: segments.map((segment, index) => ({
|
||||
type: 'Feature' as const,
|
||||
id: index,
|
||||
properties: {
|
||||
congestion: segment.congestion,
|
||||
speed: segment.speed,
|
||||
freeFlowSpeed: segment.freeFlowSpeed,
|
||||
confidence: segment.confidence,
|
||||
},
|
||||
geometry: segment.geometry,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Auto-Refresh ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Start automatic refresh timer
|
||||
*/
|
||||
private startAutoRefresh(): void {
|
||||
this.stopAutoRefresh();
|
||||
|
||||
this.refreshTimer = setInterval(() => {
|
||||
if (this.trafficState.isEnabled) {
|
||||
this.refresh();
|
||||
}
|
||||
}, this.updateInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop automatic refresh timer
|
||||
*/
|
||||
private stopAutoRefresh(): void {
|
||||
if (this.refreshTimer) {
|
||||
clearInterval(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Map Event Handling ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Handle map move/zoom end - refresh traffic for new bounds
|
||||
*/
|
||||
public handleMapMoveEnd(): void {
|
||||
if (this.trafficState.isEnabled && !this.trafficState.isLoading) {
|
||||
// Debounce to avoid too many API calls
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Cleanup ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
public cleanup(): void {
|
||||
this.stopAutoRefresh();
|
||||
this.removeTrafficLayer();
|
||||
this.currentTrafficData = null;
|
||||
}
|
||||
|
||||
// ─── Utilities ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Format last update time for display
|
||||
*/
|
||||
public formatLastUpdate(): string {
|
||||
if (!this.trafficState.lastUpdate) return 'Never';
|
||||
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - this.trafficState.lastUpdate.getTime();
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
|
||||
if (seconds < 60) return 'Just now';
|
||||
if (seconds < 120) return '1 minute ago';
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)} minutes ago`;
|
||||
return this.trafficState.lastUpdate.toLocaleTimeString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get congestion color for a given level
|
||||
*/
|
||||
public getCongestionColor(congestion: number): string {
|
||||
if (congestion < 0.3) return '#00c853'; // Green
|
||||
if (congestion < 0.6) return '#ffeb3b'; // Yellow
|
||||
if (congestion < 0.8) return '#ff9800'; // Orange
|
||||
if (congestion < 1) return '#f44336'; // Red
|
||||
return '#b71c1c'; // Dark red
|
||||
}
|
||||
|
||||
/**
|
||||
* Get congestion label for a given level
|
||||
*/
|
||||
public getCongestionLabel(level: 'low' | 'moderate' | 'heavy' | 'severe'): string {
|
||||
const labels = {
|
||||
low: 'Light traffic',
|
||||
moderate: 'Moderate traffic',
|
||||
heavy: 'Heavy traffic',
|
||||
severe: 'Severe congestion',
|
||||
};
|
||||
return labels[level];
|
||||
}
|
||||
|
||||
// ─── Rendering ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Render the traffic toggle button and status
|
||||
*/
|
||||
public render(): TemplateResult {
|
||||
const { isEnabled, isLoading, error, segmentCount } = this.trafficState;
|
||||
const hasProvider = this.provider !== null && this.provider.isConfigured;
|
||||
|
||||
return html`
|
||||
<div class="traffic-control">
|
||||
<button
|
||||
class="traffic-toggle-btn ${isEnabled ? 'active' : ''}"
|
||||
?disabled=${!hasProvider}
|
||||
@click=${() => this.toggle()}
|
||||
title="${!hasProvider ? 'Configure a traffic provider first' : isEnabled ? 'Hide traffic' : 'Show traffic'}"
|
||||
>
|
||||
${renderIcon('traffic')}
|
||||
${isLoading ? html`
|
||||
<span class="traffic-loading-indicator">${renderIcon('spinner')}</span>
|
||||
` : ''}
|
||||
</button>
|
||||
|
||||
${isEnabled && !error ? html`
|
||||
<div class="traffic-status">
|
||||
<span class="traffic-status-dot"></span>
|
||||
<span class="traffic-status-text">
|
||||
${segmentCount > 0 ? `${segmentCount} segments` : 'Loading...'}
|
||||
</span>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${error ? html`
|
||||
<div class="traffic-error" title="${error}">
|
||||
${renderIcon('error')}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render traffic legend overlay
|
||||
* @param extraClass - Optional CSS class to add to the legend for positioning
|
||||
*/
|
||||
public renderLegend(extraClass?: string): TemplateResult {
|
||||
if (!this.trafficState.isEnabled) return html``;
|
||||
|
||||
return html`
|
||||
<div class="traffic-legend ${extraClass || ''}">
|
||||
<div class="traffic-legend-title">Traffic</div>
|
||||
<div class="traffic-legend-items">
|
||||
<div class="traffic-legend-item">
|
||||
<span class="traffic-legend-color" style="background: #00c853"></span>
|
||||
<span>Free flow</span>
|
||||
</div>
|
||||
<div class="traffic-legend-item">
|
||||
<span class="traffic-legend-color" style="background: #ffeb3b"></span>
|
||||
<span>Light</span>
|
||||
</div>
|
||||
<div class="traffic-legend-item">
|
||||
<span class="traffic-legend-color" style="background: #ff9800"></span>
|
||||
<span>Moderate</span>
|
||||
</div>
|
||||
<div class="traffic-legend-item">
|
||||
<span class="traffic-legend-color" style="background: #f44336"></span>
|
||||
<span>Heavy</span>
|
||||
</div>
|
||||
<div class="traffic-legend-item">
|
||||
<span class="traffic-legend-color" style="background: #b71c1c"></span>
|
||||
<span>Severe</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="traffic-legend-updated">
|
||||
Updated: ${this.formatLastUpdate()}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -5,3 +5,15 @@ export * from './dees-geo-map.js';
|
||||
export { renderIcon, GEO_MAP_ICONS } from './geo-map.icons.js';
|
||||
export { SearchController, type INominatimResult, type IAddressSelectedEvent, type ISearchControllerConfig, type ISearchControllerCallbacks } from './geo-map.search.js';
|
||||
export { NavigationController, type TNavigationMode, type INavigationState, type IOSRMRoute, type IOSRMLeg, type IOSRMStep, type IRouteCalculatedEvent, type INavigationControllerCallbacks } from './geo-map.navigation.js';
|
||||
|
||||
// Traffic exports
|
||||
export { TrafficController, type ITrafficControllerCallbacks, type ITrafficState } from './geo-map.traffic.js';
|
||||
export {
|
||||
HereTrafficProvider,
|
||||
ValhallaTrafficProvider,
|
||||
type ITrafficProvider,
|
||||
type ITrafficProviderConfig,
|
||||
type ITrafficFlowData,
|
||||
type ITrafficFlowSegment,
|
||||
type ITrafficAwareRoute,
|
||||
} from './geo-map.traffic.providers.js';
|
||||
|
||||