2026-02-05 12:03:22 +00:00
|
|
|
|
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';
|
2026-02-05 15:07:33 +00:00
|
|
|
|
import type { ITrafficAwareRoute } from './geo-map.traffic.providers.js';
|
2026-02-05 12:03:22 +00:00
|
|
|
|
|
|
|
|
|
|
// ─── 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;
|
2026-02-05 15:07:33 +00:00
|
|
|
|
trafficRoute: ITrafficAwareRoute | null;
|
2026-02-05 12:03:22 +00:00
|
|
|
|
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;
|
2026-02-05 15:07:33 +00:00
|
|
|
|
/** Optional callback to fetch traffic-aware route */
|
|
|
|
|
|
getTrafficRoute?: (
|
|
|
|
|
|
start: [number, number],
|
|
|
|
|
|
end: [number, number],
|
|
|
|
|
|
mode: TNavigationMode
|
|
|
|
|
|
) => Promise<ITrafficAwareRoute | null>;
|
2026-02-05 16:50:51 +00:00
|
|
|
|
/** Optional callback when traffic toggle is changed */
|
|
|
|
|
|
onTrafficToggle?: (enabled: boolean) => void;
|
|
|
|
|
|
/** Optional callback to get current traffic state */
|
|
|
|
|
|
getTrafficEnabled?: () => boolean;
|
2026-02-05 12:03:22 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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,
|
2026-02-05 15:07:33 +00:00
|
|
|
|
trafficRoute: null,
|
2026-02-05 12:03:22 +00:00
|
|
|
|
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';
|
|
|
|
|
|
|
2026-02-05 15:49:07 +00:00
|
|
|
|
// View mode: 'planning' for route input, 'directions' for turn-by-turn
|
|
|
|
|
|
public viewMode: 'planning' | 'directions' = 'planning';
|
|
|
|
|
|
|
2026-02-05 12:03:22 +00:00
|
|
|
|
// 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,
|
2026-02-05 15:07:33 +00:00
|
|
|
|
trafficRoute: null,
|
2026-02-05 12:03:22 +00:00
|
|
|
|
};
|
|
|
|
|
|
this.callbacks.onRequestUpdate();
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-02-05 15:07:33 +00:00
|
|
|
|
// 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),
|
|
|
|
|
|
]);
|
2026-02-05 12:03:22 +00:00
|
|
|
|
|
|
|
|
|
|
if (route) {
|
|
|
|
|
|
this.navigationState = {
|
|
|
|
|
|
...this.navigationState,
|
|
|
|
|
|
route,
|
2026-02-05 15:07:33 +00:00
|
|
|
|
trafficRoute,
|
2026-02-05 12:03:22 +00:00
|
|
|
|
isLoading: false,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-05 15:07:33 +00:00
|
|
|
|
// Use traffic route geometry if available, otherwise use regular route
|
|
|
|
|
|
const routeToRender = trafficRoute || route;
|
|
|
|
|
|
this.renderRouteOnMap(routeToRender);
|
2026-02-05 12:03:22 +00:00
|
|
|
|
|
|
|
|
|
|
// Dispatch route-calculated event
|
|
|
|
|
|
this.callbacks.onRouteCalculated({
|
|
|
|
|
|
route,
|
|
|
|
|
|
startPoint,
|
|
|
|
|
|
endPoint,
|
|
|
|
|
|
mode: this.navigationMode,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Fit map to route bounds
|
2026-02-05 15:07:33 +00:00
|
|
|
|
this.fitToRoute(routeToRender);
|
2026-02-05 15:49:07 +00:00
|
|
|
|
|
|
|
|
|
|
// Switch to directions view after successful route calculation
|
|
|
|
|
|
this.viewMode = 'directions';
|
2026-02-05 12:03:22 +00:00
|
|
|
|
}
|
|
|
|
|
|
} 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,
|
2026-02-05 15:07:33 +00:00
|
|
|
|
trafficRoute: null,
|
2026-02-05 12:03:22 +00:00
|
|
|
|
isLoading: false,
|
|
|
|
|
|
error: null,
|
|
|
|
|
|
};
|
|
|
|
|
|
this.navStartSearchQuery = '';
|
|
|
|
|
|
this.navEndSearchQuery = '';
|
|
|
|
|
|
this.navStartSearchResults = [];
|
|
|
|
|
|
this.navEndSearchResults = [];
|
|
|
|
|
|
this.navClickMode = null;
|
2026-02-05 15:49:07 +00:00
|
|
|
|
this.viewMode = 'planning';
|
2026-02-05 12:03:22 +00:00
|
|
|
|
|
|
|
|
|
|
// 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;';
|
2026-02-05 17:50:45 +00:00
|
|
|
|
this.startMarker = new maplibregl.Marker({ element: el, anchor: 'center', pitchAlignment: 'viewport' })
|
2026-02-05 12:03:22 +00:00
|
|
|
|
.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;';
|
2026-02-05 17:50:45 +00:00
|
|
|
|
this.endMarker = new maplibregl.Marker({ element: el, anchor: 'center', pitchAlignment: 'viewport' })
|
2026-02-05 12:03:22 +00:00
|
|
|
|
.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 });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-05 15:49:07 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* 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,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-05 12:03:22 +00:00
|
|
|
|
// ─── 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] || '➡';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-05 15:07:33 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* 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];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-05 12:03:22 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* 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();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-05 15:49:07 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* Switch between planning and directions view
|
|
|
|
|
|
*/
|
|
|
|
|
|
public setViewMode(mode: 'planning' | 'directions'): void {
|
|
|
|
|
|
this.viewMode = mode;
|
|
|
|
|
|
this.callbacks.onRequestUpdate();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-05 12:03:22 +00:00
|
|
|
|
// ─── 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
|
2026-02-05 15:07:33 +00:00
|
|
|
|
* @param extraClass - Optional CSS class to add to the panel for positioning
|
2026-02-05 12:03:22 +00:00
|
|
|
|
*/
|
2026-02-05 15:07:33 +00:00
|
|
|
|
public render(extraClass?: string): TemplateResult {
|
2026-02-05 15:49:07 +00:00
|
|
|
|
// 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;
|
2026-02-05 12:03:22 +00:00
|
|
|
|
const canCalculate = startPoint && endPoint && !isLoading;
|
2026-02-05 16:50:51 +00:00
|
|
|
|
const trafficEnabled = this.callbacks.getTrafficEnabled?.() ?? false;
|
2026-02-05 12:03:22 +00:00
|
|
|
|
|
|
|
|
|
|
return html`
|
2026-02-05 15:07:33 +00:00
|
|
|
|
<div class="navigation-panel ${extraClass || ''}">
|
2026-02-05 12:03:22 +00:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-02-05 16:50:51 +00:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-02-05 12:03:22 +00:00
|
|
|
|
<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>
|
|
|
|
|
|
` : ''}
|
2026-02-05 15:49:07 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}
|
2026-02-05 12:03:22 +00:00
|
|
|
|
|
2026-02-05 15:49:07 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* Render the directions view (back button, summary, turn-by-turn steps)
|
|
|
|
|
|
*/
|
|
|
|
|
|
private renderDirectionsView(extraClass?: string): TemplateResult {
|
|
|
|
|
|
const { route, trafficRoute } = this.navigationState;
|
2026-02-05 12:03:22 +00:00
|
|
|
|
|
2026-02-05 15:49:07 +00:00
|
|
|
|
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>
|
2026-02-05 15:07:33 +00:00
|
|
|
|
|
2026-02-05 15:49:07 +00:00
|
|
|
|
${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>
|
|
|
|
|
|
` : ''}
|
2026-02-05 12:03:22 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
` : ''}
|
2026-02-05 15:49:07 +00:00
|
|
|
|
|
|
|
|
|
|
<div class="nav-steps">
|
|
|
|
|
|
${this.renderTurnByTurn(route)}
|
|
|
|
|
|
</div>
|
2026-02-05 12:03:22 +00:00
|
|
|
|
</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`
|
2026-02-05 15:49:07 +00:00
|
|
|
|
<div class="nav-step" @click=${() => this.flyToStep(step)}>
|
2026-02-05 12:03:22 +00:00
|
|
|
|
<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>
|
|
|
|
|
|
`;
|
|
|
|
|
|
})}
|
|
|
|
|
|
`;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|