feat(dees-geo-map): Add voice-guided navigation with TTS, navigation guide controller and mock GPS simulator
11
changelog.md
@@ -1,5 +1,16 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-02-05 - 1.4.0 - feat(dees-geo-map)
|
||||||
|
Add voice-guided navigation with TTS, navigation guide controller and mock GPS simulator
|
||||||
|
|
||||||
|
- Introduce VoiceSynthesisManager (ts_web/elements/00group-map/dees-geo-map/geo-map.voice.ts) — queue-based Web Speech API wrapper with urgent interruption and navigation-specific helpers (approach, maneuver, arrival, off-route).
|
||||||
|
- Add NavigationGuideController (geo-map.navigation-guide.ts) to provide real-time GPS guidance, step progression, off-route/arrival detection, guidance events and navigation camera controls.
|
||||||
|
- Add MockGPSSimulator (ts_web/elements/00group-map/dees-geo-map/geo-map.mock-gps.ts) to simulate movement along a route with speed presets, jitter, and control methods (start/pause/stop/jump).
|
||||||
|
- Expose new public APIs on DeesGeoMap: setPosition, startGuidance, stopGuidance, setVoiceEnabled, isVoiceEnabled, getGuidanceState, isNavigating, createMockGPSSimulator, getMockGPSSimulator, getGuidanceController, and navigation camera config helpers.
|
||||||
|
- Add guidance UI/assets: new icons, guidance panel rendering, styles (guidanceStyles, maplibreMarkerStyles), and demo controls to ts_web/elements/00group-map/dees-geo-map/dees-geo-map.demo.ts.
|
||||||
|
- Wire guidance controller into lifecycle: initialize when map is created, dispatch 'guidance-event' CustomEvents, clean up on teardown, and render guidance panel during navigation.
|
||||||
|
- Update module exports (index.ts) to re-export VoiceSynthesisManager, NavigationGuideController, MockGPSSimulator and related types.
|
||||||
|
|
||||||
## 2026-02-05 - 1.3.0 - feat(dees-geo-map)
|
## 2026-02-05 - 1.3.0 - feat(dees-geo-map)
|
||||||
Add dark/light theme support for UI and map tiles, subscribe to theme changes and add traffic toggle in navigation
|
Add dark/light theme support for UI and map tiles, subscribe to theme changes and add traffic toggle in navigation
|
||||||
|
|
||||||
|
|||||||
BIN
marker-debug.png
Normal file
|
After Width: | Height: | Size: 286 KiB |
BIN
marker-positioning-issue.png
Normal file
|
After Width: | Height: | Size: 342 KiB |
BIN
markers-after-stop.png
Normal file
|
After Width: | Height: | Size: 223 KiB |
BIN
markers-before-navigation.png
Normal file
|
After Width: | Height: | Size: 514 KiB |
BIN
markers-during-navigation.png
Normal file
|
After Width: | Height: | Size: 277 KiB |
BIN
markers-flat-view.png
Normal file
|
After Width: | Height: | Size: 514 KiB |
BIN
navigation-3d-markers.png
Normal file
|
After Width: | Height: | Size: 324 KiB |
BIN
navigation-marker-check.png
Normal file
|
After Width: | Height: | Size: 292 KiB |
135
readme.hints.md
@@ -26,6 +26,8 @@ Geospatial web components library using MapLibre GL JS for map rendering and ter
|
|||||||
| `showTraffic` | `boolean` | `false` | Enable traffic layer visualization |
|
| `showTraffic` | `boolean` | `false` | Enable traffic layer visualization |
|
||||||
| `trafficApiKey` | `string` | `''` | HERE API key for traffic data |
|
| `trafficApiKey` | `string` | `''` | HERE API key for traffic data |
|
||||||
| `trafficProvider` | `ITrafficProvider` | `null` | Custom traffic data provider |
|
| `trafficProvider` | `ITrafficProvider` | `null` | Custom traffic data provider |
|
||||||
|
| `enableGuidance` | `boolean` | `false` | Enable voice-guided navigation |
|
||||||
|
| `voiceConfig` | `Partial<IVoiceConfig>` | `{}` | Voice synthesis configuration |
|
||||||
|
|
||||||
### Drawing Tools (TDrawTool)
|
### Drawing Tools (TDrawTool)
|
||||||
- `point` - Draw points
|
- `point` - Draw points
|
||||||
@@ -45,6 +47,7 @@ Geospatial web components library using MapLibre GL JS for map rendering and ter
|
|||||||
- `address-selected` - Fired when a search result is selected
|
- `address-selected` - Fired when a search result is selected
|
||||||
- `route-calculated` - Fired when a navigation route is calculated (includes route, startPoint, endPoint, mode)
|
- `route-calculated` - Fired when a navigation route is calculated (includes route, startPoint, endPoint, mode)
|
||||||
- `traffic-updated` - Fired when traffic data is refreshed
|
- `traffic-updated` - Fired when traffic data is refreshed
|
||||||
|
- `guidance-event` - Fired during voice-guided navigation (includes type, position, stepIndex, instruction)
|
||||||
|
|
||||||
### Public Methods
|
### Public Methods
|
||||||
- `getFeatures()` - Get all drawn features
|
- `getFeatures()` - Get all drawn features
|
||||||
@@ -68,6 +71,22 @@ Geospatial web components library using MapLibre GL JS for map rendering and ter
|
|||||||
- `setTrafficProvider(provider)` - Set custom traffic provider
|
- `setTrafficProvider(provider)` - Set custom traffic provider
|
||||||
- `supportsTrafficRouting()` - Check if traffic-aware routing is available
|
- `supportsTrafficRouting()` - Check if traffic-aware routing is available
|
||||||
- `getTrafficController()` - Get the TrafficController instance
|
- `getTrafficController()` - Get the TrafficController instance
|
||||||
|
- `setPosition(coords, heading?, speed?)` - Set current GPS position for navigation guidance
|
||||||
|
- `startGuidance()` - Start voice-guided navigation for the current route
|
||||||
|
- `stopGuidance()` - Stop voice-guided navigation
|
||||||
|
- `setVoiceEnabled(enabled)` - Enable/disable voice guidance
|
||||||
|
- `isVoiceEnabled()` - Check if voice guidance is enabled
|
||||||
|
- `getGuidanceState()` - Get current navigation guidance state
|
||||||
|
- `isNavigating()` - Check if actively navigating
|
||||||
|
- `createMockGPSSimulator(config?)` - Create a mock GPS simulator for testing/demo
|
||||||
|
- `getMockGPSSimulator()` - Get the mock GPS simulator instance
|
||||||
|
- `getGuidanceController()` - Get the NavigationGuideController instance
|
||||||
|
- `setNavigationFollowPosition(enabled)` - Enable/disable camera following GPS position during navigation
|
||||||
|
- `setNavigationFollowBearing(enabled)` - Enable/disable camera rotating with heading during navigation
|
||||||
|
- `setNavigationPitch(pitch)` - Set navigation camera pitch (0-85 degrees, default 60)
|
||||||
|
- `setNavigationZoom(zoom)` - Set navigation zoom level (default 17)
|
||||||
|
- `getNavigationCameraConfig()` - Get full navigation camera configuration
|
||||||
|
- `setNavigationCameraConfig(config)` - Set navigation camera configuration
|
||||||
|
|
||||||
### Context Menu
|
### Context Menu
|
||||||
Right-click on the map to access a context menu with the following options:
|
Right-click on the map to access a context menu with the following options:
|
||||||
@@ -148,7 +167,10 @@ ts_web/
|
|||||||
├── geo-map.search.ts # SearchController class
|
├── geo-map.search.ts # SearchController class
|
||||||
├── geo-map.navigation.ts # NavigationController class
|
├── geo-map.navigation.ts # NavigationController class
|
||||||
├── geo-map.traffic.ts # TrafficController class
|
├── geo-map.traffic.ts # TrafficController class
|
||||||
└── geo-map.traffic.providers.ts # Traffic provider implementations
|
├── geo-map.traffic.providers.ts # Traffic provider implementations
|
||||||
|
├── geo-map.voice.ts # VoiceSynthesisManager class
|
||||||
|
├── geo-map.navigation-guide.ts # NavigationGuideController class
|
||||||
|
└── geo-map.mock-gps.ts # MockGPSSimulator class
|
||||||
```
|
```
|
||||||
|
|
||||||
## Modular Architecture
|
## Modular Architecture
|
||||||
@@ -184,6 +206,34 @@ Traffic data provider implementations:
|
|||||||
- `HereTrafficProvider` - HERE Traffic API v7 (freemium)
|
- `HereTrafficProvider` - HERE Traffic API v7 (freemium)
|
||||||
- `ValhallaTrafficProvider` - Self-hosted Valhalla server
|
- `ValhallaTrafficProvider` - Self-hosted Valhalla server
|
||||||
|
|
||||||
|
### geo-map.voice.ts
|
||||||
|
`VoiceSynthesisManager` class for voice-guided navigation:
|
||||||
|
- Uses Web Speech API for text-to-speech
|
||||||
|
- Queue-based speech with interrupt capability for urgent instructions
|
||||||
|
- Configurable language, rate, pitch, volume
|
||||||
|
- Navigation-specific methods: `speakApproach()`, `speakManeuver()`, `speakArrival()`, `speakOffRoute()`
|
||||||
|
- Graceful fallback if speech synthesis not supported
|
||||||
|
|
||||||
|
### geo-map.navigation-guide.ts
|
||||||
|
`NavigationGuideController` class for real-time GPS navigation guidance:
|
||||||
|
- Position tracking with GPS updates via `updatePosition()` or `setPosition()`
|
||||||
|
- Step progression tracking along route
|
||||||
|
- Distance thresholds for voice announcements (500m, 200m, 50m, at maneuver)
|
||||||
|
- Off-route detection (>50m from route line)
|
||||||
|
- Arrival detection (within 30m of destination)
|
||||||
|
- Renders GPS position marker on map with heading indicator
|
||||||
|
- Emits guidance events: `approach-maneuver`, `execute-maneuver`, `step-change`, `off-route`, `arrived`
|
||||||
|
- **Camera following**: Automatically moves camera to follow GPS position with smooth transitions
|
||||||
|
- **Camera configuration**: Configurable pitch (60° default for 3D view), zoom (17 default), and bearing following
|
||||||
|
|
||||||
|
### geo-map.mock-gps.ts
|
||||||
|
`MockGPSSimulator` class for testing/demo:
|
||||||
|
- Interpolates positions along route geometry
|
||||||
|
- Speed presets: Walking (5 km/h), Cycling (20 km/h), City (50 km/h), Highway (100 km/h)
|
||||||
|
- Configurable update interval and GPS jitter
|
||||||
|
- Calculates heading between consecutive points
|
||||||
|
- Methods: `start()`, `pause()`, `stop()`, `jumpToProgress()`
|
||||||
|
|
||||||
### Usage of Controllers
|
### Usage of Controllers
|
||||||
```typescript
|
```typescript
|
||||||
// SearchController is reusable
|
// SearchController is reusable
|
||||||
@@ -287,3 +337,86 @@ Terra-draw's event listeners normally intercept map events through MapLibre's ca
|
|||||||
- When a drawing tool is active (`polygon`, `rectangle`, `point`, `linestring`, `circle`, `freehand`), MapLibre's `dragPan` and `dragRotate` are disabled
|
- When a drawing tool is active (`polygon`, `rectangle`, `point`, `linestring`, `circle`, `freehand`), MapLibre's `dragPan` and `dragRotate` are disabled
|
||||||
- When `static` or `select` mode is active, dragging is re-enabled
|
- When `static` or `select` mode is active, dragging is re-enabled
|
||||||
- The `TerraDrawMapLibreGLAdapter` does NOT accept a `lib` parameter - only `map` is required
|
- The `TerraDrawMapLibreGLAdapter` does NOT accept a `lib` parameter - only `map` is required
|
||||||
|
|
||||||
|
### Voice-Guided Navigation Feature
|
||||||
|
|
||||||
|
Real-time GPS tracking with voice-guided turn-by-turn instructions:
|
||||||
|
|
||||||
|
#### How to Use
|
||||||
|
1. Calculate a route using the navigation panel (set start and end points)
|
||||||
|
2. Call `startGuidance()` to begin voice-guided navigation
|
||||||
|
3. Update position with `setPosition([lng, lat], heading?, speed?)` or use `createMockGPSSimulator()` for testing
|
||||||
|
4. Listen to `guidance-event` for navigation updates
|
||||||
|
5. Call `stopGuidance()` when done
|
||||||
|
|
||||||
|
#### Example Usage
|
||||||
|
```typescript
|
||||||
|
const map = document.querySelector('dees-geo-map');
|
||||||
|
|
||||||
|
// 1. Set up route
|
||||||
|
map.setNavigationStart([8.68, 50.11], 'Frankfurt');
|
||||||
|
map.setNavigationEnd([8.75, 50.08], 'Sachsenhausen');
|
||||||
|
await map.calculateRoute();
|
||||||
|
|
||||||
|
// 2. Listen to guidance events
|
||||||
|
map.addEventListener('guidance-event', (e) => {
|
||||||
|
console.log(e.detail.type, e.detail.instruction);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Start guidance with mock GPS simulation
|
||||||
|
const simulator = map.createMockGPSSimulator({ speed: 'city' });
|
||||||
|
map.startGuidance();
|
||||||
|
simulator.start();
|
||||||
|
|
||||||
|
// 4. Or use real GPS input
|
||||||
|
navigator.geolocation.watchPosition((pos) => {
|
||||||
|
map.setPosition([pos.coords.longitude, pos.coords.latitude], pos.coords.heading, pos.coords.speed);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Voice Announcements
|
||||||
|
- **500m**: "In 500 meters, turn left onto Main Street"
|
||||||
|
- **200m**: "In 200 meters, turn left"
|
||||||
|
- **50m**: "Turn left ahead"
|
||||||
|
- **At maneuver**: "Turn left now"
|
||||||
|
- **Arrival**: "You have arrived at your destination"
|
||||||
|
- **Off-route**: "You are off route. Recalculating."
|
||||||
|
|
||||||
|
#### Guidance Event Types
|
||||||
|
- `approach-maneuver` - Approaching a turn/maneuver
|
||||||
|
- `execute-maneuver` - At the maneuver point (urgent)
|
||||||
|
- `step-change` - Advanced to next route step
|
||||||
|
- `off-route` - Deviated more than 50m from route
|
||||||
|
- `arrived` - Within 30m of destination
|
||||||
|
- `position-updated` - Position was updated
|
||||||
|
|
||||||
|
#### Camera Following
|
||||||
|
During navigation, the map camera automatically:
|
||||||
|
- **Follows position**: Centers on current GPS location with smooth continuous transitions
|
||||||
|
- **Follows bearing**: Rotates map to match driving direction (heading)
|
||||||
|
- **3D tilt**: Uses 60° pitch by default for immersive driving view
|
||||||
|
- **Street-level zoom**: Uses zoom level 17 for optimal route visibility
|
||||||
|
- **Smooth animation**: Animation duration dynamically matches GPS update interval for fluid movement (no jerky pauses)
|
||||||
|
|
||||||
|
Camera behavior can be customized:
|
||||||
|
```typescript
|
||||||
|
// Disable camera following (user can pan freely)
|
||||||
|
map.setNavigationFollowPosition(false);
|
||||||
|
|
||||||
|
// Disable bearing rotation (map stays north-up)
|
||||||
|
map.setNavigationFollowBearing(false);
|
||||||
|
|
||||||
|
// Use flat (2D) view instead of tilted
|
||||||
|
map.setNavigationPitch(0);
|
||||||
|
|
||||||
|
// Zoom out for wider view
|
||||||
|
map.setNavigationZoom(15);
|
||||||
|
|
||||||
|
// Or set multiple options at once
|
||||||
|
map.setNavigationCameraConfig({
|
||||||
|
followPosition: true,
|
||||||
|
followBearing: true,
|
||||||
|
pitch: 60,
|
||||||
|
zoom: 17,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|||||||
BIN
route-markers-check.png
Normal file
|
After Width: | Height: | Size: 514 KiB |
BIN
route-markers-verification.png
Normal file
|
After Width: | Height: | Size: 514 KiB |
BIN
route-with-markers.png
Normal file
|
After Width: | Height: | Size: 562 KiB |
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@design.estate/dees-catalog-geo',
|
name: '@design.estate/dees-catalog-geo',
|
||||||
version: '1.3.0',
|
version: '1.4.0',
|
||||||
description: 'A geospatial web components library with MapLibre GL JS maps and terra-draw drawing tools'
|
description: 'A geospatial web components library with MapLibre GL JS maps and terra-draw drawing tools'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1378,3 +1378,331 @@ export const trafficStyles = css`
|
|||||||
color: ${cssManager.bdTheme('rgba(0, 0, 0, 0.4)', 'rgba(255, 255, 255, 0.4)')};
|
color: ${cssManager.bdTheme('rgba(0, 0, 0, 0.4)', 'rgba(255, 255, 255, 0.4)')};
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MapLibre marker styles - CRITICAL for correct marker positioning
|
||||||
|
* MapLibre markers use CSS transforms for positioning, which requires position: absolute
|
||||||
|
*/
|
||||||
|
export const maplibreMarkerStyles = css`
|
||||||
|
.maplibregl-marker {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigation guidance styles for GPS tracking and voice guidance
|
||||||
|
*/
|
||||||
|
export const guidanceStyles = css`
|
||||||
|
/* GPS Position Marker */
|
||||||
|
.gps-position-marker {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
cursor: default;
|
||||||
|
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Guidance Panel - shown during active navigation */
|
||||||
|
.guidance-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: ${cssManager.bdTheme('rgba(255, 255, 255, 0.98)', 'rgba(30, 30, 30, 0.95)')};
|
||||||
|
border: 1px solid ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.1)')};
|
||||||
|
border-radius: 12px;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
padding: 16px 24px;
|
||||||
|
min-width: 280px;
|
||||||
|
max-width: 400px;
|
||||||
|
z-index: 15;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guidance-maneuver {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guidance-maneuver-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
background: var(--geo-tool-active, #0084ff);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #fff;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guidance-maneuver-distance {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: ${cssManager.bdTheme('#1a1a1a', '#ffffff')};
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guidance-instruction {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: ${cssManager.bdTheme('#1a1a1a', '#ffffff')};
|
||||||
|
margin-bottom: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guidance-remaining {
|
||||||
|
font-size: 13px;
|
||||||
|
color: ${cssManager.bdTheme('rgba(0, 0, 0, 0.5)', 'rgba(255, 255, 255, 0.6)')};
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.1)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.guidance-off-route {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: ${cssManager.bdTheme('#dc2626', '#fca5a5')};
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Simulation Controls Panel */
|
||||||
|
.simulation-panel {
|
||||||
|
background: ${cssManager.bdTheme('rgba(255, 255, 255, 0.98)', 'rgba(30, 30, 30, 0.95)')};
|
||||||
|
border: 1px solid ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.1)')};
|
||||||
|
border-radius: 8px;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulation-panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.1)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulation-panel-header-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
color: var(--geo-tool-active, #0084ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulation-panel-header-icon svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulation-panel-header-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: ${cssManager.bdTheme('#1a1a1a', '#ffffff')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulation-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulation-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid ${cssManager.bdTheme('rgba(0, 0, 0, 0.15)', 'rgba(255, 255, 255, 0.15)')};
|
||||||
|
border-radius: 8px;
|
||||||
|
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.03)', 'rgba(0, 0, 0, 0.2)')};
|
||||||
|
color: ${cssManager.bdTheme('#1a1a1a', '#ffffff')};
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulation-btn:hover:not(:disabled) {
|
||||||
|
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.08)', 'rgba(255, 255, 255, 0.1)')};
|
||||||
|
border-color: ${cssManager.bdTheme('rgba(0, 0, 0, 0.25)', 'rgba(255, 255, 255, 0.25)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulation-btn.active {
|
||||||
|
background: var(--geo-tool-active, #0084ff);
|
||||||
|
border-color: var(--geo-tool-active, #0084ff);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulation-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulation-btn svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulation-btn.play svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Speed selector */
|
||||||
|
.simulation-speed {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulation-speed-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: ${cssManager.bdTheme('rgba(0, 0, 0, 0.5)', 'rgba(255, 255, 255, 0.5)')};
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulation-speed-label svg {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulation-speed-select {
|
||||||
|
width: 100%;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid ${cssManager.bdTheme('rgba(0, 0, 0, 0.15)', 'rgba(255, 255, 255, 0.15)')};
|
||||||
|
border-radius: 6px;
|
||||||
|
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.03)', 'rgba(0, 0, 0, 0.2)')};
|
||||||
|
color: ${cssManager.bdTheme('#1a1a1a', '#ffffff')};
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulation-speed-select:focus {
|
||||||
|
border-color: var(--geo-tool-active, #0084ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Voice toggle */
|
||||||
|
.simulation-voice {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.1)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulation-voice-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: ${cssManager.bdTheme('rgba(0, 0, 0, 0.7)', 'rgba(255, 255, 255, 0.8)')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulation-voice-label svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: var(--geo-tool-active, #0084ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulation-voice-toggle {
|
||||||
|
position: relative;
|
||||||
|
width: 40px;
|
||||||
|
height: 22px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 11px;
|
||||||
|
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.15)', 'rgba(255, 255, 255, 0.2)')};
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulation-voice-toggle.active {
|
||||||
|
background: var(--geo-tool-active, #0084ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulation-voice-toggle-thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulation-voice-toggle.active .simulation-voice-toggle-thumb {
|
||||||
|
transform: translateX(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress bar */
|
||||||
|
.simulation-progress {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulation-progress-label {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 11px;
|
||||||
|
color: ${cssManager.bdTheme('rgba(0, 0, 0, 0.5)', 'rgba(255, 255, 255, 0.5)')};
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulation-progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.1)')};
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulation-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--geo-tool-active, #0084ff);
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status text */
|
||||||
|
.simulation-status {
|
||||||
|
font-size: 12px;
|
||||||
|
color: ${cssManager.bdTheme('rgba(0, 0, 0, 0.5)', 'rgba(255, 255, 255, 0.5)')};
|
||||||
|
text-align: center;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulation-status.running {
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulation-status.paused {
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulation-status.completed {
|
||||||
|
color: var(--geo-tool-active, #0084ff);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { html, css, cssManager } from '@design.estate/dees-element';
|
import { html, css, cssManager } from '@design.estate/dees-element';
|
||||||
import '@design.estate/dees-wcctools/demotools';
|
import '@design.estate/dees-wcctools/demotools';
|
||||||
import type { DeesGeoMap } from './dees-geo-map.js';
|
import type { DeesGeoMap } from './dees-geo-map.js';
|
||||||
|
import { getSpeedPresets, type TSimulationSpeed } from './geo-map.mock-gps.js';
|
||||||
|
|
||||||
export const demoFunc = () => html`
|
export const demoFunc = () => html`
|
||||||
<style>
|
<style>
|
||||||
@@ -87,11 +88,42 @@ export const demoFunc = () => html`
|
|||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-button:hover {
|
.control-button:hover:not(:disabled) {
|
||||||
background: ${cssManager.bdTheme('#f0f0f0', '#333')};
|
background: ${cssManager.bdTheme('#f0f0f0', '#333')};
|
||||||
border-color: ${cssManager.bdTheme('#999', '#666')};
|
border-color: ${cssManager.bdTheme('#999', '#666')};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.control-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-button.active {
|
||||||
|
background: ${cssManager.bdTheme('#0066cc', '#0084ff')};
|
||||||
|
color: #fff;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-button.primary {
|
||||||
|
background: ${cssManager.bdTheme('#0066cc', '#0084ff')};
|
||||||
|
color: #fff;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-button.primary:hover:not(:disabled) {
|
||||||
|
background: ${cssManager.bdTheme('#0055aa', '#0073e6')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-button.danger {
|
||||||
|
background: ${cssManager.bdTheme('#dc2626', '#ef4444')};
|
||||||
|
color: #fff;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-button.danger:hover:not(:disabled) {
|
||||||
|
background: ${cssManager.bdTheme('#b91c1c', '#dc2626')};
|
||||||
|
}
|
||||||
|
|
||||||
.feature-display {
|
.feature-display {
|
||||||
background: ${cssManager.bdTheme('#f9f9f9', '#1e1e1e')};
|
background: ${cssManager.bdTheme('#f9f9f9', '#1e1e1e')};
|
||||||
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
|
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
|
||||||
@@ -132,6 +164,137 @@ export const demoFunc = () => html`
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Simulation Panel Styles */
|
||||||
|
.simulation-section {
|
||||||
|
background: ${cssManager.bdTheme('#f0f9ff', '#1a2633')};
|
||||||
|
border: 1px solid ${cssManager.bdTheme('#bae6fd', '#1e3a5f')};
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulation-controls-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sim-playback {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sim-speed {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sim-speed-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: ${cssManager.bdTheme('#666', '#999')};
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sim-speed-select {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid ${cssManager.bdTheme('#ccc', '#444')};
|
||||||
|
border-radius: 6px;
|
||||||
|
background: ${cssManager.bdTheme('#fff', '#2a2a2a')};
|
||||||
|
color: ${cssManager.bdTheme('#333', '#fff')};
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sim-voice {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sim-voice-checkbox {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sim-voice-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: ${cssManager.bdTheme('#333', '#fff')};
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sim-progress {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sim-progress-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sim-progress-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: ${cssManager.bdTheme('#666', '#999')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.sim-progress-value {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: ${cssManager.bdTheme('#333', '#fff')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.sim-progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.1)')};
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sim-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: ${cssManager.bdTheme('#0066cc', '#0084ff')};
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sim-status {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sim-status.idle {
|
||||||
|
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.05)', 'rgba(255, 255, 255, 0.05)')};
|
||||||
|
color: ${cssManager.bdTheme('#666', '#999')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.sim-status.running {
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
color: ${cssManager.bdTheme('#166534', '#86efac')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.sim-status.paused {
|
||||||
|
background: rgba(245, 158, 11, 0.15);
|
||||||
|
color: ${cssManager.bdTheme('#92400e', '#fcd34d')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.sim-status.completed {
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
color: ${cssManager.bdTheme('#1e40af', '#93c5fd')};
|
||||||
|
}
|
||||||
|
|
||||||
|
.sim-status.no-route {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
color: ${cssManager.bdTheme('#991b1b', '#fca5a5')};
|
||||||
|
}
|
||||||
`}
|
`}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
@@ -141,6 +304,59 @@ export const demoFunc = () => html`
|
|||||||
const eventLog = elementArg.querySelector('#event-log') as HTMLElement;
|
const eventLog = elementArg.querySelector('#event-log') as HTMLElement;
|
||||||
const featureJson = elementArg.querySelector('#feature-json') as HTMLElement;
|
const featureJson = elementArg.querySelector('#feature-json') as HTMLElement;
|
||||||
|
|
||||||
|
// Simulation state
|
||||||
|
let simulationState = {
|
||||||
|
isRunning: false,
|
||||||
|
isPaused: false,
|
||||||
|
progress: 0,
|
||||||
|
hasRoute: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSimulationUI = () => {
|
||||||
|
const startBtn = elementArg.querySelector('#sim-start') as HTMLButtonElement;
|
||||||
|
const pauseBtn = elementArg.querySelector('#sim-pause') as HTMLButtonElement;
|
||||||
|
const stopBtn = elementArg.querySelector('#sim-stop') as HTMLButtonElement;
|
||||||
|
const progressFill = elementArg.querySelector('#sim-progress-fill') as HTMLElement;
|
||||||
|
const progressValue = elementArg.querySelector('#sim-progress-value') as HTMLElement;
|
||||||
|
const statusEl = elementArg.querySelector('#sim-status') as HTMLElement;
|
||||||
|
|
||||||
|
if (!startBtn) return;
|
||||||
|
|
||||||
|
// Update button states
|
||||||
|
startBtn.disabled = !simulationState.hasRoute || (simulationState.isRunning && !simulationState.isPaused);
|
||||||
|
pauseBtn.disabled = !simulationState.isRunning || simulationState.isPaused;
|
||||||
|
stopBtn.disabled = !simulationState.isRunning && !simulationState.isPaused;
|
||||||
|
|
||||||
|
// Update progress
|
||||||
|
if (progressFill) {
|
||||||
|
progressFill.style.width = `${simulationState.progress}%`;
|
||||||
|
}
|
||||||
|
if (progressValue) {
|
||||||
|
progressValue.textContent = `${simulationState.progress.toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.className = 'sim-status';
|
||||||
|
if (!simulationState.hasRoute) {
|
||||||
|
statusEl.className += ' no-route';
|
||||||
|
statusEl.textContent = 'Calculate a route first to enable simulation';
|
||||||
|
} else if (simulationState.isRunning && !simulationState.isPaused) {
|
||||||
|
statusEl.className += ' running';
|
||||||
|
statusEl.textContent = 'Simulation running...';
|
||||||
|
} else if (simulationState.isPaused) {
|
||||||
|
statusEl.className += ' paused';
|
||||||
|
statusEl.textContent = 'Simulation paused';
|
||||||
|
} else if (simulationState.progress >= 100) {
|
||||||
|
statusEl.className += ' completed';
|
||||||
|
statusEl.textContent = 'Simulation completed!';
|
||||||
|
} else {
|
||||||
|
statusEl.className += ' idle';
|
||||||
|
statusEl.textContent = 'Ready to simulate';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const addLogEntry = (type: string, message: string) => {
|
const addLogEntry = (type: string, message: string) => {
|
||||||
const entry = document.createElement('div');
|
const entry = document.createElement('div');
|
||||||
entry.className = 'event-entry';
|
entry.className = 'event-entry';
|
||||||
@@ -157,6 +373,7 @@ export const demoFunc = () => html`
|
|||||||
if (map) {
|
if (map) {
|
||||||
map.addEventListener('map-ready', () => {
|
map.addEventListener('map-ready', () => {
|
||||||
addLogEntry('ready', 'Map initialized successfully');
|
addLogEntry('ready', 'Map initialized successfully');
|
||||||
|
updateSimulationUI();
|
||||||
});
|
});
|
||||||
|
|
||||||
map.addEventListener('draw-change', (e: CustomEvent) => {
|
map.addEventListener('draw-change', (e: CustomEvent) => {
|
||||||
@@ -183,9 +400,106 @@ export const demoFunc = () => html`
|
|||||||
const durationMin = Math.round(route.duration / 60);
|
const durationMin = Math.round(route.duration / 60);
|
||||||
addLogEntry('route', `${mode}: ${distKm} km, ${durationMin} min`);
|
addLogEntry('route', `${mode}: ${distKm} km, ${durationMin} min`);
|
||||||
console.log('Route calculated:', e.detail);
|
console.log('Route calculated:', e.detail);
|
||||||
|
|
||||||
|
// Update simulation state
|
||||||
|
simulationState.hasRoute = true;
|
||||||
|
simulationState.progress = 0;
|
||||||
|
updateSimulationUI();
|
||||||
|
|
||||||
|
// Create/update the simulator with the new route
|
||||||
|
const simulator = map.createMockGPSSimulator();
|
||||||
|
simulator.setRoute(route);
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addEventListener('guidance-event', (e: CustomEvent) => {
|
||||||
|
const event = e.detail;
|
||||||
|
addLogEntry('guidance', `${event.type}: ${event.instruction || 'Step ' + event.stepIndex}`);
|
||||||
|
console.log('Guidance event:', event);
|
||||||
|
|
||||||
|
// Update progress
|
||||||
|
const simulator = map.getMockGPSSimulator();
|
||||||
|
if (simulator) {
|
||||||
|
simulationState.progress = simulator.getProgress();
|
||||||
|
updateSimulationUI();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set up simulation controls
|
||||||
|
const setupSimulationControls = () => {
|
||||||
|
const startBtn = elementArg.querySelector('#sim-start') as HTMLButtonElement;
|
||||||
|
const pauseBtn = elementArg.querySelector('#sim-pause') as HTMLButtonElement;
|
||||||
|
const stopBtn = elementArg.querySelector('#sim-stop') as HTMLButtonElement;
|
||||||
|
const speedSelect = elementArg.querySelector('#sim-speed') as HTMLSelectElement;
|
||||||
|
const voiceCheckbox = elementArg.querySelector('#sim-voice') as HTMLInputElement;
|
||||||
|
|
||||||
|
if (startBtn && map) {
|
||||||
|
startBtn.addEventListener('click', () => {
|
||||||
|
let simulator = map.getMockGPSSimulator();
|
||||||
|
if (!simulator) {
|
||||||
|
simulator = map.createMockGPSSimulator();
|
||||||
|
const route = map.getNavigationState()?.route;
|
||||||
|
if (route) {
|
||||||
|
simulator.setRoute(route);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start guidance
|
||||||
|
map.startGuidance();
|
||||||
|
simulator.start();
|
||||||
|
|
||||||
|
simulationState.isRunning = true;
|
||||||
|
simulationState.isPaused = false;
|
||||||
|
updateSimulationUI();
|
||||||
|
addLogEntry('simulation', 'Started');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pauseBtn && map) {
|
||||||
|
pauseBtn.addEventListener('click', () => {
|
||||||
|
const simulator = map.getMockGPSSimulator();
|
||||||
|
if (simulator) {
|
||||||
|
simulator.pause();
|
||||||
|
simulationState.isPaused = true;
|
||||||
|
updateSimulationUI();
|
||||||
|
addLogEntry('simulation', 'Paused');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stopBtn && map) {
|
||||||
|
stopBtn.addEventListener('click', () => {
|
||||||
|
map.stopGuidance();
|
||||||
|
simulationState.isRunning = false;
|
||||||
|
simulationState.isPaused = false;
|
||||||
|
simulationState.progress = 0;
|
||||||
|
updateSimulationUI();
|
||||||
|
addLogEntry('simulation', 'Stopped');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (speedSelect && map) {
|
||||||
|
speedSelect.addEventListener('change', () => {
|
||||||
|
const speed = speedSelect.value as TSimulationSpeed;
|
||||||
|
const simulator = map.getMockGPSSimulator();
|
||||||
|
if (simulator) {
|
||||||
|
simulator.setSpeed(speed);
|
||||||
|
addLogEntry('simulation', `Speed changed to ${speedSelect.options[speedSelect.selectedIndex].text}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (voiceCheckbox && map) {
|
||||||
|
// Set initial state
|
||||||
|
voiceCheckbox.checked = true;
|
||||||
|
|
||||||
|
voiceCheckbox.addEventListener('change', () => {
|
||||||
|
map.setVoiceEnabled(voiceCheckbox.checked);
|
||||||
|
addLogEntry('simulation', `Voice ${voiceCheckbox.checked ? 'enabled' : 'disabled'}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Set up navigation buttons
|
// Set up navigation buttons
|
||||||
const locations: Record<string, [number, number]> = {
|
const locations: Record<string, [number, number]> = {
|
||||||
paris: [2.3522, 48.8566],
|
paris: [2.3522, 48.8566],
|
||||||
@@ -257,6 +571,12 @@ export const demoFunc = () => html`
|
|||||||
updateFeatureDisplay();
|
updateFeatureDisplay();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize simulation controls after a tick
|
||||||
|
setTimeout(() => {
|
||||||
|
setupSimulationControls();
|
||||||
|
updateSimulationUI();
|
||||||
|
}, 100);
|
||||||
}}>
|
}}>
|
||||||
<div class="demo-section">
|
<div class="demo-section">
|
||||||
<h2 class="demo-title">Interactive Map with Drawing Tools</h2>
|
<h2 class="demo-title">Interactive Map with Drawing Tools</h2>
|
||||||
@@ -281,6 +601,58 @@ export const demoFunc = () => html`
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="demo-section simulation-section">
|
||||||
|
<h2 class="demo-title">GPS Simulation & Voice Navigation</h2>
|
||||||
|
<p class="demo-description">
|
||||||
|
Calculate a route using the navigation panel, then use these controls to simulate GPS movement
|
||||||
|
along the route with voice-guided turn-by-turn instructions.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="simulation-controls-grid">
|
||||||
|
<div class="sim-playback">
|
||||||
|
<button class="control-button primary" id="sim-start" disabled>
|
||||||
|
▶ Start
|
||||||
|
</button>
|
||||||
|
<button class="control-button" id="sim-pause" disabled>
|
||||||
|
⏸ Pause
|
||||||
|
</button>
|
||||||
|
<button class="control-button danger" id="sim-stop" disabled>
|
||||||
|
⏹ Stop
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sim-speed">
|
||||||
|
<span class="sim-speed-label">Speed:</span>
|
||||||
|
<select class="sim-speed-select" id="sim-speed">
|
||||||
|
${getSpeedPresets().map(preset => html`
|
||||||
|
<option value="${preset.id}" ?selected=${preset.id === 'city'}>
|
||||||
|
${preset.name} (${preset.kmh} km/h)
|
||||||
|
</option>
|
||||||
|
`)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sim-voice">
|
||||||
|
<input type="checkbox" id="sim-voice" class="sim-voice-checkbox" checked />
|
||||||
|
<label for="sim-voice" class="sim-voice-label">Voice Guidance</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sim-progress">
|
||||||
|
<div class="sim-progress-header">
|
||||||
|
<span class="sim-progress-label">Route Progress</span>
|
||||||
|
<span class="sim-progress-value" id="sim-progress-value">0%</span>
|
||||||
|
</div>
|
||||||
|
<div class="sim-progress-bar">
|
||||||
|
<div class="sim-progress-fill" id="sim-progress-fill" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sim-status no-route" id="sim-status">
|
||||||
|
Calculate a route first to enable simulation
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="demo-section">
|
<div class="demo-section">
|
||||||
<h2 class="demo-title">Quick Navigation</h2>
|
<h2 class="demo-title">Quick Navigation</h2>
|
||||||
<div class="locations-grid">
|
<div class="locations-grid">
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
domtools,
|
domtools,
|
||||||
} from '@design.estate/dees-element';
|
} from '@design.estate/dees-element';
|
||||||
import { DeesContextmenu } from '@design.estate/dees-catalog';
|
import { DeesContextmenu } from '@design.estate/dees-catalog';
|
||||||
import { geoComponentStyles, mapContainerStyles, toolbarStyles, searchStyles, navigationStyles, trafficStyles, headerToolbarStyles } from '../../00componentstyles.js';
|
import { geoComponentStyles, mapContainerStyles, toolbarStyles, searchStyles, navigationStyles, trafficStyles, headerToolbarStyles, guidanceStyles, maplibreMarkerStyles } from '../../00componentstyles.js';
|
||||||
|
|
||||||
// MapLibre imports
|
// MapLibre imports
|
||||||
import maplibregl from 'maplibre-gl';
|
import maplibregl from 'maplibre-gl';
|
||||||
@@ -33,9 +33,12 @@ import { TerraDrawMapLibreGLAdapter } from 'terra-draw-maplibre-gl-adapter';
|
|||||||
// Modular imports
|
// Modular imports
|
||||||
import { renderIcon } from './geo-map.icons.js';
|
import { renderIcon } from './geo-map.icons.js';
|
||||||
import { SearchController, type INominatimResult, type IAddressSelectedEvent } from './geo-map.search.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 { NavigationController, type TNavigationMode, type INavigationState, type IRouteCalculatedEvent, type IOSRMRoute } from './geo-map.navigation.js';
|
||||||
import { TrafficController } from './geo-map.traffic.js';
|
import { TrafficController } from './geo-map.traffic.js';
|
||||||
import { HereTrafficProvider, type ITrafficProvider } from './geo-map.traffic.providers.js';
|
import { HereTrafficProvider, type ITrafficProvider } from './geo-map.traffic.providers.js';
|
||||||
|
import { NavigationGuideController, type IGPSPosition, type IGuidanceEvent, type INavigationGuideState, type INavigationCameraConfig } from './geo-map.navigation-guide.js';
|
||||||
|
import { MockGPSSimulator, type TSimulationSpeed, type IMockGPSConfig } from './geo-map.mock-gps.js';
|
||||||
|
import type { IVoiceConfig } from './geo-map.voice.js';
|
||||||
|
|
||||||
// Re-export types for external consumers
|
// Re-export types for external consumers
|
||||||
export type { INominatimResult, IAddressSelectedEvent } from './geo-map.search.js';
|
export type { INominatimResult, IAddressSelectedEvent } from './geo-map.search.js';
|
||||||
@@ -48,6 +51,9 @@ export type {
|
|||||||
IRouteCalculatedEvent,
|
IRouteCalculatedEvent,
|
||||||
} from './geo-map.navigation.js';
|
} from './geo-map.navigation.js';
|
||||||
export type { ITrafficProvider, ITrafficFlowData, ITrafficAwareRoute } from './geo-map.traffic.providers.js';
|
export type { ITrafficProvider, ITrafficFlowData, ITrafficAwareRoute } from './geo-map.traffic.providers.js';
|
||||||
|
export type { IGPSPosition, IGuidanceEvent, INavigationGuideState, TGuidanceEventType, INavigationCameraConfig } from './geo-map.navigation-guide.js';
|
||||||
|
export type { IVoiceConfig } from './geo-map.voice.js';
|
||||||
|
export type { TSimulationSpeed, IMockGPSConfig } from './geo-map.mock-gps.js';
|
||||||
|
|
||||||
export type TDrawTool = 'polygon' | 'rectangle' | 'point' | 'linestring' | 'circle' | 'freehand' | 'select' | 'static';
|
export type TDrawTool = 'polygon' | 'rectangle' | 'point' | 'linestring' | 'circle' | 'freehand' | 'select' | 'static';
|
||||||
|
|
||||||
@@ -128,6 +134,13 @@ export class DeesGeoMap extends DeesElement {
|
|||||||
@property({ type: String })
|
@property({ type: String })
|
||||||
accessor trafficApiKey: string = '';
|
accessor trafficApiKey: string = '';
|
||||||
|
|
||||||
|
// Guidance properties
|
||||||
|
@property({ type: Boolean })
|
||||||
|
accessor enableGuidance: boolean = false;
|
||||||
|
|
||||||
|
@property({ type: Object })
|
||||||
|
accessor voiceConfig: Partial<IVoiceConfig> = {};
|
||||||
|
|
||||||
// ─── State ──────────────────────────────────────────────────────────────────
|
// ─── State ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
@@ -149,6 +162,8 @@ export class DeesGeoMap extends DeesElement {
|
|||||||
private searchController: SearchController | null = null;
|
private searchController: SearchController | null = null;
|
||||||
private navigationController: NavigationController | null = null;
|
private navigationController: NavigationController | null = null;
|
||||||
private trafficController: TrafficController | null = null;
|
private trafficController: TrafficController | null = null;
|
||||||
|
private guidanceController: NavigationGuideController | null = null;
|
||||||
|
private mockGPSSimulator: MockGPSSimulator | null = null;
|
||||||
|
|
||||||
// ─── Styles ─────────────────────────────────────────────────────────────────
|
// ─── Styles ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -161,6 +176,8 @@ export class DeesGeoMap extends DeesElement {
|
|||||||
navigationStyles,
|
navigationStyles,
|
||||||
trafficStyles,
|
trafficStyles,
|
||||||
headerToolbarStyles,
|
headerToolbarStyles,
|
||||||
|
guidanceStyles,
|
||||||
|
maplibreMarkerStyles,
|
||||||
css`
|
css`
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -348,6 +365,22 @@ export class DeesGeoMap extends DeesElement {
|
|||||||
hereProvider.configure({ apiKey: this.trafficApiKey });
|
hereProvider.configure({ apiKey: this.trafficApiKey });
|
||||||
this.trafficController.setProvider(hereProvider);
|
this.trafficController.setProvider(hereProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize guidance controller
|
||||||
|
this.guidanceController = new NavigationGuideController(
|
||||||
|
{
|
||||||
|
onGuidanceEvent: (event) => {
|
||||||
|
this.dispatchEvent(new CustomEvent<IGuidanceEvent>('guidance-event', {
|
||||||
|
detail: event,
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
onRequestUpdate: () => this.requestUpdate(),
|
||||||
|
getMap: () => this.map,
|
||||||
|
},
|
||||||
|
this.voiceConfig
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Map Initialization ─────────────────────────────────────────────────────
|
// ─── Map Initialization ─────────────────────────────────────────────────────
|
||||||
@@ -772,6 +805,199 @@ export class DeesGeoMap extends DeesElement {
|
|||||||
return this.trafficController;
|
return this.trafficController;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Guidance Public Methods ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set current GPS position for navigation guidance
|
||||||
|
* @param coords - [lng, lat] coordinates
|
||||||
|
* @param heading - Optional heading in degrees (0 = North)
|
||||||
|
* @param speed - Optional speed in meters/second
|
||||||
|
*/
|
||||||
|
public setPosition(coords: [number, number], heading?: number, speed?: number): void {
|
||||||
|
this.guidanceController?.setPosition(coords[0], coords[1], heading, speed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start voice-guided navigation for the current route
|
||||||
|
*/
|
||||||
|
public startGuidance(): void {
|
||||||
|
const route = this.navigationController?.navigationState?.route;
|
||||||
|
if (route && this.guidanceController) {
|
||||||
|
this.guidanceController.startGuidance(route);
|
||||||
|
} else {
|
||||||
|
console.warn('[dees-geo-map] Cannot start guidance: no route calculated');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop voice-guided navigation
|
||||||
|
*/
|
||||||
|
public stopGuidance(): void {
|
||||||
|
this.guidanceController?.stopGuidance();
|
||||||
|
this.mockGPSSimulator?.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable or disable voice guidance
|
||||||
|
*/
|
||||||
|
public setVoiceEnabled(enabled: boolean): void {
|
||||||
|
this.guidanceController?.setVoiceEnabled(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if voice guidance is enabled
|
||||||
|
*/
|
||||||
|
public isVoiceEnabled(): boolean {
|
||||||
|
return this.guidanceController?.isVoiceEnabled() ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current guidance state
|
||||||
|
*/
|
||||||
|
public getGuidanceState(): INavigationGuideState | null {
|
||||||
|
return this.guidanceController?.state ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if actively navigating
|
||||||
|
*/
|
||||||
|
public isNavigating(): boolean {
|
||||||
|
return this.guidanceController?.isNavigating() ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a mock GPS simulator for testing/demo
|
||||||
|
* The simulator emits position updates along the current route
|
||||||
|
*/
|
||||||
|
public createMockGPSSimulator(config?: Partial<IMockGPSConfig>): MockGPSSimulator {
|
||||||
|
// Clean up existing simulator
|
||||||
|
if (this.mockGPSSimulator) {
|
||||||
|
this.mockGPSSimulator.cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.mockGPSSimulator = new MockGPSSimulator(
|
||||||
|
{
|
||||||
|
onPositionUpdate: (position: IGPSPosition) => {
|
||||||
|
if (this.guidanceController) {
|
||||||
|
this.guidanceController.updatePosition(position);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSimulationStart: () => {
|
||||||
|
console.log('[MockGPSSimulator] Simulation started');
|
||||||
|
},
|
||||||
|
onSimulationPause: () => {
|
||||||
|
console.log('[MockGPSSimulator] Simulation paused');
|
||||||
|
},
|
||||||
|
onSimulationStop: () => {
|
||||||
|
console.log('[MockGPSSimulator] Simulation stopped');
|
||||||
|
},
|
||||||
|
onSimulationComplete: () => {
|
||||||
|
console.log('[MockGPSSimulator] Simulation complete');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
config
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set the route if available
|
||||||
|
const route = this.navigationController?.navigationState?.route;
|
||||||
|
if (route) {
|
||||||
|
this.mockGPSSimulator.setRoute(route);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.mockGPSSimulator;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the mock GPS simulator (if created)
|
||||||
|
*/
|
||||||
|
public getMockGPSSimulator(): MockGPSSimulator | null {
|
||||||
|
return this.mockGPSSimulator;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the guidance controller for advanced usage
|
||||||
|
*/
|
||||||
|
public getGuidanceController(): NavigationGuideController | null {
|
||||||
|
return this.guidanceController;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Navigation Camera Control Methods ─────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable or disable camera following the GPS position during navigation
|
||||||
|
* @param enabled - Whether the camera should follow the position
|
||||||
|
*/
|
||||||
|
public setNavigationFollowPosition(enabled: boolean): void {
|
||||||
|
this.guidanceController?.setFollowPosition(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if camera is following position during navigation
|
||||||
|
*/
|
||||||
|
public isNavigationFollowingPosition(): boolean {
|
||||||
|
return this.guidanceController?.isFollowingPosition() ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable or disable camera rotating with heading during navigation
|
||||||
|
* @param enabled - Whether the camera should rotate with heading
|
||||||
|
*/
|
||||||
|
public setNavigationFollowBearing(enabled: boolean): void {
|
||||||
|
this.guidanceController?.setFollowBearing(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if camera is following bearing during navigation
|
||||||
|
*/
|
||||||
|
public isNavigationFollowingBearing(): boolean {
|
||||||
|
return this.guidanceController?.isFollowingBearing() ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the navigation camera pitch (3D tilt angle)
|
||||||
|
* @param pitch - Angle in degrees (0 = flat, 60 = tilted for 3D view)
|
||||||
|
*/
|
||||||
|
public setNavigationPitch(pitch: number): void {
|
||||||
|
this.guidanceController?.setPitch(pitch);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current navigation pitch setting
|
||||||
|
*/
|
||||||
|
public getNavigationPitch(): number {
|
||||||
|
return this.guidanceController?.getPitch() ?? 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the navigation zoom level
|
||||||
|
* @param zoom - Zoom level (typically 15-19 for street-level navigation)
|
||||||
|
*/
|
||||||
|
public setNavigationZoom(zoom: number): void {
|
||||||
|
this.guidanceController?.setZoom(zoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current navigation zoom setting
|
||||||
|
*/
|
||||||
|
public getNavigationZoom(): number {
|
||||||
|
return this.guidanceController?.getZoom() ?? 17;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the full navigation camera configuration
|
||||||
|
*/
|
||||||
|
public getNavigationCameraConfig(): INavigationCameraConfig | null {
|
||||||
|
return this.guidanceController?.getCameraConfig() ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set navigation camera configuration
|
||||||
|
* @param config - Partial camera configuration to apply
|
||||||
|
*/
|
||||||
|
public setNavigationCameraConfig(config: Partial<INavigationCameraConfig>): void {
|
||||||
|
this.guidanceController?.setCameraConfig(config);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Private Methods ────────────────────────────────────────────────────────
|
// ─── Private Methods ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private ensureMaplibreCssLoaded() {
|
private ensureMaplibreCssLoaded() {
|
||||||
@@ -793,6 +1019,8 @@ export class DeesGeoMap extends DeesElement {
|
|||||||
// Clean up controllers
|
// Clean up controllers
|
||||||
this.navigationController?.cleanup();
|
this.navigationController?.cleanup();
|
||||||
this.trafficController?.cleanup();
|
this.trafficController?.cleanup();
|
||||||
|
this.guidanceController?.cleanup();
|
||||||
|
this.mockGPSSimulator?.cleanup();
|
||||||
|
|
||||||
if (this.map) {
|
if (this.map) {
|
||||||
this.map.remove();
|
this.map.remove();
|
||||||
@@ -923,6 +1151,9 @@ export class DeesGeoMap extends DeesElement {
|
|||||||
<!-- Bottom Right: Empty (zoom in header) -->
|
<!-- Bottom Right: Empty (zoom in header) -->
|
||||||
<div class="overlay-bottom-right"></div>
|
<div class="overlay-bottom-right"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Guidance Panel (shown during active navigation) -->
|
||||||
|
${this.guidanceController?.isNavigating() ? this.guidanceController.render() : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right Sidebar: Draw Panel -->
|
<!-- Right Sidebar: Draw Panel -->
|
||||||
|
|||||||
@@ -44,6 +44,16 @@ export const GEO_MAP_ICONS: Record<string, TemplateResult> = {
|
|||||||
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>`,
|
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>`,
|
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>`,
|
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>`,
|
||||||
|
|
||||||
|
// Guidance / Simulation
|
||||||
|
volume: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/></svg>`,
|
||||||
|
volumeOff: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg>`,
|
||||||
|
play: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3" fill="currentColor"/></svg>`,
|
||||||
|
pause: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="4" width="4" height="16" fill="currentColor"/><rect x="14" y="4" width="4" height="16" fill="currentColor"/></svg>`,
|
||||||
|
stop: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2" fill="currentColor"/></svg>`,
|
||||||
|
gpsPosition: 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="3" fill="currentColor"/><circle cx="12" cy="12" r="8"/><line x1="12" y1="2" x2="12" y2="4"/><line x1="12" y1="20" x2="12" y2="22"/><line x1="2" y1="12" x2="4" y2="12"/><line x1="20" y1="12" x2="22" y2="12"/></svg>`,
|
||||||
|
speed: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a10 10 0 1 0 10 10"/><path d="M12 12l4-8"/><circle cx="12" cy="12" r="2"/></svg>`,
|
||||||
|
simulate: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h2"/><path d="M18 12h2"/><circle cx="12" cy="12" r="4"/><path d="M12 4v2"/><path d="M12 18v2"/><path d="M7.05 7.05l1.4 1.4"/><path d="M15.55 15.55l1.4 1.4"/><path d="M15.55 7.05l1.4-1.4"/><path d="M7.05 16.95l1.4-1.4"/></svg>`,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
494
ts_web/elements/00group-map/dees-geo-map/geo-map.mock-gps.ts
Normal file
@@ -0,0 +1,494 @@
|
|||||||
|
import type { IOSRMRoute } from './geo-map.navigation.js';
|
||||||
|
import type { IGPSPosition } from './geo-map.navigation-guide.js';
|
||||||
|
|
||||||
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type TSimulationSpeed = 'walking' | 'cycling' | 'city' | 'highway' | 'custom';
|
||||||
|
|
||||||
|
export interface ISimulationSpeedConfig {
|
||||||
|
walking: number; // m/s
|
||||||
|
cycling: number;
|
||||||
|
city: number;
|
||||||
|
highway: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMockGPSConfig {
|
||||||
|
speed: TSimulationSpeed;
|
||||||
|
customSpeedMps?: number; // Custom speed in m/s
|
||||||
|
updateInterval: number; // ms between position updates
|
||||||
|
jitterMeters: number; // Random GPS jitter amount
|
||||||
|
startFromBeginning: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMockGPSCallbacks {
|
||||||
|
onPositionUpdate: (position: IGPSPosition) => void;
|
||||||
|
onSimulationStart?: () => void;
|
||||||
|
onSimulationPause?: () => void;
|
||||||
|
onSimulationStop?: () => void;
|
||||||
|
onSimulationComplete?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Speed Presets ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const SPEED_PRESETS: ISimulationSpeedConfig = {
|
||||||
|
walking: 1.4, // ~5 km/h
|
||||||
|
cycling: 5.5, // ~20 km/h
|
||||||
|
city: 13.9, // ~50 km/h
|
||||||
|
highway: 27.8, // ~100 km/h
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Utility Functions ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate distance between two points using Haversine formula
|
||||||
|
*/
|
||||||
|
function haversineDistance(
|
||||||
|
lat1: number, lng1: number,
|
||||||
|
lat2: number, lng2: number
|
||||||
|
): number {
|
||||||
|
const R = 6371000; // Earth's radius in meters
|
||||||
|
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||||
|
const dLng = (lng2 - lng1) * Math.PI / 180;
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||||
|
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
||||||
|
Math.sin(dLng / 2) * Math.sin(dLng / 2);
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
return R * c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate bearing between two points (in degrees, 0 = North)
|
||||||
|
*/
|
||||||
|
function calculateBearing(
|
||||||
|
lat1: number, lng1: number,
|
||||||
|
lat2: number, lng2: number
|
||||||
|
): number {
|
||||||
|
const dLng = (lng2 - lng1) * Math.PI / 180;
|
||||||
|
const lat1Rad = lat1 * Math.PI / 180;
|
||||||
|
const lat2Rad = lat2 * Math.PI / 180;
|
||||||
|
|
||||||
|
const y = Math.sin(dLng) * Math.cos(lat2Rad);
|
||||||
|
const x = Math.cos(lat1Rad) * Math.sin(lat2Rad) -
|
||||||
|
Math.sin(lat1Rad) * Math.cos(lat2Rad) * Math.cos(dLng);
|
||||||
|
|
||||||
|
const bearing = Math.atan2(y, x) * 180 / Math.PI;
|
||||||
|
return (bearing + 360) % 360;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interpolate between two coordinates
|
||||||
|
*/
|
||||||
|
function interpolateCoord(
|
||||||
|
start: [number, number],
|
||||||
|
end: [number, number],
|
||||||
|
fraction: number
|
||||||
|
): [number, number] {
|
||||||
|
return [
|
||||||
|
start[0] + (end[0] - start[0]) * fraction,
|
||||||
|
start[1] + (end[1] - start[1]) * fraction,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add random GPS jitter to coordinates
|
||||||
|
*/
|
||||||
|
function addJitter(lng: number, lat: number, jitterMeters: number): [number, number] {
|
||||||
|
// Convert jitter meters to approximate degrees
|
||||||
|
// 1 degree latitude ≈ 111,000 meters
|
||||||
|
// 1 degree longitude ≈ 111,000 * cos(latitude) meters
|
||||||
|
const jitterLat = ((Math.random() - 0.5) * 2 * jitterMeters) / 111000;
|
||||||
|
const jitterLng = ((Math.random() - 0.5) * 2 * jitterMeters) / (111000 * Math.cos(lat * Math.PI / 180));
|
||||||
|
|
||||||
|
return [lng + jitterLng, lat + jitterLat];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── MockGPSSimulator ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simulates GPS positions along a route for testing/demo purposes
|
||||||
|
*/
|
||||||
|
export class MockGPSSimulator {
|
||||||
|
// Configuration
|
||||||
|
private config: IMockGPSConfig = {
|
||||||
|
speed: 'city',
|
||||||
|
updateInterval: 1000,
|
||||||
|
jitterMeters: 2,
|
||||||
|
startFromBeginning: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Route data
|
||||||
|
private route: IOSRMRoute | null = null;
|
||||||
|
private coordinates: [number, number][] = [];
|
||||||
|
private segmentDistances: number[] = [];
|
||||||
|
private totalDistance: number = 0;
|
||||||
|
|
||||||
|
// Simulation state
|
||||||
|
private isRunning: boolean = false;
|
||||||
|
private isPaused: boolean = false;
|
||||||
|
private currentDistanceTraveled: number = 0;
|
||||||
|
private lastPosition: IGPSPosition | null = null;
|
||||||
|
private intervalId: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
|
private callbacks: IMockGPSCallbacks;
|
||||||
|
|
||||||
|
constructor(callbacks: IMockGPSCallbacks, config?: Partial<IMockGPSConfig>) {
|
||||||
|
this.callbacks = callbacks;
|
||||||
|
if (config) {
|
||||||
|
this.configure(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Configuration ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure simulator settings
|
||||||
|
*/
|
||||||
|
public configure(config: Partial<IMockGPSConfig>): void {
|
||||||
|
this.config = { ...this.config, ...config };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current configuration
|
||||||
|
*/
|
||||||
|
public getConfig(): IMockGPSConfig {
|
||||||
|
return { ...this.config };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set simulation speed
|
||||||
|
*/
|
||||||
|
public setSpeed(speed: TSimulationSpeed, customSpeedMps?: number): void {
|
||||||
|
this.config.speed = speed;
|
||||||
|
if (speed === 'custom' && customSpeedMps !== undefined) {
|
||||||
|
this.config.customSpeedMps = customSpeedMps;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current speed in m/s
|
||||||
|
*/
|
||||||
|
public getSpeedMps(): number {
|
||||||
|
if (this.config.speed === 'custom' && this.config.customSpeedMps !== undefined) {
|
||||||
|
return this.config.customSpeedMps;
|
||||||
|
}
|
||||||
|
return SPEED_PRESETS[this.config.speed] || SPEED_PRESETS.city;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get speed in km/h
|
||||||
|
*/
|
||||||
|
public getSpeedKmh(): number {
|
||||||
|
return this.getSpeedMps() * 3.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Route Setup ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the route to simulate
|
||||||
|
*/
|
||||||
|
public setRoute(route: IOSRMRoute): void {
|
||||||
|
this.route = route;
|
||||||
|
this.coordinates = route.geometry.coordinates as [number, number][];
|
||||||
|
|
||||||
|
// Pre-calculate segment distances
|
||||||
|
this.segmentDistances = [];
|
||||||
|
this.totalDistance = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < this.coordinates.length - 1; i++) {
|
||||||
|
const [lng1, lat1] = this.coordinates[i];
|
||||||
|
const [lng2, lat2] = this.coordinates[i + 1];
|
||||||
|
const distance = haversineDistance(lat1, lng1, lat2, lng2);
|
||||||
|
this.segmentDistances.push(distance);
|
||||||
|
this.totalDistance += distance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset state
|
||||||
|
this.currentDistanceTraveled = 0;
|
||||||
|
this.lastPosition = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total route distance
|
||||||
|
*/
|
||||||
|
public getTotalDistance(): number {
|
||||||
|
return this.totalDistance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current distance traveled
|
||||||
|
*/
|
||||||
|
public getDistanceTraveled(): number {
|
||||||
|
return this.currentDistanceTraveled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get progress as percentage (0-100)
|
||||||
|
*/
|
||||||
|
public getProgress(): number {
|
||||||
|
if (this.totalDistance === 0) return 0;
|
||||||
|
return (this.currentDistanceTraveled / this.totalDistance) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Simulation Control ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the simulation
|
||||||
|
*/
|
||||||
|
public start(): void {
|
||||||
|
if (!this.route || this.coordinates.length < 2) {
|
||||||
|
console.warn('[MockGPSSimulator] No route set or route too short');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isRunning && !this.isPaused) {
|
||||||
|
return; // Already running
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isPaused) {
|
||||||
|
// Resume from paused state
|
||||||
|
this.isPaused = false;
|
||||||
|
} else {
|
||||||
|
// Fresh start
|
||||||
|
if (this.config.startFromBeginning) {
|
||||||
|
this.currentDistanceTraveled = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isRunning = true;
|
||||||
|
this.callbacks.onSimulationStart?.();
|
||||||
|
|
||||||
|
// Start interval
|
||||||
|
this.intervalId = setInterval(() => {
|
||||||
|
this.tick();
|
||||||
|
}, this.config.updateInterval);
|
||||||
|
|
||||||
|
// Emit initial position
|
||||||
|
this.tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause the simulation
|
||||||
|
*/
|
||||||
|
public pause(): void {
|
||||||
|
if (!this.isRunning || this.isPaused) return;
|
||||||
|
|
||||||
|
this.isPaused = true;
|
||||||
|
|
||||||
|
if (this.intervalId) {
|
||||||
|
clearInterval(this.intervalId);
|
||||||
|
this.intervalId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.callbacks.onSimulationPause?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the simulation
|
||||||
|
*/
|
||||||
|
public stop(): void {
|
||||||
|
this.isRunning = false;
|
||||||
|
this.isPaused = false;
|
||||||
|
|
||||||
|
if (this.intervalId) {
|
||||||
|
clearInterval(this.intervalId);
|
||||||
|
this.intervalId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentDistanceTraveled = 0;
|
||||||
|
this.lastPosition = null;
|
||||||
|
|
||||||
|
this.callbacks.onSimulationStop?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if simulation is running
|
||||||
|
*/
|
||||||
|
public isSimulationRunning(): boolean {
|
||||||
|
return this.isRunning && !this.isPaused;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if simulation is paused
|
||||||
|
*/
|
||||||
|
public isSimulationPaused(): boolean {
|
||||||
|
return this.isPaused;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jump to a specific point in the route (by percentage)
|
||||||
|
*/
|
||||||
|
public jumpToProgress(percentage: number): void {
|
||||||
|
const clamped = Math.max(0, Math.min(100, percentage));
|
||||||
|
this.currentDistanceTraveled = (clamped / 100) * this.totalDistance;
|
||||||
|
|
||||||
|
// Emit the new position immediately
|
||||||
|
if (this.isRunning) {
|
||||||
|
this.emitCurrentPosition();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Simulation Tick ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process one simulation tick
|
||||||
|
*/
|
||||||
|
private tick(): void {
|
||||||
|
if (!this.isRunning || this.isPaused) return;
|
||||||
|
|
||||||
|
// Calculate distance to travel this tick
|
||||||
|
const speedMps = this.getSpeedMps();
|
||||||
|
const tickDuration = this.config.updateInterval / 1000; // seconds
|
||||||
|
const distanceThisTick = speedMps * tickDuration;
|
||||||
|
|
||||||
|
// Update distance traveled
|
||||||
|
this.currentDistanceTraveled += distanceThisTick;
|
||||||
|
|
||||||
|
// Check if we've reached the end
|
||||||
|
if (this.currentDistanceTraveled >= this.totalDistance) {
|
||||||
|
this.currentDistanceTraveled = this.totalDistance;
|
||||||
|
this.emitCurrentPosition();
|
||||||
|
this.completeSimulation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit current position
|
||||||
|
this.emitCurrentPosition();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit the current interpolated position
|
||||||
|
*/
|
||||||
|
private emitCurrentPosition(): void {
|
||||||
|
const position = this.calculatePosition(this.currentDistanceTraveled);
|
||||||
|
if (position) {
|
||||||
|
this.lastPosition = position;
|
||||||
|
this.callbacks.onPositionUpdate(position);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate position at a given distance along the route
|
||||||
|
*/
|
||||||
|
private calculatePosition(distanceAlongRoute: number): IGPSPosition | null {
|
||||||
|
if (this.coordinates.length < 2) return null;
|
||||||
|
|
||||||
|
// Find which segment we're on
|
||||||
|
let accumulatedDistance = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < this.segmentDistances.length; i++) {
|
||||||
|
const segmentDistance = this.segmentDistances[i];
|
||||||
|
|
||||||
|
if (accumulatedDistance + segmentDistance >= distanceAlongRoute) {
|
||||||
|
// We're on this segment
|
||||||
|
const distanceIntoSegment = distanceAlongRoute - accumulatedDistance;
|
||||||
|
const fraction = segmentDistance > 0 ? distanceIntoSegment / segmentDistance : 0;
|
||||||
|
|
||||||
|
const start = this.coordinates[i];
|
||||||
|
const end = this.coordinates[i + 1];
|
||||||
|
|
||||||
|
// Interpolate position
|
||||||
|
const [lng, lat] = interpolateCoord(start, end, fraction);
|
||||||
|
|
||||||
|
// Add jitter
|
||||||
|
const [jitteredLng, jitteredLat] = addJitter(lng, lat, this.config.jitterMeters);
|
||||||
|
|
||||||
|
// Calculate heading
|
||||||
|
const heading = calculateBearing(start[1], start[0], end[1], end[0]);
|
||||||
|
|
||||||
|
// Calculate speed (current configured speed)
|
||||||
|
const speed = this.getSpeedMps();
|
||||||
|
|
||||||
|
return {
|
||||||
|
lng: jitteredLng,
|
||||||
|
lat: jitteredLat,
|
||||||
|
heading,
|
||||||
|
speed,
|
||||||
|
accuracy: this.config.jitterMeters,
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
accumulatedDistance += segmentDistance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're past the end, return the last coordinate
|
||||||
|
const lastCoord = this.coordinates[this.coordinates.length - 1];
|
||||||
|
const prevCoord = this.coordinates[this.coordinates.length - 2];
|
||||||
|
const heading = calculateBearing(prevCoord[1], prevCoord[0], lastCoord[1], lastCoord[0]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
lng: lastCoord[0],
|
||||||
|
lat: lastCoord[1],
|
||||||
|
heading,
|
||||||
|
speed: 0,
|
||||||
|
accuracy: this.config.jitterMeters,
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete the simulation
|
||||||
|
*/
|
||||||
|
private completeSimulation(): void {
|
||||||
|
this.isRunning = false;
|
||||||
|
this.isPaused = false;
|
||||||
|
|
||||||
|
if (this.intervalId) {
|
||||||
|
clearInterval(this.intervalId);
|
||||||
|
this.intervalId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.callbacks.onSimulationComplete?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Cleanup ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up resources
|
||||||
|
*/
|
||||||
|
public cleanup(): void {
|
||||||
|
this.stop();
|
||||||
|
this.route = null;
|
||||||
|
this.coordinates = [];
|
||||||
|
this.segmentDistances = [];
|
||||||
|
this.totalDistance = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Speed Helper Functions ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get display name for speed preset
|
||||||
|
*/
|
||||||
|
export function getSpeedDisplayName(speed: TSimulationSpeed): string {
|
||||||
|
const names: Record<TSimulationSpeed, string> = {
|
||||||
|
walking: 'Walking',
|
||||||
|
cycling: 'Cycling',
|
||||||
|
city: 'City Driving',
|
||||||
|
highway: 'Highway',
|
||||||
|
custom: 'Custom',
|
||||||
|
};
|
||||||
|
return names[speed];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get speed in km/h for a preset
|
||||||
|
*/
|
||||||
|
export function getSpeedKmh(speed: TSimulationSpeed): number {
|
||||||
|
if (speed === 'custom') return 0;
|
||||||
|
return SPEED_PRESETS[speed] * 3.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all speed presets with display info
|
||||||
|
*/
|
||||||
|
export function getSpeedPresets(): Array<{ id: TSimulationSpeed; name: string; kmh: number }> {
|
||||||
|
return [
|
||||||
|
{ id: 'walking', name: 'Walking', kmh: 5 },
|
||||||
|
{ id: 'cycling', name: 'Cycling', kmh: 20 },
|
||||||
|
{ id: 'city', name: 'City Driving', kmh: 50 },
|
||||||
|
{ id: 'highway', name: 'Highway', kmh: 100 },
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,911 @@
|
|||||||
|
import { html, type TemplateResult } from '@design.estate/dees-element';
|
||||||
|
import maplibregl from 'maplibre-gl';
|
||||||
|
import { VoiceSynthesisManager, type IVoiceConfig } from './geo-map.voice.js';
|
||||||
|
import type { IOSRMRoute, IOSRMStep } from './geo-map.navigation.js';
|
||||||
|
|
||||||
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface IGPSPosition {
|
||||||
|
lng: number;
|
||||||
|
lat: number;
|
||||||
|
heading?: number; // 0-360 degrees, 0 = North
|
||||||
|
speed?: number; // meters/second
|
||||||
|
accuracy?: number; // meters
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface INavigationGuideState {
|
||||||
|
isNavigating: boolean;
|
||||||
|
currentStepIndex: number;
|
||||||
|
distanceToNextManeuver: number; // meters
|
||||||
|
distanceRemaining: number; // meters
|
||||||
|
timeRemaining: number; // seconds
|
||||||
|
currentPosition: IGPSPosition | null;
|
||||||
|
isOffRoute: boolean;
|
||||||
|
hasArrived: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TGuidanceEventType =
|
||||||
|
| 'approach-maneuver' // "In 200m, turn left"
|
||||||
|
| 'execute-maneuver' // "Turn left now"
|
||||||
|
| 'step-change' // Moved to next step
|
||||||
|
| 'off-route' // User deviated from route
|
||||||
|
| 'arrived' // Reached destination
|
||||||
|
| 'position-updated'; // Position was updated
|
||||||
|
|
||||||
|
export interface IGuidanceEvent {
|
||||||
|
type: TGuidanceEventType;
|
||||||
|
position: IGPSPosition;
|
||||||
|
stepIndex: number;
|
||||||
|
step?: IOSRMStep;
|
||||||
|
distanceToManeuver: number;
|
||||||
|
instruction?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface INavigationGuideCallbacks {
|
||||||
|
onGuidanceEvent: (event: IGuidanceEvent) => void;
|
||||||
|
onRequestUpdate: () => void;
|
||||||
|
getMap: () => maplibregl.Map | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Distance Thresholds ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const GUIDANCE_THRESHOLDS = {
|
||||||
|
FAR: 500, // "In 500 meters..."
|
||||||
|
APPROACHING: 200, // "In 200 meters..."
|
||||||
|
NEAR: 50, // "Turn left ahead"
|
||||||
|
AT_MANEUVER: 20, // "Turn left now"
|
||||||
|
OFF_ROUTE: 50, // Distance from route line to trigger off-route
|
||||||
|
ARRIVED: 30, // Distance from destination to trigger arrival
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Utility Functions ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate distance between two points using Haversine formula
|
||||||
|
*/
|
||||||
|
function haversineDistance(
|
||||||
|
lat1: number, lng1: number,
|
||||||
|
lat2: number, lng2: number
|
||||||
|
): number {
|
||||||
|
const R = 6371000; // Earth's radius in meters
|
||||||
|
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||||
|
const dLng = (lng2 - lng1) * Math.PI / 180;
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||||
|
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
||||||
|
Math.sin(dLng / 2) * Math.sin(dLng / 2);
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
return R * c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate distance from a point to a line segment
|
||||||
|
*/
|
||||||
|
function pointToLineDistance(
|
||||||
|
px: number, py: number,
|
||||||
|
x1: number, y1: number,
|
||||||
|
x2: number, y2: number
|
||||||
|
): number {
|
||||||
|
const A = px - x1;
|
||||||
|
const B = py - y1;
|
||||||
|
const C = x2 - x1;
|
||||||
|
const D = y2 - y1;
|
||||||
|
|
||||||
|
const dot = A * C + B * D;
|
||||||
|
const lenSq = C * C + D * D;
|
||||||
|
let param = -1;
|
||||||
|
|
||||||
|
if (lenSq !== 0) {
|
||||||
|
param = dot / lenSq;
|
||||||
|
}
|
||||||
|
|
||||||
|
let xx: number, yy: number;
|
||||||
|
|
||||||
|
if (param < 0) {
|
||||||
|
xx = x1;
|
||||||
|
yy = y1;
|
||||||
|
} else if (param > 1) {
|
||||||
|
xx = x2;
|
||||||
|
yy = y2;
|
||||||
|
} else {
|
||||||
|
xx = x1 + param * C;
|
||||||
|
yy = y1 + param * D;
|
||||||
|
}
|
||||||
|
|
||||||
|
return haversineDistance(py, px, yy, xx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the minimum distance from a point to a polyline
|
||||||
|
*/
|
||||||
|
function pointToPolylineDistance(
|
||||||
|
px: number, py: number,
|
||||||
|
coordinates: [number, number][]
|
||||||
|
): number {
|
||||||
|
let minDist = Infinity;
|
||||||
|
|
||||||
|
for (let i = 0; i < coordinates.length - 1; i++) {
|
||||||
|
const [x1, y1] = coordinates[i];
|
||||||
|
const [x2, y2] = coordinates[i + 1];
|
||||||
|
const dist = pointToLineDistance(px, py, x1, y1, x2, y2);
|
||||||
|
if (dist < minDist) {
|
||||||
|
minDist = dist;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return minDist;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format distance for voice instructions
|
||||||
|
*/
|
||||||
|
function formatDistanceForVoice(meters: number): string {
|
||||||
|
if (meters < 100) {
|
||||||
|
return `${Math.round(meters / 10) * 10} meters`;
|
||||||
|
} else if (meters < 1000) {
|
||||||
|
return `${Math.round(meters / 50) * 50} meters`;
|
||||||
|
} else {
|
||||||
|
const km = meters / 1000;
|
||||||
|
if (km < 10) {
|
||||||
|
return `${km.toFixed(1)} kilometers`;
|
||||||
|
}
|
||||||
|
return `${Math.round(km)} kilometers`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format maneuver type for voice
|
||||||
|
*/
|
||||||
|
function formatManeuverForVoice(type: string, modifier?: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'turn':
|
||||||
|
if (modifier === 'left') return 'turn left';
|
||||||
|
if (modifier === 'right') return 'turn right';
|
||||||
|
if (modifier === 'slight left') return 'bear left';
|
||||||
|
if (modifier === 'slight right') return 'bear right';
|
||||||
|
if (modifier === 'sharp left') return 'take a sharp left';
|
||||||
|
if (modifier === 'sharp right') return 'take a sharp right';
|
||||||
|
return `turn ${modifier || ''}`.trim();
|
||||||
|
case 'depart':
|
||||||
|
return 'head forward';
|
||||||
|
case 'arrive':
|
||||||
|
return 'arrive at your destination';
|
||||||
|
case 'merge':
|
||||||
|
return `merge ${modifier || ''}`.trim();
|
||||||
|
case 'fork':
|
||||||
|
if (modifier === 'left') return 'keep left at the fork';
|
||||||
|
if (modifier === 'right') return 'keep right at the fork';
|
||||||
|
return 'take the fork';
|
||||||
|
case 'roundabout':
|
||||||
|
case 'rotary':
|
||||||
|
return 'enter the roundabout';
|
||||||
|
case 'continue':
|
||||||
|
return 'continue straight';
|
||||||
|
case 'end of road':
|
||||||
|
return `at the end of the road, turn ${modifier || 'around'}`;
|
||||||
|
default:
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Camera Configuration ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface INavigationCameraConfig {
|
||||||
|
followPosition: boolean; // Camera follows GPS position (default: true)
|
||||||
|
followBearing: boolean; // Camera rotates with heading (default: true)
|
||||||
|
pitch: number; // 3D tilt angle in degrees (default: 60)
|
||||||
|
zoom: number; // Navigation zoom level (default: 17)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── NavigationGuideController ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller for real-time GPS navigation guidance
|
||||||
|
* Tracks position, step progression, and fires guidance events
|
||||||
|
*/
|
||||||
|
export class NavigationGuideController {
|
||||||
|
// State
|
||||||
|
public state: INavigationGuideState = {
|
||||||
|
isNavigating: false,
|
||||||
|
currentStepIndex: 0,
|
||||||
|
distanceToNextManeuver: 0,
|
||||||
|
distanceRemaining: 0,
|
||||||
|
timeRemaining: 0,
|
||||||
|
currentPosition: null,
|
||||||
|
isOffRoute: false,
|
||||||
|
hasArrived: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Route data
|
||||||
|
private route: IOSRMRoute | null = null;
|
||||||
|
private allSteps: IOSRMStep[] = [];
|
||||||
|
|
||||||
|
// Voice synthesis
|
||||||
|
private voiceManager: VoiceSynthesisManager;
|
||||||
|
|
||||||
|
// Position marker
|
||||||
|
private positionMarker: maplibregl.Marker | null = null;
|
||||||
|
|
||||||
|
// Track last position time for smooth animation timing
|
||||||
|
private lastPositionTimestamp: number = 0;
|
||||||
|
|
||||||
|
// Camera configuration for navigation mode
|
||||||
|
private cameraConfig: INavigationCameraConfig = {
|
||||||
|
followPosition: true,
|
||||||
|
followBearing: true,
|
||||||
|
pitch: 60,
|
||||||
|
zoom: 17,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Announcement tracking to avoid repetition
|
||||||
|
private lastAnnouncedThreshold: number | null = null;
|
||||||
|
private lastAnnouncedStepIndex: number = -1;
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
|
private callbacks: INavigationGuideCallbacks;
|
||||||
|
|
||||||
|
constructor(callbacks: INavigationGuideCallbacks, voiceConfig?: Partial<IVoiceConfig>) {
|
||||||
|
this.callbacks = callbacks;
|
||||||
|
this.voiceManager = new VoiceSynthesisManager(voiceConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Configuration ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure voice settings
|
||||||
|
*/
|
||||||
|
public configureVoice(config: Partial<IVoiceConfig>): void {
|
||||||
|
this.voiceManager.configure(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable/disable voice
|
||||||
|
*/
|
||||||
|
public setVoiceEnabled(enabled: boolean): void {
|
||||||
|
if (enabled) {
|
||||||
|
this.voiceManager.enable();
|
||||||
|
} else {
|
||||||
|
this.voiceManager.disable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if voice is enabled
|
||||||
|
*/
|
||||||
|
public isVoiceEnabled(): boolean {
|
||||||
|
return this.voiceManager.isEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get voice manager for direct access
|
||||||
|
*/
|
||||||
|
public getVoiceManager(): VoiceSynthesisManager {
|
||||||
|
return this.voiceManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Camera Configuration ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable/disable camera following the GPS position
|
||||||
|
*/
|
||||||
|
public setFollowPosition(enabled: boolean): void {
|
||||||
|
this.cameraConfig.followPosition = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if camera is following position
|
||||||
|
*/
|
||||||
|
public isFollowingPosition(): boolean {
|
||||||
|
return this.cameraConfig.followPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable/disable camera rotating with heading
|
||||||
|
*/
|
||||||
|
public setFollowBearing(enabled: boolean): void {
|
||||||
|
this.cameraConfig.followBearing = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if camera is following bearing
|
||||||
|
*/
|
||||||
|
public isFollowingBearing(): boolean {
|
||||||
|
return this.cameraConfig.followBearing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the navigation camera pitch (3D tilt angle)
|
||||||
|
* @param pitch - Angle in degrees (0 = flat, 60 = tilted)
|
||||||
|
*/
|
||||||
|
public setPitch(pitch: number): void {
|
||||||
|
this.cameraConfig.pitch = Math.max(0, Math.min(85, pitch));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current pitch setting
|
||||||
|
*/
|
||||||
|
public getPitch(): number {
|
||||||
|
return this.cameraConfig.pitch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the navigation zoom level
|
||||||
|
* @param zoom - Zoom level (typically 15-19 for street-level navigation)
|
||||||
|
*/
|
||||||
|
public setZoom(zoom: number): void {
|
||||||
|
this.cameraConfig.zoom = Math.max(1, Math.min(22, zoom));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current zoom setting
|
||||||
|
*/
|
||||||
|
public getZoom(): number {
|
||||||
|
return this.cameraConfig.zoom;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the full camera configuration
|
||||||
|
*/
|
||||||
|
public getCameraConfig(): INavigationCameraConfig {
|
||||||
|
return { ...this.cameraConfig };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the full camera configuration
|
||||||
|
*/
|
||||||
|
public setCameraConfig(config: Partial<INavigationCameraConfig>): void {
|
||||||
|
if (config.followPosition !== undefined) {
|
||||||
|
this.cameraConfig.followPosition = config.followPosition;
|
||||||
|
}
|
||||||
|
if (config.followBearing !== undefined) {
|
||||||
|
this.cameraConfig.followBearing = config.followBearing;
|
||||||
|
}
|
||||||
|
if (config.pitch !== undefined) {
|
||||||
|
this.cameraConfig.pitch = Math.max(0, Math.min(85, config.pitch));
|
||||||
|
}
|
||||||
|
if (config.zoom !== undefined) {
|
||||||
|
this.cameraConfig.zoom = Math.max(1, Math.min(22, config.zoom));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Navigation Lifecycle ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start navigation guidance for a route
|
||||||
|
*/
|
||||||
|
public startGuidance(route: IOSRMRoute): void {
|
||||||
|
this.route = route;
|
||||||
|
this.allSteps = route.legs.flatMap(leg => leg.steps);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
isNavigating: true,
|
||||||
|
currentStepIndex: 0,
|
||||||
|
distanceToNextManeuver: this.allSteps[0]?.distance ?? 0,
|
||||||
|
distanceRemaining: route.distance,
|
||||||
|
timeRemaining: route.duration,
|
||||||
|
currentPosition: null,
|
||||||
|
isOffRoute: false,
|
||||||
|
hasArrived: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.lastAnnouncedThreshold = null;
|
||||||
|
this.lastAnnouncedStepIndex = -1;
|
||||||
|
|
||||||
|
this.callbacks.onRequestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop navigation guidance
|
||||||
|
*/
|
||||||
|
public stopGuidance(): void {
|
||||||
|
this.state = {
|
||||||
|
isNavigating: false,
|
||||||
|
currentStepIndex: 0,
|
||||||
|
distanceToNextManeuver: 0,
|
||||||
|
distanceRemaining: 0,
|
||||||
|
timeRemaining: 0,
|
||||||
|
currentPosition: null,
|
||||||
|
isOffRoute: false,
|
||||||
|
hasArrived: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.route = null;
|
||||||
|
this.allSteps = [];
|
||||||
|
this.lastAnnouncedThreshold = null;
|
||||||
|
this.lastAnnouncedStepIndex = -1;
|
||||||
|
this.lastPositionTimestamp = 0;
|
||||||
|
|
||||||
|
this.voiceManager.stop();
|
||||||
|
this.removePositionMarker();
|
||||||
|
|
||||||
|
this.callbacks.onRequestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if currently navigating
|
||||||
|
*/
|
||||||
|
public isNavigating(): boolean {
|
||||||
|
return this.state.isNavigating;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Position Updates ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update position from external GPS source
|
||||||
|
*/
|
||||||
|
public updatePosition(position: IGPSPosition): void {
|
||||||
|
if (!this.state.isNavigating || !this.route) return;
|
||||||
|
|
||||||
|
this.state.currentPosition = position;
|
||||||
|
|
||||||
|
// Update position marker
|
||||||
|
this.updatePositionMarker(position);
|
||||||
|
|
||||||
|
// Check if off-route
|
||||||
|
const distanceFromRoute = this.calculateDistanceFromRoute(position);
|
||||||
|
const wasOffRoute = this.state.isOffRoute;
|
||||||
|
this.state.isOffRoute = distanceFromRoute > GUIDANCE_THRESHOLDS.OFF_ROUTE;
|
||||||
|
|
||||||
|
if (this.state.isOffRoute && !wasOffRoute) {
|
||||||
|
this.handleOffRoute(position);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find current step and calculate distances
|
||||||
|
this.updateStepProgress(position);
|
||||||
|
|
||||||
|
// Check for arrival
|
||||||
|
this.checkArrival(position);
|
||||||
|
|
||||||
|
// Fire position updated event
|
||||||
|
this.emitGuidanceEvent('position-updated', position);
|
||||||
|
|
||||||
|
this.callbacks.onRequestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set position (convenience method for external use)
|
||||||
|
*/
|
||||||
|
public setPosition(lng: number, lat: number, heading?: number, speed?: number): void {
|
||||||
|
this.updatePosition({
|
||||||
|
lng,
|
||||||
|
lat,
|
||||||
|
heading,
|
||||||
|
speed,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Step Progress Tracking ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update step progress based on current position
|
||||||
|
*/
|
||||||
|
private updateStepProgress(position: IGPSPosition): void {
|
||||||
|
if (this.allSteps.length === 0) return;
|
||||||
|
|
||||||
|
// Find the closest step maneuver point
|
||||||
|
let closestStepIndex = this.state.currentStepIndex;
|
||||||
|
let minDistance = Infinity;
|
||||||
|
|
||||||
|
// Look ahead a few steps (don't go backwards)
|
||||||
|
const searchEnd = Math.min(this.state.currentStepIndex + 3, this.allSteps.length);
|
||||||
|
|
||||||
|
for (let i = this.state.currentStepIndex; i < searchEnd; i++) {
|
||||||
|
const step = this.allSteps[i];
|
||||||
|
const [maneuverLng, maneuverLat] = step.maneuver.location;
|
||||||
|
const distance = haversineDistance(position.lat, position.lng, maneuverLat, maneuverLng);
|
||||||
|
|
||||||
|
if (distance < minDistance) {
|
||||||
|
minDistance = distance;
|
||||||
|
closestStepIndex = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we've passed the current maneuver (close to it)
|
||||||
|
const currentStep = this.allSteps[this.state.currentStepIndex];
|
||||||
|
if (currentStep) {
|
||||||
|
const [maneuverLng, maneuverLat] = currentStep.maneuver.location;
|
||||||
|
const distanceToManeuver = haversineDistance(position.lat, position.lng, maneuverLat, maneuverLng);
|
||||||
|
|
||||||
|
// If we're very close to the maneuver or past it, advance to next step
|
||||||
|
if (distanceToManeuver < GUIDANCE_THRESHOLDS.AT_MANEUVER &&
|
||||||
|
this.state.currentStepIndex < this.allSteps.length - 1) {
|
||||||
|
this.advanceToNextStep(position);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update distance to maneuver
|
||||||
|
this.state.distanceToNextManeuver = distanceToManeuver;
|
||||||
|
|
||||||
|
// Check guidance thresholds
|
||||||
|
this.checkGuidanceThresholds(position, distanceToManeuver, currentStep);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate remaining distance and time
|
||||||
|
this.calculateRemaining(position);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advance to the next step
|
||||||
|
*/
|
||||||
|
private advanceToNextStep(position: IGPSPosition): void {
|
||||||
|
const previousStepIndex = this.state.currentStepIndex;
|
||||||
|
this.state.currentStepIndex++;
|
||||||
|
|
||||||
|
if (this.state.currentStepIndex >= this.allSteps.length) {
|
||||||
|
// Reached end of route
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newStep = this.allSteps[this.state.currentStepIndex];
|
||||||
|
|
||||||
|
// Reset announcement tracking for new step
|
||||||
|
this.lastAnnouncedThreshold = null;
|
||||||
|
|
||||||
|
// Emit step change event
|
||||||
|
this.emitGuidanceEvent('step-change', position, newStep);
|
||||||
|
|
||||||
|
// Announce the new step if it's not just departing
|
||||||
|
if (this.lastAnnouncedStepIndex !== previousStepIndex) {
|
||||||
|
this.lastAnnouncedStepIndex = previousStepIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check guidance thresholds and announce instructions
|
||||||
|
*/
|
||||||
|
private checkGuidanceThresholds(position: IGPSPosition, distance: number, step: IOSRMStep): void {
|
||||||
|
// Determine current threshold zone
|
||||||
|
let currentThreshold: number | null = null;
|
||||||
|
|
||||||
|
if (distance <= GUIDANCE_THRESHOLDS.AT_MANEUVER) {
|
||||||
|
currentThreshold = GUIDANCE_THRESHOLDS.AT_MANEUVER;
|
||||||
|
} else if (distance <= GUIDANCE_THRESHOLDS.NEAR) {
|
||||||
|
currentThreshold = GUIDANCE_THRESHOLDS.NEAR;
|
||||||
|
} else if (distance <= GUIDANCE_THRESHOLDS.APPROACHING) {
|
||||||
|
currentThreshold = GUIDANCE_THRESHOLDS.APPROACHING;
|
||||||
|
} else if (distance <= GUIDANCE_THRESHOLDS.FAR) {
|
||||||
|
currentThreshold = GUIDANCE_THRESHOLDS.FAR;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only announce if we've entered a new threshold zone
|
||||||
|
if (currentThreshold !== null && currentThreshold !== this.lastAnnouncedThreshold) {
|
||||||
|
this.lastAnnouncedThreshold = currentThreshold;
|
||||||
|
this.announceManeuver(position, distance, step, currentThreshold);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Announce maneuver based on threshold
|
||||||
|
*/
|
||||||
|
private announceManeuver(position: IGPSPosition, distance: number, step: IOSRMStep, threshold: number): void {
|
||||||
|
const maneuver = formatManeuverForVoice(step.maneuver.type, step.maneuver.modifier);
|
||||||
|
const streetName = step.name !== '' ? step.name : undefined;
|
||||||
|
|
||||||
|
if (threshold === GUIDANCE_THRESHOLDS.AT_MANEUVER) {
|
||||||
|
// Execute maneuver
|
||||||
|
this.voiceManager.speakManeuver(maneuver, true);
|
||||||
|
this.emitGuidanceEvent('execute-maneuver', position, step, maneuver);
|
||||||
|
} else if (threshold === GUIDANCE_THRESHOLDS.NEAR) {
|
||||||
|
// "Turn left ahead"
|
||||||
|
const instruction = `${maneuver} ahead`;
|
||||||
|
this.voiceManager.speak(instruction);
|
||||||
|
this.emitGuidanceEvent('approach-maneuver', position, step, instruction);
|
||||||
|
} else {
|
||||||
|
// Approach - "In 200 meters, turn left"
|
||||||
|
const distanceStr = formatDistanceForVoice(distance);
|
||||||
|
this.voiceManager.speakApproach(distanceStr, maneuver, streetName);
|
||||||
|
const instruction = `In ${distanceStr}, ${maneuver}${streetName ? ` onto ${streetName}` : ''}`;
|
||||||
|
this.emitGuidanceEvent('approach-maneuver', position, step, instruction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Route Calculations ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate distance from current position to route line
|
||||||
|
*/
|
||||||
|
private calculateDistanceFromRoute(position: IGPSPosition): number {
|
||||||
|
if (!this.route) return 0;
|
||||||
|
|
||||||
|
const coords = this.route.geometry.coordinates as [number, number][];
|
||||||
|
return pointToPolylineDistance(position.lng, position.lat, coords);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate remaining distance and time
|
||||||
|
*/
|
||||||
|
private calculateRemaining(position: IGPSPosition): void {
|
||||||
|
if (!this.route || this.allSteps.length === 0) return;
|
||||||
|
|
||||||
|
// Sum distance/duration of remaining steps
|
||||||
|
let remainingDistance = 0;
|
||||||
|
let remainingDuration = 0;
|
||||||
|
|
||||||
|
for (let i = this.state.currentStepIndex; i < this.allSteps.length; i++) {
|
||||||
|
remainingDistance += this.allSteps[i].distance;
|
||||||
|
remainingDuration += this.allSteps[i].duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtract distance already traveled in current step
|
||||||
|
if (this.state.currentStepIndex < this.allSteps.length) {
|
||||||
|
const currentStep = this.allSteps[this.state.currentStepIndex];
|
||||||
|
const stepProgress = 1 - (this.state.distanceToNextManeuver / currentStep.distance);
|
||||||
|
const progressDistance = currentStep.distance * Math.max(0, Math.min(1, stepProgress));
|
||||||
|
remainingDistance -= progressDistance;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.distanceRemaining = Math.max(0, remainingDistance);
|
||||||
|
this.state.timeRemaining = Math.max(0, remainingDuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user has arrived at destination
|
||||||
|
*/
|
||||||
|
private checkArrival(position: IGPSPosition): void {
|
||||||
|
if (!this.route || this.state.hasArrived) return;
|
||||||
|
|
||||||
|
// Get the last coordinate of the route (destination)
|
||||||
|
const coords = this.route.geometry.coordinates;
|
||||||
|
const destination = coords[coords.length - 1] as [number, number];
|
||||||
|
|
||||||
|
const distanceToDestination = haversineDistance(
|
||||||
|
position.lat, position.lng,
|
||||||
|
destination[1], destination[0]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (distanceToDestination <= GUIDANCE_THRESHOLDS.ARRIVED) {
|
||||||
|
this.state.hasArrived = true;
|
||||||
|
this.voiceManager.speakArrival();
|
||||||
|
this.emitGuidanceEvent('arrived', position);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle off-route scenario
|
||||||
|
*/
|
||||||
|
private handleOffRoute(position: IGPSPosition): void {
|
||||||
|
this.voiceManager.speakOffRoute();
|
||||||
|
this.emitGuidanceEvent('off-route', position);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Position Marker ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update position marker on map and move camera to follow
|
||||||
|
*/
|
||||||
|
private updatePositionMarker(position: IGPSPosition): void {
|
||||||
|
const map = this.callbacks.getMap();
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
if (!this.positionMarker) {
|
||||||
|
// Create marker element with inline styles (Shadow DOM fix)
|
||||||
|
// MapLibre markers are added to the light DOM, so CSS classes from
|
||||||
|
// Shadow DOM won't apply. Using inline styles ensures proper styling.
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.style.cssText = 'width: 32px; height: 32px; cursor: default; filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));';
|
||||||
|
el.innerHTML = this.createPositionMarkerSVG();
|
||||||
|
|
||||||
|
this.positionMarker = new maplibregl.Marker({
|
||||||
|
element: el,
|
||||||
|
anchor: 'center',
|
||||||
|
rotationAlignment: 'map',
|
||||||
|
pitchAlignment: 'viewport',
|
||||||
|
})
|
||||||
|
.setLngLat([position.lng, position.lat])
|
||||||
|
.addTo(map);
|
||||||
|
} else {
|
||||||
|
this.positionMarker.setLngLat([position.lng, position.lat]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply rotation via MapLibre's marker rotation (correct anchor handling)
|
||||||
|
if (position.heading !== undefined) {
|
||||||
|
this.positionMarker.setRotation(position.heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate animation duration based on time since last update
|
||||||
|
// This creates smooth, continuous animation that matches the GPS update interval
|
||||||
|
const now = Date.now();
|
||||||
|
const timeSinceLastUpdate = this.lastPositionTimestamp > 0
|
||||||
|
? now - this.lastPositionTimestamp
|
||||||
|
: 1000;
|
||||||
|
this.lastPositionTimestamp = now;
|
||||||
|
|
||||||
|
// Clamp duration: min 100ms (prevents jarring), max 2000ms (prevents lag)
|
||||||
|
const animationDuration = Math.max(100, Math.min(2000, timeSinceLastUpdate));
|
||||||
|
|
||||||
|
// Move camera to follow position
|
||||||
|
if (this.cameraConfig.followPosition) {
|
||||||
|
const bearing = this.cameraConfig.followBearing && position.heading !== undefined
|
||||||
|
? position.heading
|
||||||
|
: map.getBearing();
|
||||||
|
|
||||||
|
map.easeTo({
|
||||||
|
center: [position.lng, position.lat],
|
||||||
|
bearing,
|
||||||
|
pitch: this.cameraConfig.pitch,
|
||||||
|
zoom: this.cameraConfig.zoom,
|
||||||
|
duration: animationDuration,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create SVG for position marker with heading indicator
|
||||||
|
* Note: Rotation is handled by MapLibre's marker.setRotation() for correct anchor handling
|
||||||
|
*/
|
||||||
|
private createPositionMarkerSVG(): string {
|
||||||
|
return `
|
||||||
|
<svg width="32" height="32" viewBox="0 0 32 32">
|
||||||
|
<!-- Blue circle -->
|
||||||
|
<circle cx="16" cy="16" r="10" fill="#3b82f6" stroke="#fff" stroke-width="3"/>
|
||||||
|
<!-- Heading indicator triangle (pointing up/north, rotation handled by MapLibre) -->
|
||||||
|
<polygon points="16,2 12,10 20,10" fill="#fff"/>
|
||||||
|
<!-- Accuracy circle (semi-transparent) -->
|
||||||
|
<circle cx="16" cy="16" r="14" fill="rgba(59, 130, 246, 0.2)" stroke="none"/>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove position marker from map
|
||||||
|
*/
|
||||||
|
private removePositionMarker(): void {
|
||||||
|
if (this.positionMarker) {
|
||||||
|
this.positionMarker.remove();
|
||||||
|
this.positionMarker = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Event Emission ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit a guidance event
|
||||||
|
*/
|
||||||
|
private emitGuidanceEvent(
|
||||||
|
type: TGuidanceEventType,
|
||||||
|
position: IGPSPosition,
|
||||||
|
step?: IOSRMStep,
|
||||||
|
instruction?: string
|
||||||
|
): void {
|
||||||
|
const event: IGuidanceEvent = {
|
||||||
|
type,
|
||||||
|
position,
|
||||||
|
stepIndex: this.state.currentStepIndex,
|
||||||
|
step,
|
||||||
|
distanceToManeuver: this.state.distanceToNextManeuver,
|
||||||
|
instruction,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.callbacks.onGuidanceEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Render ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render guidance panel UI
|
||||||
|
*/
|
||||||
|
public render(): TemplateResult {
|
||||||
|
if (!this.state.isNavigating || !this.route) {
|
||||||
|
return html``;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentStep = this.allSteps[this.state.currentStepIndex];
|
||||||
|
const distance = this.formatDistance(this.state.distanceToNextManeuver);
|
||||||
|
const maneuverIcon = this.getManeuverIcon(currentStep?.maneuver.type, currentStep?.maneuver.modifier);
|
||||||
|
const instruction = currentStep ? this.formatInstruction(currentStep) : '';
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="guidance-panel">
|
||||||
|
<div class="guidance-maneuver">
|
||||||
|
<div class="guidance-maneuver-icon">${maneuverIcon}</div>
|
||||||
|
<div class="guidance-maneuver-distance">${distance}</div>
|
||||||
|
</div>
|
||||||
|
<div class="guidance-instruction">${instruction}</div>
|
||||||
|
<div class="guidance-remaining">
|
||||||
|
${this.formatDistance(this.state.distanceRemaining)} remaining
|
||||||
|
•
|
||||||
|
${this.formatDuration(this.state.timeRemaining)}
|
||||||
|
</div>
|
||||||
|
${this.state.isOffRoute ? html`
|
||||||
|
<div class="guidance-off-route">
|
||||||
|
Off route - recalculating...
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Formatting Utilities ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format distance for display
|
||||||
|
*/
|
||||||
|
private formatDistance(meters: number): string {
|
||||||
|
if (meters < 1000) {
|
||||||
|
return `${Math.round(meters)} m`;
|
||||||
|
}
|
||||||
|
return `${(meters / 1000).toFixed(1)} km`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format duration for display
|
||||||
|
*/
|
||||||
|
private formatDuration(seconds: number): string {
|
||||||
|
if (seconds < 60) {
|
||||||
|
return `${Math.round(seconds)} sec`;
|
||||||
|
}
|
||||||
|
if (seconds < 3600) {
|
||||||
|
return `${Math.round(seconds / 60)} min`;
|
||||||
|
}
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const mins = Math.round((seconds % 3600) / 60);
|
||||||
|
return mins > 0 ? `${hours} hr ${mins} min` : `${hours} hr`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get maneuver icon
|
||||||
|
*/
|
||||||
|
private getManeuverIcon(type?: string, modifier?: string): string {
|
||||||
|
if (!type) return '➡';
|
||||||
|
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
'depart': '⬆️',
|
||||||
|
'arrive': '🏁',
|
||||||
|
'turn-left': '↰',
|
||||||
|
'turn-right': '↱',
|
||||||
|
'turn-slight left': '↖',
|
||||||
|
'turn-slight right': '↗',
|
||||||
|
'turn-sharp left': '⬅',
|
||||||
|
'turn-sharp right': '➡',
|
||||||
|
'continue-straight': '⬆️',
|
||||||
|
'continue': '⬆️',
|
||||||
|
'roundabout': '🔄',
|
||||||
|
'rotary': '🔄',
|
||||||
|
'merge': '⤵️',
|
||||||
|
'fork-left': '↖',
|
||||||
|
'fork-right': '↗',
|
||||||
|
};
|
||||||
|
|
||||||
|
const key = modifier ? `${type}-${modifier}` : type;
|
||||||
|
return icons[key] || icons[type] || '➡';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format step instruction
|
||||||
|
*/
|
||||||
|
private formatInstruction(step: IOSRMStep): string {
|
||||||
|
const { type, modifier } = step.maneuver;
|
||||||
|
const name = step.name || 'unnamed road';
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'depart':
|
||||||
|
return `Head ${modifier || 'forward'} on ${name}`;
|
||||||
|
case 'arrive':
|
||||||
|
return 'Arrive at your destination';
|
||||||
|
case 'turn':
|
||||||
|
return `Turn ${modifier || ''} onto ${name}`;
|
||||||
|
case 'continue':
|
||||||
|
return `Continue on ${name}`;
|
||||||
|
case 'merge':
|
||||||
|
return `Merge ${modifier || ''} onto ${name}`;
|
||||||
|
case 'roundabout':
|
||||||
|
case 'rotary':
|
||||||
|
return `At the roundabout, take the exit onto ${name}`;
|
||||||
|
default:
|
||||||
|
return `${type} on ${name}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Cleanup ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up resources
|
||||||
|
*/
|
||||||
|
public cleanup(): void {
|
||||||
|
this.stopGuidance();
|
||||||
|
this.removePositionMarker();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -417,7 +417,7 @@ export class NavigationController {
|
|||||||
el.className = 'nav-marker nav-marker-start';
|
el.className = 'nav-marker nav-marker-start';
|
||||||
el.innerHTML = `<svg viewBox="0 0 24 24" fill="#22c55e" stroke="#fff" stroke-width="2"><circle cx="12" cy="12" r="8"/></svg>`;
|
el.innerHTML = `<svg viewBox="0 0 24 24" fill="#22c55e" stroke="#fff" stroke-width="2"><circle cx="12" cy="12" r="8"/></svg>`;
|
||||||
el.style.cssText = 'width: 24px; height: 24px; cursor: pointer;';
|
el.style.cssText = 'width: 24px; height: 24px; cursor: pointer;';
|
||||||
this.startMarker = new maplibregl.Marker({ element: el, anchor: 'center' })
|
this.startMarker = new maplibregl.Marker({ element: el, anchor: 'center', pitchAlignment: 'viewport' })
|
||||||
.setLngLat(this.navigationState.startPoint)
|
.setLngLat(this.navigationState.startPoint)
|
||||||
.addTo(map);
|
.addTo(map);
|
||||||
} else {
|
} else {
|
||||||
@@ -435,7 +435,7 @@ export class NavigationController {
|
|||||||
el.className = 'nav-marker nav-marker-end';
|
el.className = 'nav-marker nav-marker-end';
|
||||||
el.innerHTML = `<svg viewBox="0 0 24 24" fill="#ef4444" stroke="#fff" stroke-width="2"><circle cx="12" cy="12" r="8"/></svg>`;
|
el.innerHTML = `<svg viewBox="0 0 24 24" fill="#ef4444" stroke="#fff" stroke-width="2"><circle cx="12" cy="12" r="8"/></svg>`;
|
||||||
el.style.cssText = 'width: 24px; height: 24px; cursor: pointer;';
|
el.style.cssText = 'width: 24px; height: 24px; cursor: pointer;';
|
||||||
this.endMarker = new maplibregl.Marker({ element: el, anchor: 'center' })
|
this.endMarker = new maplibregl.Marker({ element: el, anchor: 'center', pitchAlignment: 'viewport' })
|
||||||
.setLngLat(this.navigationState.endPoint)
|
.setLngLat(this.navigationState.endPoint)
|
||||||
.addTo(map);
|
.addTo(map);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
337
ts_web/elements/00group-map/dees-geo-map/geo-map.voice.ts
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
/**
|
||||||
|
* Voice synthesis manager for turn-by-turn navigation
|
||||||
|
* Uses Web Speech API for voice instructions
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface IVoiceConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
language: string;
|
||||||
|
rate: number; // 0.1 - 10, default 1
|
||||||
|
pitch: number; // 0 - 2, default 1
|
||||||
|
volume: number; // 0 - 1, default 1
|
||||||
|
voiceName?: string; // Specific voice to use (optional)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IVoiceQueueItem {
|
||||||
|
text: string;
|
||||||
|
priority: 'normal' | 'urgent';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── VoiceSynthesisManager ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manager for voice synthesis using Web Speech API
|
||||||
|
* Provides queue-based speech with interrupt capability
|
||||||
|
*/
|
||||||
|
export class VoiceSynthesisManager {
|
||||||
|
private config: IVoiceConfig = {
|
||||||
|
enabled: true,
|
||||||
|
language: 'en-US',
|
||||||
|
rate: 1,
|
||||||
|
pitch: 1,
|
||||||
|
volume: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
private queue: IVoiceQueueItem[] = [];
|
||||||
|
private isSpeaking: boolean = false;
|
||||||
|
private currentUtterance: SpeechSynthesisUtterance | null = null;
|
||||||
|
private availableVoices: SpeechSynthesisVoice[] = [];
|
||||||
|
private voicesLoaded: boolean = false;
|
||||||
|
|
||||||
|
constructor(config?: Partial<IVoiceConfig>) {
|
||||||
|
if (config) {
|
||||||
|
this.configure(config);
|
||||||
|
}
|
||||||
|
this.initVoices();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Configuration ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure voice settings
|
||||||
|
*/
|
||||||
|
public configure(config: Partial<IVoiceConfig>): void {
|
||||||
|
this.config = { ...this.config, ...config };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current configuration
|
||||||
|
*/
|
||||||
|
public getConfig(): IVoiceConfig {
|
||||||
|
return { ...this.config };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if speech synthesis is supported
|
||||||
|
*/
|
||||||
|
public isSupported(): boolean {
|
||||||
|
return typeof window !== 'undefined' && 'speechSynthesis' in window;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable voice synthesis
|
||||||
|
*/
|
||||||
|
public enable(): void {
|
||||||
|
this.config.enabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable voice synthesis
|
||||||
|
*/
|
||||||
|
public disable(): void {
|
||||||
|
this.config.enabled = false;
|
||||||
|
this.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle voice synthesis
|
||||||
|
*/
|
||||||
|
public toggle(): void {
|
||||||
|
if (this.config.enabled) {
|
||||||
|
this.disable();
|
||||||
|
} else {
|
||||||
|
this.enable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if voice is enabled
|
||||||
|
*/
|
||||||
|
public isEnabled(): boolean {
|
||||||
|
return this.config.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Voice Selection ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize available voices
|
||||||
|
*/
|
||||||
|
private initVoices(): void {
|
||||||
|
if (!this.isSupported()) return;
|
||||||
|
|
||||||
|
// Voices may load asynchronously
|
||||||
|
const loadVoices = () => {
|
||||||
|
this.availableVoices = window.speechSynthesis.getVoices();
|
||||||
|
this.voicesLoaded = this.availableVoices.length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
loadVoices();
|
||||||
|
|
||||||
|
// Some browsers need to wait for voiceschanged event
|
||||||
|
if (window.speechSynthesis.onvoiceschanged !== undefined) {
|
||||||
|
window.speechSynthesis.onvoiceschanged = loadVoices;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available voices
|
||||||
|
*/
|
||||||
|
public getAvailableVoices(): SpeechSynthesisVoice[] {
|
||||||
|
if (!this.isSupported()) return [];
|
||||||
|
return window.speechSynthesis.getVoices();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get voices for a specific language
|
||||||
|
*/
|
||||||
|
public getVoicesForLanguage(lang: string): SpeechSynthesisVoice[] {
|
||||||
|
return this.getAvailableVoices().filter(voice =>
|
||||||
|
voice.lang.startsWith(lang) || voice.lang.startsWith(lang.split('-')[0])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the best voice for current language
|
||||||
|
*/
|
||||||
|
private findBestVoice(): SpeechSynthesisVoice | null {
|
||||||
|
if (!this.voicesLoaded) {
|
||||||
|
this.availableVoices = window.speechSynthesis.getVoices();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a specific voice is requested, try to find it
|
||||||
|
if (this.config.voiceName) {
|
||||||
|
const requestedVoice = this.availableVoices.find(
|
||||||
|
v => v.name === this.config.voiceName
|
||||||
|
);
|
||||||
|
if (requestedVoice) return requestedVoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find a voice matching the language
|
||||||
|
const langVoices = this.availableVoices.filter(
|
||||||
|
v => v.lang.startsWith(this.config.language.split('-')[0])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Prefer native/local voices, then default voices
|
||||||
|
const nativeVoice = langVoices.find(v => v.localService);
|
||||||
|
if (nativeVoice) return nativeVoice;
|
||||||
|
|
||||||
|
const defaultVoice = langVoices.find(v => v.default);
|
||||||
|
if (defaultVoice) return defaultVoice;
|
||||||
|
|
||||||
|
// Return any matching voice
|
||||||
|
return langVoices[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Speech Methods ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speak text with normal priority (queued)
|
||||||
|
*/
|
||||||
|
public speak(text: string): void {
|
||||||
|
if (!this.config.enabled || !this.isSupported()) return;
|
||||||
|
|
||||||
|
this.queue.push({ text, priority: 'normal' });
|
||||||
|
this.processQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speak text with urgent priority (interrupts current speech)
|
||||||
|
*/
|
||||||
|
public speakUrgent(text: string): void {
|
||||||
|
if (!this.config.enabled || !this.isSupported()) return;
|
||||||
|
|
||||||
|
// Cancel current speech and clear queue
|
||||||
|
this.stop();
|
||||||
|
|
||||||
|
// Add to front of queue
|
||||||
|
this.queue.unshift({ text, priority: 'urgent' });
|
||||||
|
this.processQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process the speech queue
|
||||||
|
*/
|
||||||
|
private processQueue(): void {
|
||||||
|
if (this.isSpeaking || this.queue.length === 0) return;
|
||||||
|
if (!this.isSupported()) return;
|
||||||
|
|
||||||
|
const item = this.queue.shift();
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
this.isSpeaking = true;
|
||||||
|
|
||||||
|
const utterance = new SpeechSynthesisUtterance(item.text);
|
||||||
|
this.currentUtterance = utterance;
|
||||||
|
|
||||||
|
// Apply configuration
|
||||||
|
utterance.lang = this.config.language;
|
||||||
|
utterance.rate = this.config.rate;
|
||||||
|
utterance.pitch = this.config.pitch;
|
||||||
|
utterance.volume = this.config.volume;
|
||||||
|
|
||||||
|
// Set voice if available
|
||||||
|
const voice = this.findBestVoice();
|
||||||
|
if (voice) {
|
||||||
|
utterance.voice = voice;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event handlers
|
||||||
|
utterance.onend = () => {
|
||||||
|
this.isSpeaking = false;
|
||||||
|
this.currentUtterance = null;
|
||||||
|
this.processQueue();
|
||||||
|
};
|
||||||
|
|
||||||
|
utterance.onerror = (event) => {
|
||||||
|
console.warn('[VoiceSynthesisManager] Speech error:', event.error);
|
||||||
|
this.isSpeaking = false;
|
||||||
|
this.currentUtterance = null;
|
||||||
|
this.processQueue();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Speak
|
||||||
|
window.speechSynthesis.speak(utterance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop current speech and clear queue
|
||||||
|
*/
|
||||||
|
public stop(): void {
|
||||||
|
if (!this.isSupported()) return;
|
||||||
|
|
||||||
|
window.speechSynthesis.cancel();
|
||||||
|
this.queue = [];
|
||||||
|
this.isSpeaking = false;
|
||||||
|
this.currentUtterance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause current speech
|
||||||
|
*/
|
||||||
|
public pause(): void {
|
||||||
|
if (!this.isSupported()) return;
|
||||||
|
window.speechSynthesis.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume paused speech
|
||||||
|
*/
|
||||||
|
public resume(): void {
|
||||||
|
if (!this.isSupported()) return;
|
||||||
|
window.speechSynthesis.resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if currently speaking
|
||||||
|
*/
|
||||||
|
public isSpeakingNow(): boolean {
|
||||||
|
return this.isSpeaking;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get queue length
|
||||||
|
*/
|
||||||
|
public getQueueLength(): number {
|
||||||
|
return this.queue.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Navigation-Specific Methods ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speak approach maneuver instruction
|
||||||
|
* "In [distance], [maneuver] onto [street]"
|
||||||
|
*/
|
||||||
|
public speakApproach(distance: string, maneuver: string, streetName?: string): void {
|
||||||
|
let text = `In ${distance}, ${maneuver}`;
|
||||||
|
if (streetName && streetName !== 'unnamed road') {
|
||||||
|
text += ` onto ${streetName}`;
|
||||||
|
}
|
||||||
|
this.speak(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speak execute maneuver instruction (urgent - immediate action)
|
||||||
|
* "[Maneuver] now" or just "[Maneuver]"
|
||||||
|
*/
|
||||||
|
public speakManeuver(maneuver: string, urgent: boolean = true): void {
|
||||||
|
const text = urgent ? `${maneuver} now` : maneuver;
|
||||||
|
if (urgent) {
|
||||||
|
this.speakUrgent(text);
|
||||||
|
} else {
|
||||||
|
this.speak(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speak arrival
|
||||||
|
*/
|
||||||
|
public speakArrival(): void {
|
||||||
|
this.speakUrgent('You have arrived at your destination');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speak off-route warning
|
||||||
|
*/
|
||||||
|
public speakOffRoute(): void {
|
||||||
|
this.speakUrgent('You are off route. Recalculating.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speak route recalculated
|
||||||
|
*/
|
||||||
|
public speakRecalculated(): void {
|
||||||
|
this.speak('Route recalculated');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,3 +17,29 @@ export {
|
|||||||
type ITrafficFlowSegment,
|
type ITrafficFlowSegment,
|
||||||
type ITrafficAwareRoute,
|
type ITrafficAwareRoute,
|
||||||
} from './geo-map.traffic.providers.js';
|
} from './geo-map.traffic.providers.js';
|
||||||
|
|
||||||
|
// Voice synthesis exports
|
||||||
|
export { VoiceSynthesisManager, type IVoiceConfig, type IVoiceQueueItem } from './geo-map.voice.js';
|
||||||
|
|
||||||
|
// Navigation guidance exports
|
||||||
|
export {
|
||||||
|
NavigationGuideController,
|
||||||
|
type IGPSPosition,
|
||||||
|
type INavigationGuideState,
|
||||||
|
type TGuidanceEventType,
|
||||||
|
type IGuidanceEvent,
|
||||||
|
type INavigationGuideCallbacks,
|
||||||
|
type INavigationCameraConfig,
|
||||||
|
} from './geo-map.navigation-guide.js';
|
||||||
|
|
||||||
|
// Mock GPS simulator exports
|
||||||
|
export {
|
||||||
|
MockGPSSimulator,
|
||||||
|
getSpeedDisplayName,
|
||||||
|
getSpeedKmh,
|
||||||
|
getSpeedPresets,
|
||||||
|
type TSimulationSpeed,
|
||||||
|
type ISimulationSpeedConfig,
|
||||||
|
type IMockGPSConfig,
|
||||||
|
type IMockGPSCallbacks,
|
||||||
|
} from './geo-map.mock-gps.js';
|
||||||
|
|||||||