diff --git a/changelog.md b/changelog.md index 47206b0..70639a2 100644 --- a/changelog.md +++ b/changelog.md @@ -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 diff --git a/marker-debug.png b/marker-debug.png new file mode 100644 index 0000000..bf87c28 Binary files /dev/null and b/marker-debug.png differ diff --git a/marker-positioning-issue.png b/marker-positioning-issue.png new file mode 100644 index 0000000..ecb293c Binary files /dev/null and b/marker-positioning-issue.png differ diff --git a/markers-after-stop.png b/markers-after-stop.png new file mode 100644 index 0000000..24817e0 Binary files /dev/null and b/markers-after-stop.png differ diff --git a/markers-before-navigation.png b/markers-before-navigation.png new file mode 100644 index 0000000..8228d80 Binary files /dev/null and b/markers-before-navigation.png differ diff --git a/markers-during-navigation.png b/markers-during-navigation.png new file mode 100644 index 0000000..1f187ea Binary files /dev/null and b/markers-during-navigation.png differ diff --git a/markers-flat-view.png b/markers-flat-view.png new file mode 100644 index 0000000..8228d80 Binary files /dev/null and b/markers-flat-view.png differ diff --git a/navigation-3d-markers.png b/navigation-3d-markers.png new file mode 100644 index 0000000..af60c21 Binary files /dev/null and b/navigation-3d-markers.png differ diff --git a/navigation-marker-check.png b/navigation-marker-check.png new file mode 100644 index 0000000..eac5c3c Binary files /dev/null and b/navigation-marker-check.png differ diff --git a/readme.hints.md b/readme.hints.md index 4afe549..e584422 100644 --- a/readme.hints.md +++ b/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 | | `trafficApiKey` | `string` | `''` | HERE API key for traffic data | | `trafficProvider` | `ITrafficProvider` | `null` | Custom traffic data provider | +| `enableGuidance` | `boolean` | `false` | Enable voice-guided navigation | +| `voiceConfig` | `Partial` | `{}` | 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, +}); +``` diff --git a/route-markers-check.png b/route-markers-check.png new file mode 100644 index 0000000..7872ee3 Binary files /dev/null and b/route-markers-check.png differ diff --git a/route-markers-verification.png b/route-markers-verification.png new file mode 100644 index 0000000..f96567f Binary files /dev/null and b/route-markers-verification.png differ diff --git a/route-with-markers.png b/route-with-markers.png new file mode 100644 index 0000000..bc3f2ca Binary files /dev/null and b/route-with-markers.png differ diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 0ee1185..42b6f4f 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -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' } diff --git a/ts_web/elements/00componentstyles.ts b/ts_web/elements/00componentstyles.ts index a03d027..255827c 100644 --- a/ts_web/elements/00componentstyles.ts +++ b/ts_web/elements/00componentstyles.ts @@ -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); + } +`; diff --git a/ts_web/elements/00group-map/dees-geo-map/dees-geo-map.demo.ts b/ts_web/elements/00group-map/dees-geo-map/dees-geo-map.demo.ts index 6c2a712..a52281e 100644 --- a/ts_web/elements/00group-map/dees-geo-map/dees-geo-map.demo.ts +++ b/ts_web/elements/00group-map/dees-geo-map/dees-geo-map.demo.ts @@ -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` @@ -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 = { paris: [2.3522, 48.8566], @@ -257,6 +571,12 @@ export const demoFunc = () => html` updateFeatureDisplay(); }); } + + // Initialize simulation controls after a tick + setTimeout(() => { + setupSimulationControls(); + updateSimulationUI(); + }, 100); }}>

Interactive Map with Drawing Tools

@@ -281,6 +601,58 @@ export const demoFunc = () => html`

+
+

GPS Simulation & Voice Navigation

+

+ 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. +

+ +
+
+ + + +
+ +
+ Speed: + +
+ +
+ + +
+
+ +
+
+ Route Progress + 0% +
+
+
+
+
+ +
+ Calculate a route first to enable simulation +
+
+

Quick Navigation

diff --git a/ts_web/elements/00group-map/dees-geo-map/dees-geo-map.ts b/ts_web/elements/00group-map/dees-geo-map/dees-geo-map.ts index 335a3d0..f1e5a51 100644 --- a/ts_web/elements/00group-map/dees-geo-map/dees-geo-map.ts +++ b/ts_web/elements/00group-map/dees-geo-map/dees-geo-map.ts @@ -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 = {}; + // ─── 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('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): 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): 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 {
+ + + ${this.guidanceController?.isNavigating() ? this.guidanceController.render() : ''}
diff --git a/ts_web/elements/00group-map/dees-geo-map/geo-map.icons.ts b/ts_web/elements/00group-map/dees-geo-map/geo-map.icons.ts index 3f79bf1..acbdd91 100644 --- a/ts_web/elements/00group-map/dees-geo-map/geo-map.icons.ts +++ b/ts_web/elements/00group-map/dees-geo-map/geo-map.icons.ts @@ -44,6 +44,16 @@ export const GEO_MAP_ICONS: Record = { traffic: html``, trafficLight: html``, congestion: html``, + + // Guidance / Simulation + volume: html``, + volumeOff: html``, + play: html``, + pause: html``, + stop: html``, + gpsPosition: html``, + speed: html``, + simulate: html``, }; /** diff --git a/ts_web/elements/00group-map/dees-geo-map/geo-map.mock-gps.ts b/ts_web/elements/00group-map/dees-geo-map/geo-map.mock-gps.ts new file mode 100644 index 0000000..d6c8bb0 --- /dev/null +++ b/ts_web/elements/00group-map/dees-geo-map/geo-map.mock-gps.ts @@ -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 | null = null; + + // Callbacks + private callbacks: IMockGPSCallbacks; + + constructor(callbacks: IMockGPSCallbacks, config?: Partial) { + this.callbacks = callbacks; + if (config) { + this.configure(config); + } + } + + // ─── Configuration ────────────────────────────────────────────────────────── + + /** + * Configure simulator settings + */ + public configure(config: Partial): 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 = { + 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 }, + ]; +} diff --git a/ts_web/elements/00group-map/dees-geo-map/geo-map.navigation-guide.ts b/ts_web/elements/00group-map/dees-geo-map/geo-map.navigation-guide.ts new file mode 100644 index 0000000..9eaa9ab --- /dev/null +++ b/ts_web/elements/00group-map/dees-geo-map/geo-map.navigation-guide.ts @@ -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) { + this.callbacks = callbacks; + this.voiceManager = new VoiceSynthesisManager(voiceConfig); + } + + // ─── Configuration ────────────────────────────────────────────────────────── + + /** + * Configure voice settings + */ + public configureVoice(config: Partial): 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): 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 ` + + + + + + + + + `; + } + + /** + * 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` +
+
+
${maneuverIcon}
+
${distance}
+
+
${instruction}
+
+ ${this.formatDistance(this.state.distanceRemaining)} remaining + • + ${this.formatDuration(this.state.timeRemaining)} +
+ ${this.state.isOffRoute ? html` +
+ Off route - recalculating... +
+ ` : ''} +
+ `; + } + + // ─── 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 = { + '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(); + } +} diff --git a/ts_web/elements/00group-map/dees-geo-map/geo-map.navigation.ts b/ts_web/elements/00group-map/dees-geo-map/geo-map.navigation.ts index d553df9..66814df 100644 --- a/ts_web/elements/00group-map/dees-geo-map/geo-map.navigation.ts +++ b/ts_web/elements/00group-map/dees-geo-map/geo-map.navigation.ts @@ -417,7 +417,7 @@ export class NavigationController { el.className = 'nav-marker nav-marker-start'; el.innerHTML = ``; 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 = ``; 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 { diff --git a/ts_web/elements/00group-map/dees-geo-map/geo-map.voice.ts b/ts_web/elements/00group-map/dees-geo-map/geo-map.voice.ts new file mode 100644 index 0000000..8f5ab58 --- /dev/null +++ b/ts_web/elements/00group-map/dees-geo-map/geo-map.voice.ts @@ -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) { + if (config) { + this.configure(config); + } + this.initVoices(); + } + + // ─── Configuration ────────────────────────────────────────────────────────── + + /** + * Configure voice settings + */ + public configure(config: Partial): 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'); + } +} diff --git a/ts_web/elements/00group-map/dees-geo-map/index.ts b/ts_web/elements/00group-map/dees-geo-map/index.ts index 7a26aa3..04e63ab 100644 --- a/ts_web/elements/00group-map/dees-geo-map/index.ts +++ b/ts_web/elements/00group-map/dees-geo-map/index.ts @@ -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';