Files
dees-catalog-geo/ts_web/elements/00group-map/dees-geo-map/geo-map.navigation.ts

1121 lines
34 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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',
});
}
});
}
}