Files
dees-catalog-geo/ts_web/elements/00group-map/dees-geo-map/geo-map.navigation.ts
2026-02-05 12:03:22 +00:00

944 lines
28 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';
// ─── 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>
`;
})}
`;
}
}