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(); } }