feat(dees-geo-map): Add voice-guided navigation with TTS, navigation guide controller and mock GPS simulator

This commit is contained in:
2026-02-05 17:50:45 +00:00
parent 428e0546bd
commit 50b5c9325c
23 changed files with 2860 additions and 7 deletions

View File

@@ -1,5 +1,16 @@
# 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)
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

BIN
markers-after-stop.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

BIN
markers-flat-view.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 KiB

BIN
navigation-3d-markers.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

BIN
navigation-marker-check.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

View File

@@ -26,6 +26,8 @@ Geospatial web components library using MapLibre GL JS for map rendering and ter
| `showTraffic` | `boolean` | `false` | Enable traffic layer visualization |
| `trafficApiKey` | `string` | `''` | HERE API key for traffic data |
| `trafficProvider` | `ITrafficProvider` | `null` | Custom traffic data provider |
| `enableGuidance` | `boolean` | `false` | Enable voice-guided navigation |
| `voiceConfig` | `Partial<IVoiceConfig>` | `{}` | Voice synthesis configuration |
### Drawing Tools (TDrawTool)
- `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
- `route-calculated` - Fired when a navigation route is calculated (includes route, startPoint, endPoint, mode)
- `traffic-updated` - Fired when traffic data is refreshed
- `guidance-event` - Fired during voice-guided navigation (includes type, position, stepIndex, instruction)
### Public Methods
- `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
- `supportsTrafficRouting()` - Check if traffic-aware routing is available
- `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
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.navigation.ts # NavigationController 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
@@ -184,6 +206,34 @@ Traffic data provider implementations:
- `HereTrafficProvider` - HERE Traffic API v7 (freemium)
- `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
```typescript
// 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 `static` or `select` mode is active, dragging is re-enabled
- 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 KiB

BIN
route-with-markers.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 KiB

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
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'
}

View File

@@ -1378,3 +1378,331 @@ export const trafficStyles = css`
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);
}
`;

View File

@@ -1,6 +1,7 @@
import { html, css, cssManager } from '@design.estate/dees-element';
import '@design.estate/dees-wcctools/demotools';
import type { DeesGeoMap } from './dees-geo-map.js';
import { getSpeedPresets, type TSimulationSpeed } from './geo-map.mock-gps.js';
export const demoFunc = () => html`
<style>
@@ -87,11 +88,42 @@ export const demoFunc = () => html`
transition: all 0.15s ease;
}
.control-button:hover {
.control-button:hover:not(:disabled) {
background: ${cssManager.bdTheme('#f0f0f0', '#333')};
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 {
background: ${cssManager.bdTheme('#f9f9f9', '#1e1e1e')};
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
@@ -132,6 +164,137 @@ export const demoFunc = () => html`
color: #fff;
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>
@@ -141,6 +304,59 @@ export const demoFunc = () => html`
const eventLog = elementArg.querySelector('#event-log') 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 entry = document.createElement('div');
entry.className = 'event-entry';
@@ -157,6 +373,7 @@ export const demoFunc = () => html`
if (map) {
map.addEventListener('map-ready', () => {
addLogEntry('ready', 'Map initialized successfully');
updateSimulationUI();
});
map.addEventListener('draw-change', (e: CustomEvent) => {
@@ -183,9 +400,106 @@ export const demoFunc = () => html`
const durationMin = Math.round(route.duration / 60);
addLogEntry('route', `${mode}: ${distKm} km, ${durationMin} min`);
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
const locations: Record<string, [number, number]> = {
paris: [2.3522, 48.8566],
@@ -257,6 +571,12 @@ export const demoFunc = () => html`
updateFeatureDisplay();
});
}
// Initialize simulation controls after a tick
setTimeout(() => {
setupSimulationControls();
updateSimulationUI();
}, 100);
}}>
<div class="demo-section">
<h2 class="demo-title">Interactive Map with Drawing Tools</h2>
@@ -281,6 +601,58 @@ export const demoFunc = () => html`
</p>
</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">
<h2 class="demo-title">Quick Navigation</h2>
<div class="locations-grid">

View File

@@ -11,7 +11,7 @@ import {
domtools,
} from '@design.estate/dees-element';
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
import maplibregl from 'maplibre-gl';
@@ -33,9 +33,12 @@ import { TerraDrawMapLibreGLAdapter } from 'terra-draw-maplibre-gl-adapter';
// Modular imports
import { renderIcon } from './geo-map.icons.js';
import { SearchController, type INominatimResult, type IAddressSelectedEvent } from './geo-map.search.js';
import { NavigationController, type TNavigationMode, type INavigationState, type IRouteCalculatedEvent } from './geo-map.navigation.js';
import { NavigationController, type TNavigationMode, type INavigationState, type IRouteCalculatedEvent, type IOSRMRoute } from './geo-map.navigation.js';
import { TrafficController } from './geo-map.traffic.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
export type { INominatimResult, IAddressSelectedEvent } from './geo-map.search.js';
@@ -48,6 +51,9 @@ export type {
IRouteCalculatedEvent,
} from './geo-map.navigation.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';
@@ -128,6 +134,13 @@ export class DeesGeoMap extends DeesElement {
@property({ type: String })
accessor trafficApiKey: string = '';
// Guidance properties
@property({ type: Boolean })
accessor enableGuidance: boolean = false;
@property({ type: Object })
accessor voiceConfig: Partial<IVoiceConfig> = {};
// ─── State ──────────────────────────────────────────────────────────────────
@state()
@@ -149,6 +162,8 @@ export class DeesGeoMap extends DeesElement {
private searchController: SearchController | null = null;
private navigationController: NavigationController | null = null;
private trafficController: TrafficController | null = null;
private guidanceController: NavigationGuideController | null = null;
private mockGPSSimulator: MockGPSSimulator | null = null;
// ─── Styles ─────────────────────────────────────────────────────────────────
@@ -161,6 +176,8 @@ export class DeesGeoMap extends DeesElement {
navigationStyles,
trafficStyles,
headerToolbarStyles,
guidanceStyles,
maplibreMarkerStyles,
css`
:host {
display: block;
@@ -348,6 +365,22 @@ export class DeesGeoMap extends DeesElement {
hereProvider.configure({ apiKey: this.trafficApiKey });
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 ─────────────────────────────────────────────────────
@@ -772,6 +805,199 @@ export class DeesGeoMap extends DeesElement {
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 ensureMaplibreCssLoaded() {
@@ -793,6 +1019,8 @@ export class DeesGeoMap extends DeesElement {
// Clean up controllers
this.navigationController?.cleanup();
this.trafficController?.cleanup();
this.guidanceController?.cleanup();
this.mockGPSSimulator?.cleanup();
if (this.map) {
this.map.remove();
@@ -923,6 +1151,9 @@ export class DeesGeoMap extends DeesElement {
<!-- Bottom Right: Empty (zoom in header) -->
<div class="overlay-bottom-right"></div>
</div>
<!-- Guidance Panel (shown during active navigation) -->
${this.guidanceController?.isNavigating() ? this.guidanceController.render() : ''}
</div>
<!-- Right Sidebar: Draw Panel -->

View File

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

View 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 },
];
}

View File

@@ -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
&bull;
${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();
}
}

View File

@@ -417,7 +417,7 @@ export class NavigationController {
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.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)
.addTo(map);
} else {
@@ -435,7 +435,7 @@ export class NavigationController {
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.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)
.addTo(map);
} else {

View 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');
}
}

View File

@@ -17,3 +17,29 @@ export {
type ITrafficFlowSegment,
type ITrafficAwareRoute,
} 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';