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

944 lines
28 KiB
TypeScript
Raw Normal View History

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