initial
This commit is contained in:
943
ts_web/elements/00group-map/dees-geo-map/geo-map.navigation.ts
Normal file
943
ts_web/elements/00group-map/dees-geo-map/geo-map.navigation.ts
Normal file
@@ -0,0 +1,943 @@
|
||||
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';
|
||||
|
||||
// ─── 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
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
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';
|
||||
|
||||
// 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,
|
||||
};
|
||||
this.callbacks.onRequestUpdate();
|
||||
|
||||
try {
|
||||
const route = await this.fetchRoute(startPoint, endPoint, this.navigationMode);
|
||||
|
||||
if (route) {
|
||||
this.navigationState = {
|
||||
...this.navigationState,
|
||||
route,
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
this.renderRouteOnMap(route);
|
||||
|
||||
// Dispatch route-calculated event
|
||||
this.callbacks.onRouteCalculated({
|
||||
route,
|
||||
startPoint,
|
||||
endPoint,
|
||||
mode: this.navigationMode,
|
||||
});
|
||||
|
||||
// Fit map to route bounds
|
||||
this.fitToRoute(route);
|
||||
}
|
||||
} 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,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
this.navStartSearchQuery = '';
|
||||
this.navEndSearchQuery = '';
|
||||
this.navStartSearchResults = [];
|
||||
this.navEndSearchResults = [];
|
||||
this.navClickMode = null;
|
||||
|
||||
// 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' })
|
||||
.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' })
|
||||
.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 });
|
||||
}
|
||||
|
||||
// ─── 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] || '➡';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format step instruction for display
|
||||
*/
|
||||
public formatStepInstruction(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 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 onto ${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();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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
|
||||
*/
|
||||
public render(): TemplateResult {
|
||||
const { route, isLoading, error, startPoint, endPoint } = this.navigationState;
|
||||
const canCalculate = startPoint && endPoint && !isLoading;
|
||||
|
||||
return html`
|
||||
<div class="navigation-panel">
|
||||
<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-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>
|
||||
` : ''}
|
||||
|
||||
${route && !isLoading ? html`
|
||||
<div class="nav-summary">
|
||||
<div class="nav-summary-item">
|
||||
${renderIcon('ruler')}
|
||||
<span>${this.formatDistance(route.distance)}</span>
|
||||
</div>
|
||||
<div class="nav-summary-item">
|
||||
${renderIcon('clock')}
|
||||
<span>${this.formatDuration(route.duration)}</span>
|
||||
</div>
|
||||
</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);
|
||||
|
||||
if (steps.length === 0) {
|
||||
return html`<div class="nav-empty-steps">No turn-by-turn directions available</div>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
${steps.map(step => {
|
||||
const icon = this.getManeuverIcon(step.maneuver.type, step.maneuver.modifier);
|
||||
const instruction = this.formatStepInstruction(step);
|
||||
const distance = this.formatDistance(step.distance);
|
||||
|
||||
return html`
|
||||
<div class="nav-step">
|
||||
<div class="nav-step-icon">${icon}</div>
|
||||
<div class="nav-step-content">
|
||||
<div class="nav-step-instruction">${instruction}</div>
|
||||
<div class="nav-step-distance">${distance}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user