feat(geo-map): add live traffic visualization and traffic-aware routing with pluggable providers and UI integration

This commit is contained in:
2026-02-05 15:07:33 +00:00
parent 1a0fceadc0
commit df690dc329
22 changed files with 2238 additions and 181 deletions

17
changelog.md Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

BIN
header-toolbar-full.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

BIN
header-toolbar-layout.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

BIN
header-toolbar-native.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

BIN
header-toolbar-v3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

BIN
map-layout-test.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 951 KiB

BIN
map-no-toolbar-test.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 968 KiB

View File

@@ -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
View File

@@ -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

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

View File

@@ -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'
}

View File

@@ -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);
}
`;

View File

@@ -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">

View File

@@ -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>
`;
}
}

View File

@@ -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>`,
};
/**

View File

@@ -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>

View File

@@ -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')}

View File

@@ -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;
}>;
}>;
};
}

View 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>
`;
}
}

View File

@@ -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';