1121 lines
34 KiB
TypeScript
1121 lines
34 KiB
TypeScript
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<ITrafficAwareRoute | null>;
|
||
/** 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<typeof setTimeout> | 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<IOSRMRoute | null> {
|
||
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<void> {
|
||
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 = `<svg viewBox="0 0 24 24" fill="#22c55e" stroke="#fff" stroke-width="2"><circle cx="12" cy="12" r="8"/></svg>`;
|
||
el.style.cssText = 'width: 24px; height: 24px; cursor: pointer;';
|
||
this.startMarker = new maplibregl.Marker({ element: el, anchor: 'center', 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 = `<svg viewBox="0 0 24 24" fill="#ef4444" stroke="#fff" stroke-width="2"><circle cx="12" cy="12" r="8"/></svg>`;
|
||
el.style.cssText = 'width: 24px; height: 24px; cursor: pointer;';
|
||
this.endMarker = new maplibregl.Marker({ element: el, anchor: 'center', 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<INominatimResult[]> {
|
||
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<string, string> = {
|
||
'depart': '⬆️',
|
||
'arrive': '🏁',
|
||
'turn-left': '↰',
|
||
'turn-right': '↱',
|
||
'turn-slight left': '↖',
|
||
'turn-slight right': '↗',
|
||
'turn-sharp left': '⬅',
|
||
'turn-sharp right': '➡',
|
||
'continue-straight': '⬆️',
|
||
'continue': '⬆️',
|
||
'roundabout': '🔄',
|
||
'rotary': '🔄',
|
||
'merge': '⤵️',
|
||
'fork-left': '↖',
|
||
'fork-right': '↗',
|
||
'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`
|
||
<div class="navigation-panel ${extraClass || ''}">
|
||
<div class="nav-header">
|
||
<div class="nav-header-icon">${renderIcon('navigation')}</div>
|
||
<span class="nav-header-title">Navigation</span>
|
||
</div>
|
||
|
||
<div class="nav-mode-selector">
|
||
<button
|
||
class="nav-mode-btn ${this.navigationMode === 'driving' ? 'active' : ''}"
|
||
@click=${() => this.setNavigationMode('driving')}
|
||
title="Driving"
|
||
>
|
||
${renderIcon('car')}
|
||
</button>
|
||
<button
|
||
class="nav-mode-btn ${this.navigationMode === 'walking' ? 'active' : ''}"
|
||
@click=${() => this.setNavigationMode('walking')}
|
||
title="Walking"
|
||
>
|
||
${renderIcon('walk')}
|
||
</button>
|
||
<button
|
||
class="nav-mode-btn ${this.navigationMode === 'cycling' ? 'active' : ''}"
|
||
@click=${() => this.setNavigationMode('cycling')}
|
||
title="Cycling"
|
||
>
|
||
${renderIcon('bike')}
|
||
</button>
|
||
</div>
|
||
|
||
<div class="nav-traffic-toggle">
|
||
<div class="nav-traffic-toggle-label">
|
||
${renderIcon('traffic')}
|
||
<span>Traffic-aware routing</span>
|
||
</div>
|
||
<button
|
||
class="nav-traffic-toggle-btn ${trafficEnabled ? 'active' : ''}"
|
||
@click=${() => this.callbacks.onTrafficToggle?.(!trafficEnabled)}
|
||
title="${trafficEnabled ? 'Disable traffic' : 'Enable traffic'}"
|
||
>
|
||
<span class="toggle-track">
|
||
<span class="toggle-thumb"></span>
|
||
</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="nav-inputs">
|
||
${this.renderNavInput('start', 'Start point', this.navStartSearchQuery, this.navStartSearchResults)}
|
||
${this.renderNavInput('end', 'End point', this.navEndSearchQuery, this.navEndSearchResults)}
|
||
</div>
|
||
|
||
<div class="nav-actions">
|
||
<button
|
||
class="nav-action-btn primary"
|
||
?disabled=${!canCalculate}
|
||
@click=${() => this.calculateRoute()}
|
||
>
|
||
Get Route
|
||
</button>
|
||
<button
|
||
class="nav-action-btn secondary"
|
||
@click=${() => this.clearNavigation()}
|
||
>
|
||
Clear
|
||
</button>
|
||
</div>
|
||
|
||
${error ? html`
|
||
<div class="nav-error">
|
||
${renderIcon('error')}
|
||
<span>${error}</span>
|
||
</div>
|
||
` : ''}
|
||
|
||
${isLoading ? html`
|
||
<div class="nav-loading">
|
||
${renderIcon('spinner')}
|
||
<span>Calculating route...</span>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/**
|
||
* 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`
|
||
<div class="navigation-panel nav-directions-view ${extraClass || ''}">
|
||
<div class="nav-directions-header">
|
||
<button
|
||
class="nav-back-btn"
|
||
@click=${() => this.setViewMode('planning')}
|
||
title="Back to route planning"
|
||
>
|
||
${renderIcon('arrowLeft')}
|
||
</button>
|
||
<div class="nav-directions-summary">
|
||
${renderIcon('ruler')}
|
||
<span>${this.formatDistance(route.distance)}</span>
|
||
<span class="nav-directions-separator">•</span>
|
||
${renderIcon('clock')}
|
||
<span>${this.formatDuration(trafficRoute?.duration ?? route.duration)}</span>
|
||
</div>
|
||
</div>
|
||
|
||
${trafficRoute ? html`
|
||
<div class="nav-traffic-info ${trafficRoute.congestionLevel}">
|
||
<span class="nav-traffic-indicator ${trafficRoute.congestionLevel}"></span>
|
||
<span class="nav-traffic-text">${this.getCongestionLabel(trafficRoute.congestionLevel)}</span>
|
||
${trafficRoute.duration > trafficRoute.durationWithoutTraffic ? html`
|
||
<span class="nav-traffic-delay">
|
||
+${this.formatDuration(trafficRoute.duration - trafficRoute.durationWithoutTraffic)} due to traffic
|
||
</span>
|
||
` : ''}
|
||
</div>
|
||
` : ''}
|
||
|
||
<div class="nav-steps">
|
||
${this.renderTurnByTurn(route)}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/**
|
||
* 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`
|
||
<div class="nav-input-group">
|
||
<div class="nav-input-marker ${inputType}"></div>
|
||
<div class="nav-input-wrapper">
|
||
<input
|
||
type="text"
|
||
class="nav-input ${hasValue ? 'has-value' : ''}"
|
||
placeholder="${placeholder}"
|
||
.value=${query}
|
||
@input=${(e: Event) => this.handleNavSearchInput(e, inputType)}
|
||
@keydown=${(e: KeyboardEvent) => this.handleNavSearchKeydown(e, inputType)}
|
||
@focus=${() => { this.navActiveInput = inputType; this.callbacks.onRequestUpdate(); }}
|
||
/>
|
||
${hasValue ? html`
|
||
<button
|
||
class="nav-input-clear"
|
||
@click=${() => this.clearNavPoint(inputType)}
|
||
title="Clear"
|
||
>
|
||
${renderIcon('close')}
|
||
</button>
|
||
` : ''}
|
||
<button
|
||
class="nav-set-map-btn ${isClickMode ? 'active' : ''}"
|
||
@click=${() => this.toggleNavClickMode(inputType)}
|
||
title="Click on map"
|
||
>
|
||
${renderIcon('mapPin')}
|
||
</button>
|
||
|
||
${results.length > 0 && this.navActiveInput === inputType ? html`
|
||
<div class="nav-search-results">
|
||
${results.map((result, index) => html`
|
||
<div
|
||
class="nav-search-result ${index === this.navHighlightedIndex ? 'highlighted' : ''}"
|
||
@click=${() => this.selectNavSearchResult(result, inputType)}
|
||
@mouseenter=${() => { this.navHighlightedIndex = index; this.callbacks.onRequestUpdate(); }}
|
||
>
|
||
<span class="nav-search-result-name">${result.display_name}</span>
|
||
<span class="nav-search-result-type">${result.type}</span>
|
||
</div>
|
||
`)}
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/**
|
||
* Render turn-by-turn directions
|
||
*/
|
||
private renderTurnByTurn(route: IOSRMRoute): TemplateResult {
|
||
if (!route.legs || route.legs.length === 0) {
|
||
return html`<div class="nav-empty-steps">No turn-by-turn directions available</div>`;
|
||
}
|
||
|
||
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`<div class="nav-empty-steps">No turn-by-turn directions available</div>`;
|
||
}
|
||
|
||
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`
|
||
<div
|
||
class="nav-step ${isCurrent ? 'current' : ''} ${isCompleted ? 'completed' : ''}"
|
||
@click=${() => this.flyToStep(step)}
|
||
data-step-index="${index}"
|
||
>
|
||
${isCurrent ? html`
|
||
<div class="nav-step-progress-bar" style="width: ${progressPercent}%"></div>
|
||
` : ''}
|
||
<div class="nav-step-icon">${isCompleted ? '✓' : icon}</div>
|
||
<div class="nav-step-content">
|
||
<div class="nav-step-instruction">${instruction}</div>
|
||
<div class="nav-step-distance">${distance}</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
})}
|
||
`;
|
||
}
|
||
|
||
/**
|
||
* 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',
|
||
});
|
||
}
|
||
});
|
||
}
|
||
}
|