import { html, type TemplateResult } from '@design.estate/dees-element'; import maplibregl from 'maplibre-gl'; import { renderIcon } from './geo-map.icons.js'; import { type INominatimResult } from './geo-map.search.js'; import type { ITrafficAwareRoute } from './geo-map.traffic.providers.js'; import type { INavigationGuideState } from './geo-map.navigation-guide.js'; // ─── Navigation/Routing Types ──────────────────────────────────────────────── export type TNavigationMode = 'driving' | 'walking' | 'cycling'; export interface IOSRMRoute { geometry: GeoJSON.LineString; distance: number; // meters duration: number; // seconds legs: IOSRMLeg[]; } export interface IOSRMLeg { steps: IOSRMStep[]; distance: number; duration: number; } export interface IOSRMStep { geometry: GeoJSON.LineString; maneuver: { type: string; // 'turn', 'depart', 'arrive', etc. modifier?: string; // 'left', 'right', 'straight', etc. location: [number, number]; }; name: string; // Street name ref?: string; // Road reference (A1, M25, etc.) distance: number; duration: number; driving_side: string; } export interface INavigationState { startPoint: [number, number] | null; endPoint: [number, number] | null; startAddress: string; endAddress: string; route: IOSRMRoute | null; trafficRoute: ITrafficAwareRoute | null; isLoading: boolean; error: string | null; } export interface IRouteCalculatedEvent { route: IOSRMRoute; startPoint: [number, number]; endPoint: [number, number]; mode: TNavigationMode; } /** * Callbacks for NavigationController events */ export interface INavigationControllerCallbacks { onRouteCalculated: (event: IRouteCalculatedEvent) => void; onRequestUpdate: () => void; getMap: () => maplibregl.Map | null; /** Optional callback to fetch traffic-aware route */ getTrafficRoute?: ( start: [number, number], end: [number, number], mode: TNavigationMode ) => Promise; /** Optional callback when traffic toggle is changed */ onTrafficToggle?: (enabled: boolean) => void; /** Optional callback to get current traffic state */ getTrafficEnabled?: () => boolean; /** Optional callback to get current guidance state for turn-by-turn synchronization */ getGuidanceState?: () => INavigationGuideState | null; } /** * Controller for A-to-B navigation functionality * Handles routing, markers, search inputs, and turn-by-turn directions */ export class NavigationController { // State public navigationState: INavigationState = { startPoint: null, endPoint: null, startAddress: '', endAddress: '', route: null, trafficRoute: null, isLoading: false, error: null, }; // Navigation search state public navStartSearchQuery: string = ''; public navEndSearchQuery: string = ''; public navStartSearchResults: INominatimResult[] = []; public navEndSearchResults: INominatimResult[] = []; public navActiveInput: 'start' | 'end' | null = null; public navClickMode: 'start' | 'end' | null = null; public navHighlightedIndex: number = -1; // Mode public navigationMode: TNavigationMode = 'driving'; // View mode: 'planning' for route input, 'directions' for turn-by-turn public viewMode: 'planning' | 'directions' = 'planning'; // Internal private callbacks: INavigationControllerCallbacks; private navSearchDebounceTimer: ReturnType | null = null; private startMarker: maplibregl.Marker | null = null; private endMarker: maplibregl.Marker | null = null; constructor(callbacks: INavigationControllerCallbacks) { this.callbacks = callbacks; } // ─── Routing ──────────────────────────────────────────────────────────────── /** * Fetch a route from OSRM API */ public async fetchRoute( start: [number, number], end: [number, number], mode: TNavigationMode ): Promise { const profile = mode === 'cycling' ? 'bike' : mode === 'walking' ? 'foot' : 'car'; const coords = `${start[0]},${start[1]};${end[0]},${end[1]}`; const url = `https://router.project-osrm.org/route/v1/${profile}/${coords}?geometries=geojson&steps=true&overview=full`; try { const response = await fetch(url); if (!response.ok) { throw new Error(`OSRM API error: ${response.status}`); } const data = await response.json(); if (data.code !== 'Ok' || !data.routes || data.routes.length === 0) { throw new Error(data.message || 'No route found'); } const route = data.routes[0]; return { geometry: route.geometry, distance: route.distance, duration: route.duration, legs: route.legs, }; } catch (error) { console.error('Route fetch error:', error); throw error; } } /** * Calculate and display route */ public async calculateRoute(): Promise { const { startPoint, endPoint } = this.navigationState; if (!startPoint || !endPoint) { this.navigationState = { ...this.navigationState, error: 'Please set both start and end points', }; this.callbacks.onRequestUpdate(); return; } this.navigationState = { ...this.navigationState, isLoading: true, error: null, trafficRoute: null, }; this.callbacks.onRequestUpdate(); try { // Fetch both regular route and traffic-aware route in parallel const [route, trafficRoute] = await Promise.all([ this.fetchRoute(startPoint, endPoint, this.navigationMode), this.callbacks.getTrafficRoute ? this.callbacks.getTrafficRoute(startPoint, endPoint, this.navigationMode) : Promise.resolve(null), ]); if (route) { this.navigationState = { ...this.navigationState, route, trafficRoute, isLoading: false, }; // Use traffic route geometry if available, otherwise use regular route const routeToRender = trafficRoute || route; this.renderRouteOnMap(routeToRender); // Dispatch route-calculated event this.callbacks.onRouteCalculated({ route, startPoint, endPoint, mode: this.navigationMode, }); // Fit map to route bounds this.fitToRoute(routeToRender); // Switch to directions view after successful route calculation this.viewMode = 'directions'; } } catch (error) { this.navigationState = { ...this.navigationState, isLoading: false, error: error instanceof Error ? error.message : 'Failed to calculate route', }; } this.callbacks.onRequestUpdate(); } // ─── Point Management ─────────────────────────────────────────────────────── /** * Set navigation start point */ public setNavigationStart(coords: [number, number], address?: string): void { this.navigationState = { ...this.navigationState, startPoint: coords, startAddress: address || `${coords[1].toFixed(5)}, ${coords[0].toFixed(5)}`, error: null, }; this.navStartSearchQuery = this.navigationState.startAddress; this.navStartSearchResults = []; this.updateNavigationMarkers(); this.callbacks.onRequestUpdate(); // Auto-calculate if both points are set if (this.navigationState.endPoint) { this.calculateRoute(); } } /** * Set navigation end point */ public setNavigationEnd(coords: [number, number], address?: string): void { this.navigationState = { ...this.navigationState, endPoint: coords, endAddress: address || `${coords[1].toFixed(5)}, ${coords[0].toFixed(5)}`, error: null, }; this.navEndSearchQuery = this.navigationState.endAddress; this.navEndSearchResults = []; this.updateNavigationMarkers(); this.callbacks.onRequestUpdate(); // Auto-calculate if both points are set if (this.navigationState.startPoint) { this.calculateRoute(); } } /** * Clear all navigation state */ public clearNavigation(): void { this.navigationState = { startPoint: null, endPoint: null, startAddress: '', endAddress: '', route: null, trafficRoute: null, isLoading: false, error: null, }; this.navStartSearchQuery = ''; this.navEndSearchQuery = ''; this.navStartSearchResults = []; this.navEndSearchResults = []; this.navClickMode = null; this.viewMode = 'planning'; // Remove markers if (this.startMarker) { this.startMarker.remove(); this.startMarker = null; } if (this.endMarker) { this.endMarker.remove(); this.endMarker = null; } // Remove route layer and source const map = this.callbacks.getMap(); if (map) { if (map.getLayer('route-layer')) { map.removeLayer('route-layer'); } if (map.getLayer('route-outline-layer')) { map.removeLayer('route-outline-layer'); } if (map.getSource('route-source')) { map.removeSource('route-source'); } } this.callbacks.onRequestUpdate(); } /** * Clear a specific navigation point */ public clearNavPoint(pointType: 'start' | 'end'): void { if (pointType === 'start') { this.navigationState = { ...this.navigationState, startPoint: null, startAddress: '', route: null, }; this.navStartSearchQuery = ''; if (this.startMarker) { this.startMarker.remove(); this.startMarker = null; } } else { this.navigationState = { ...this.navigationState, endPoint: null, endAddress: '', route: null, }; this.navEndSearchQuery = ''; if (this.endMarker) { this.endMarker.remove(); this.endMarker = null; } } // Remove route display const map = this.callbacks.getMap(); if (map) { if (map.getLayer('route-layer')) { map.removeLayer('route-layer'); } if (map.getLayer('route-outline-layer')) { map.removeLayer('route-outline-layer'); } if (map.getSource('route-source')) { map.removeSource('route-source'); } } this.callbacks.onRequestUpdate(); } // ─── Map Interaction ──────────────────────────────────────────────────────── /** * Handle map click for navigation point selection */ public handleMapClickForNavigation(e: maplibregl.MapMouseEvent): void { if (!this.navClickMode) return; const coords: [number, number] = [e.lngLat.lng, e.lngLat.lat]; if (this.navClickMode === 'start') { this.setNavigationStart(coords); } else if (this.navClickMode === 'end') { this.setNavigationEnd(coords); } // Exit click mode this.navClickMode = null; // Re-enable map interactions const map = this.callbacks.getMap(); if (map) { map.getCanvas().style.cursor = ''; } } /** * Toggle map click mode for setting navigation points */ public toggleNavClickMode(mode: 'start' | 'end'): void { const map = this.callbacks.getMap(); if (this.navClickMode === mode) { // Cancel click mode this.navClickMode = null; if (map) { map.getCanvas().style.cursor = ''; } } else { this.navClickMode = mode; if (map) { map.getCanvas().style.cursor = 'crosshair'; } } this.callbacks.onRequestUpdate(); } /** * Update navigation markers on the map */ public updateNavigationMarkers(): void { const map = this.callbacks.getMap(); if (!map) return; // Update start marker if (this.navigationState.startPoint) { if (!this.startMarker) { const el = document.createElement('div'); 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', pitchAlignment: 'viewport' }) .setLngLat(this.navigationState.startPoint) .addTo(map); } else { this.startMarker.setLngLat(this.navigationState.startPoint); } } else if (this.startMarker) { this.startMarker.remove(); this.startMarker = null; } // Update end marker if (this.navigationState.endPoint) { if (!this.endMarker) { const el = document.createElement('div'); 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', pitchAlignment: 'viewport' }) .setLngLat(this.navigationState.endPoint) .addTo(map); } else { this.endMarker.setLngLat(this.navigationState.endPoint); } } else if (this.endMarker) { this.endMarker.remove(); this.endMarker = null; } } /** * Render route on the map */ public renderRouteOnMap(route: IOSRMRoute): void { const map = this.callbacks.getMap(); if (!map) return; const sourceId = 'route-source'; const layerId = 'route-layer'; const outlineLayerId = 'route-outline-layer'; // Remove existing layers/source if (map.getLayer(layerId)) { map.removeLayer(layerId); } if (map.getLayer(outlineLayerId)) { map.removeLayer(outlineLayerId); } if (map.getSource(sourceId)) { map.removeSource(sourceId); } // Add route source map.addSource(sourceId, { type: 'geojson', data: { type: 'Feature', properties: {}, geometry: route.geometry, }, }); // Add outline layer (for border effect) map.addLayer({ id: outlineLayerId, type: 'line', source: sourceId, layout: { 'line-join': 'round', 'line-cap': 'round', }, paint: { 'line-color': '#1e40af', 'line-width': 8, 'line-opacity': 0.8, }, }); // Add main route layer map.addLayer({ id: layerId, type: 'line', source: sourceId, layout: { 'line-join': 'round', 'line-cap': 'round', }, paint: { 'line-color': '#3b82f6', 'line-width': 5, 'line-opacity': 1, }, }); } /** * Fit map to show the entire route */ public fitToRoute(route: IOSRMRoute): void { const map = this.callbacks.getMap(); if (!map || !route.geometry.coordinates.length) return; const bounds = new maplibregl.LngLatBounds(); for (const coord of route.geometry.coordinates) { bounds.extend(coord as [number, number]); } map.fitBounds(bounds, { padding: 80 }); } /** * Fly to a specific navigation step location */ public flyToStep(step: IOSRMStep): void { const map = this.callbacks.getMap(); if (!map) return; map.flyTo({ center: step.maneuver.location, zoom: 17, duration: 1000, }); } // ─── Search within Navigation ─────────────────────────────────────────────── /** * Search Nominatim API for addresses */ private async searchNominatim(query: string): Promise { if (query.length < 3) return []; const params = new URLSearchParams({ q: query, format: 'json', limit: '5', addressdetails: '1', }); const response = await fetch(`https://nominatim.openstreetmap.org/search?${params}`, { headers: { 'User-Agent': 'dees-geo-map/1.0', }, }); if (!response.ok) return []; return response.json(); } /** * Handle navigation input search */ public handleNavSearchInput(event: Event, inputType: 'start' | 'end'): void { const input = event.target as HTMLInputElement; const query = input.value; if (inputType === 'start') { this.navStartSearchQuery = query; } else { this.navEndSearchQuery = query; } this.navActiveInput = inputType; this.navHighlightedIndex = -1; // Clear previous debounce if (this.navSearchDebounceTimer) { clearTimeout(this.navSearchDebounceTimer); } if (query.length < 3) { if (inputType === 'start') { this.navStartSearchResults = []; } else { this.navEndSearchResults = []; } this.callbacks.onRequestUpdate(); return; } // Debounce API calls this.navSearchDebounceTimer = setTimeout(async () => { const results = await this.searchNominatim(query); if (inputType === 'start') { this.navStartSearchResults = results; } else { this.navEndSearchResults = results; } this.callbacks.onRequestUpdate(); }, 500); } /** * Handle navigation search result selection */ public selectNavSearchResult(result: INominatimResult, inputType: 'start' | 'end'): void { const lng = parseFloat(result.lon); const lat = parseFloat(result.lat); const coords: [number, number] = [lng, lat]; const address = result.display_name; if (inputType === 'start') { this.setNavigationStart(coords, address); this.navStartSearchResults = []; } else { this.setNavigationEnd(coords, address); this.navEndSearchResults = []; } this.navActiveInput = null; } /** * Handle keyboard navigation in search results */ public handleNavSearchKeydown(event: KeyboardEvent, inputType: 'start' | 'end'): void { const results = inputType === 'start' ? this.navStartSearchResults : this.navEndSearchResults; if (results.length === 0) return; switch (event.key) { case 'ArrowDown': event.preventDefault(); this.navHighlightedIndex = Math.min(this.navHighlightedIndex + 1, results.length - 1); this.callbacks.onRequestUpdate(); break; case 'ArrowUp': event.preventDefault(); this.navHighlightedIndex = Math.max(this.navHighlightedIndex - 1, -1); this.callbacks.onRequestUpdate(); break; case 'Enter': event.preventDefault(); if (this.navHighlightedIndex >= 0 && results[this.navHighlightedIndex]) { this.selectNavSearchResult(results[this.navHighlightedIndex], inputType); } break; case 'Escape': event.preventDefault(); if (inputType === 'start') { this.navStartSearchResults = []; } else { this.navEndSearchResults = []; } this.navActiveInput = null; this.callbacks.onRequestUpdate(); break; } } // ─── Formatting Utilities ─────────────────────────────────────────────────── /** * Format distance for display */ public formatDistance(meters: number): string { if (meters < 1000) { return `${Math.round(meters)} m`; } return `${(meters / 1000).toFixed(1)} km`; } /** * Format duration for display */ public 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 for turn type */ public getManeuverIcon(type: string, modifier?: string): string { 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': '↗', 'end of road-left': '↰', 'end of road-right': '↱', 'new name': '⬆️', 'notification': 'ℹ️', }; const key = modifier ? `${type}-${modifier}` : type; return icons[key] || icons[type] || '➡'; } /** * Get congestion label for display */ public getCongestionLabel(level: 'low' | 'moderate' | 'heavy' | 'severe'): string { const labels = { low: 'Light traffic', moderate: 'Moderate traffic', heavy: 'Heavy traffic', severe: 'Severe congestion', }; return labels[level]; } /** * Format step instruction for display */ public formatStepInstruction(step: IOSRMStep): string { const { type, modifier } = step.maneuver; const name = step.name || step.ref || 'unnamed road'; switch (type) { case 'depart': return `Head ${modifier || 'forward'} on ${name}`; case 'arrive': return modifier === 'left' ? `Arrive at your destination on the left` : modifier === 'right' ? `Arrive at your destination on the right` : `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 'fork': return `Take the ${modifier || ''} fork onto ${name}`; case 'roundabout': case 'rotary': return `At the roundabout, take the exit onto ${name}`; case 'end of road': return `At the end of the road, turn ${modifier || ''} onto ${name}`; case 'new name': return `Continue on ${name}`; default: return `${type} ${modifier || ''} on ${name}`.trim(); } } // ─── Mode ─────────────────────────────────────────────────────────────────── /** * Change navigation mode and recalculate route if exists */ public setNavigationMode(mode: TNavigationMode): void { this.navigationMode = mode; this.callbacks.onRequestUpdate(); // Recalculate route if we have both points if (this.navigationState.startPoint && this.navigationState.endPoint) { this.calculateRoute(); } } /** * Switch between planning and directions view */ public setViewMode(mode: 'planning' | 'directions'): void { this.viewMode = mode; this.callbacks.onRequestUpdate(); } // ─── Cleanup ──────────────────────────────────────────────────────────────── /** * Clean up markers and resources */ public cleanup(): void { if (this.startMarker) { this.startMarker.remove(); this.startMarker = null; } if (this.endMarker) { this.endMarker.remove(); this.endMarker = null; } if (this.navSearchDebounceTimer) { clearTimeout(this.navSearchDebounceTimer); this.navSearchDebounceTimer = null; } } // ─── Rendering ────────────────────────────────────────────────────────────── /** * Render the navigation panel * @param extraClass - Optional CSS class to add to the panel for positioning */ public render(extraClass?: string): TemplateResult { // If we have a route and we're in directions view, show that if (this.viewMode === 'directions' && this.navigationState.route) { return this.renderDirectionsView(extraClass); } return this.renderPlanningView(extraClass); } /** * Render the route planning view (inputs, mode selector, actions) */ private renderPlanningView(extraClass?: string): TemplateResult { const { isLoading, error, startPoint, endPoint } = this.navigationState; const canCalculate = startPoint && endPoint && !isLoading; const trafficEnabled = this.callbacks.getTrafficEnabled?.() ?? false; return html` `; } /** * Render the directions view (back button, summary, turn-by-turn steps) */ private renderDirectionsView(extraClass?: string): TemplateResult { const { route, trafficRoute } = this.navigationState; if (!route) { // Shouldn't happen, but fallback to planning view return this.renderPlanningView(extraClass); } return html` `; } /** * Render a navigation input field */ private renderNavInput( inputType: 'start' | 'end', placeholder: string, query: string, results: INominatimResult[] ): TemplateResult { const hasValue = inputType === 'start' ? this.navigationState.startPoint !== null : this.navigationState.endPoint !== null; const isClickMode = this.navClickMode === inputType; return html` `; } /** * Render turn-by-turn directions */ private renderTurnByTurn(route: IOSRMRoute): TemplateResult { if (!route.legs || route.legs.length === 0) { return html``; } const steps = route.legs.flatMap(leg => leg.steps); const guidanceState = this.callbacks.getGuidanceState?.(); const isNavigating = guidanceState?.isNavigating ?? false; const currentStepIndex = guidanceState?.currentStepIndex ?? -1; if (steps.length === 0) { return html``; } return html` ${steps.map((step, index) => { const icon = this.getManeuverIcon(step.maneuver.type, step.maneuver.modifier); const instruction = this.formatStepInstruction(step); const distance = this.formatDistance(step.distance); // currentStepIndex points to the maneuver we're approaching, // but we're traveling on the PREVIOUS step's road const isCurrent = isNavigating && index === currentStepIndex - 1; const isCompleted = isNavigating && index < currentStepIndex - 1; // Calculate progress percentage for current step let progressPercent = 0; if (isCurrent && step.distance > 0) { const distanceRemaining = guidanceState?.distanceToNextManeuver ?? step.distance; progressPercent = Math.max(0, Math.min(100, ((step.distance - distanceRemaining) / step.distance) * 100 )); } return html` `; })} `; } /** * Scroll the turn-by-turn list to show the current step * Called externally when guidance state changes */ public scrollToCurrentStep(stepIndex: number): void { // Use requestAnimationFrame to ensure DOM is updated requestAnimationFrame(() => { // Find elements in document - they may be in shadow DOM const stepsContainer = document.querySelector('.nav-steps') ?? document.querySelector('dees-geo-map')?.shadowRoot?.querySelector('.nav-steps'); const currentStep = document.querySelector(`.nav-step[data-step-index="${stepIndex}"]`) ?? document.querySelector('dees-geo-map')?.shadowRoot?.querySelector(`.nav-step[data-step-index="${stepIndex}"]`); if (stepsContainer && currentStep) { (currentStep as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'nearest', }); } }); } }