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

923 lines
29 KiB
TypeScript
Raw Normal View History

import { html, type TemplateResult } from '@design.estate/dees-element';
import maplibregl from 'maplibre-gl';
import { VoiceSynthesisManager, type IVoiceConfig } from './geo-map.voice.js';
import type { IOSRMRoute, IOSRMStep } from './geo-map.navigation.js';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface IGPSPosition {
lng: number;
lat: number;
heading?: number; // 0-360 degrees, 0 = North
speed?: number; // meters/second
accuracy?: number; // meters
timestamp: Date;
}
export interface INavigationGuideState {
isNavigating: boolean;
currentStepIndex: number;
distanceToNextManeuver: number; // meters
distanceRemaining: number; // meters
timeRemaining: number; // seconds
currentPosition: IGPSPosition | null;
isOffRoute: boolean;
hasArrived: boolean;
}
export type TGuidanceEventType =
| 'approach-maneuver' // "In 200m, turn left"
| 'execute-maneuver' // "Turn left now"
| 'step-change' // Moved to next step
| 'off-route' // User deviated from route
| 'arrived' // Reached destination
| 'position-updated'; // Position was updated
export interface IGuidanceEvent {
type: TGuidanceEventType;
position: IGPSPosition;
stepIndex: number;
step?: IOSRMStep;
distanceToManeuver: number;
instruction?: string;
}
export interface INavigationGuideCallbacks {
onGuidanceEvent: (event: IGuidanceEvent) => void;
onRequestUpdate: () => void;
getMap: () => maplibregl.Map | null;
}
// ─── Distance Thresholds ──────────────────────────────────────────────────────
const GUIDANCE_THRESHOLDS = {
FAR: 500, // "In 500 meters..."
APPROACHING: 200, // "In 200 meters..."
NEAR: 50, // "Turn left ahead"
AT_MANEUVER: 20, // "Turn left now"
OFF_ROUTE: 50, // Distance from route line to trigger off-route
ARRIVED: 30, // Distance from destination to trigger arrival
};
// ─── Utility Functions ────────────────────────────────────────────────────────
/**
* Calculate distance between two points using Haversine formula
*/
function haversineDistance(
lat1: number, lng1: number,
lat2: number, lng2: number
): number {
const R = 6371000; // Earth's radius in meters
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLng = (lng2 - lng1) * Math.PI / 180;
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLng / 2) * Math.sin(dLng / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
/**
* Calculate distance from a point to a line segment
*/
function pointToLineDistance(
px: number, py: number,
x1: number, y1: number,
x2: number, y2: number
): number {
const A = px - x1;
const B = py - y1;
const C = x2 - x1;
const D = y2 - y1;
const dot = A * C + B * D;
const lenSq = C * C + D * D;
let param = -1;
if (lenSq !== 0) {
param = dot / lenSq;
}
let xx: number, yy: number;
if (param < 0) {
xx = x1;
yy = y1;
} else if (param > 1) {
xx = x2;
yy = y2;
} else {
xx = x1 + param * C;
yy = y1 + param * D;
}
return haversineDistance(py, px, yy, xx);
}
/**
* Find the minimum distance from a point to a polyline
*/
function pointToPolylineDistance(
px: number, py: number,
coordinates: [number, number][]
): number {
let minDist = Infinity;
for (let i = 0; i < coordinates.length - 1; i++) {
const [x1, y1] = coordinates[i];
const [x2, y2] = coordinates[i + 1];
const dist = pointToLineDistance(px, py, x1, y1, x2, y2);
if (dist < minDist) {
minDist = dist;
}
}
return minDist;
}
/**
* Format distance for voice instructions
*/
function formatDistanceForVoice(meters: number): string {
if (meters < 100) {
return `${Math.round(meters / 10) * 10} meters`;
} else if (meters < 1000) {
return `${Math.round(meters / 50) * 50} meters`;
} else {
const km = meters / 1000;
if (km < 10) {
return `${km.toFixed(1)} kilometers`;
}
return `${Math.round(km)} kilometers`;
}
}
/**
* Format maneuver type for voice
*/
function formatManeuverForVoice(type: string, modifier?: string): string {
switch (type) {
case 'turn':
if (modifier === 'left') return 'turn left';
if (modifier === 'right') return 'turn right';
if (modifier === 'slight left') return 'bear left';
if (modifier === 'slight right') return 'bear right';
if (modifier === 'sharp left') return 'take a sharp left';
if (modifier === 'sharp right') return 'take a sharp right';
return `turn ${modifier || ''}`.trim();
case 'depart':
return 'head forward';
case 'arrive':
return 'arrive at your destination';
case 'merge':
return `merge ${modifier || ''}`.trim();
case 'fork':
if (modifier === 'left') return 'keep left at the fork';
if (modifier === 'right') return 'keep right at the fork';
return 'take the fork';
case 'roundabout':
case 'rotary':
return 'enter the roundabout';
case 'continue':
return 'continue straight';
case 'end of road':
return `at the end of the road, turn ${modifier || 'around'}`;
case 'new name':
return 'continue straight';
default:
return type;
}
}
// ─── Camera Configuration ────────────────────────────────────────────────────
export interface INavigationCameraConfig {
followPosition: boolean; // Camera follows GPS position (default: true)
followBearing: boolean; // Camera rotates with heading (default: true)
pitch: number; // 3D tilt angle in degrees (default: 60)
zoom: number; // Navigation zoom level (default: 17)
}
// ─── NavigationGuideController ────────────────────────────────────────────────
/**
* Controller for real-time GPS navigation guidance
* Tracks position, step progression, and fires guidance events
*/
export class NavigationGuideController {
// State
public state: INavigationGuideState = {
isNavigating: false,
currentStepIndex: 0,
distanceToNextManeuver: 0,
distanceRemaining: 0,
timeRemaining: 0,
currentPosition: null,
isOffRoute: false,
hasArrived: false,
};
// Route data
private route: IOSRMRoute | null = null;
private allSteps: IOSRMStep[] = [];
// Voice synthesis
private voiceManager: VoiceSynthesisManager;
// Position marker
private positionMarker: maplibregl.Marker | null = null;
// Track last position time for smooth animation timing
private lastPositionTimestamp: number = 0;
// Camera configuration for navigation mode
private cameraConfig: INavigationCameraConfig = {
followPosition: true,
followBearing: true,
pitch: 60,
zoom: 17,
};
// Announcement tracking to avoid repetition
private lastAnnouncedThreshold: number | null = null;
private lastAnnouncedStepIndex: number = -1;
// Callbacks
private callbacks: INavigationGuideCallbacks;
constructor(callbacks: INavigationGuideCallbacks, voiceConfig?: Partial<IVoiceConfig>) {
this.callbacks = callbacks;
this.voiceManager = new VoiceSynthesisManager(voiceConfig);
}
// ─── Configuration ──────────────────────────────────────────────────────────
/**
* Configure voice settings
*/
public configureVoice(config: Partial<IVoiceConfig>): void {
this.voiceManager.configure(config);
}
/**
* Enable/disable voice
*/
public setVoiceEnabled(enabled: boolean): void {
if (enabled) {
this.voiceManager.enable();
} else {
this.voiceManager.disable();
}
}
/**
* Check if voice is enabled
*/
public isVoiceEnabled(): boolean {
return this.voiceManager.isEnabled();
}
/**
* Get voice manager for direct access
*/
public getVoiceManager(): VoiceSynthesisManager {
return this.voiceManager;
}
// ─── Camera Configuration ──────────────────────────────────────────────────
/**
* Enable/disable camera following the GPS position
*/
public setFollowPosition(enabled: boolean): void {
this.cameraConfig.followPosition = enabled;
}
/**
* Check if camera is following position
*/
public isFollowingPosition(): boolean {
return this.cameraConfig.followPosition;
}
/**
* Enable/disable camera rotating with heading
*/
public setFollowBearing(enabled: boolean): void {
this.cameraConfig.followBearing = enabled;
}
/**
* Check if camera is following bearing
*/
public isFollowingBearing(): boolean {
return this.cameraConfig.followBearing;
}
/**
* Set the navigation camera pitch (3D tilt angle)
* @param pitch - Angle in degrees (0 = flat, 60 = tilted)
*/
public setPitch(pitch: number): void {
this.cameraConfig.pitch = Math.max(0, Math.min(85, pitch));
}
/**
* Get current pitch setting
*/
public getPitch(): number {
return this.cameraConfig.pitch;
}
/**
* Set the navigation zoom level
* @param zoom - Zoom level (typically 15-19 for street-level navigation)
*/
public setZoom(zoom: number): void {
this.cameraConfig.zoom = Math.max(1, Math.min(22, zoom));
}
/**
* Get current zoom setting
*/
public getZoom(): number {
return this.cameraConfig.zoom;
}
/**
* Get the full camera configuration
*/
public getCameraConfig(): INavigationCameraConfig {
return { ...this.cameraConfig };
}
/**
* Set the full camera configuration
*/
public setCameraConfig(config: Partial<INavigationCameraConfig>): void {
if (config.followPosition !== undefined) {
this.cameraConfig.followPosition = config.followPosition;
}
if (config.followBearing !== undefined) {
this.cameraConfig.followBearing = config.followBearing;
}
if (config.pitch !== undefined) {
this.cameraConfig.pitch = Math.max(0, Math.min(85, config.pitch));
}
if (config.zoom !== undefined) {
this.cameraConfig.zoom = Math.max(1, Math.min(22, config.zoom));
}
}
// ─── Navigation Lifecycle ───────────────────────────────────────────────────
/**
* Start navigation guidance for a route
*/
public startGuidance(route: IOSRMRoute): void {
this.route = route;
this.allSteps = route.legs.flatMap(leg => leg.steps);
this.state = {
isNavigating: true,
currentStepIndex: 0,
distanceToNextManeuver: this.allSteps[0]?.distance ?? 0,
distanceRemaining: route.distance,
timeRemaining: route.duration,
currentPosition: null,
isOffRoute: false,
hasArrived: false,
};
this.lastAnnouncedThreshold = null;
this.lastAnnouncedStepIndex = -1;
this.callbacks.onRequestUpdate();
}
/**
* Stop navigation guidance
*/
public stopGuidance(): void {
this.state = {
isNavigating: false,
currentStepIndex: 0,
distanceToNextManeuver: 0,
distanceRemaining: 0,
timeRemaining: 0,
currentPosition: null,
isOffRoute: false,
hasArrived: false,
};
this.route = null;
this.allSteps = [];
this.lastAnnouncedThreshold = null;
this.lastAnnouncedStepIndex = -1;
this.lastPositionTimestamp = 0;
this.voiceManager.stop();
this.removePositionMarker();
this.callbacks.onRequestUpdate();
}
/**
* Check if currently navigating
*/
public isNavigating(): boolean {
return this.state.isNavigating;
}
// ─── Position Updates ───────────────────────────────────────────────────────
/**
* Update position from external GPS source
*/
public updatePosition(position: IGPSPosition): void {
if (!this.state.isNavigating || !this.route) return;
this.state.currentPosition = position;
// Update position marker
this.updatePositionMarker(position);
// Check if off-route
const distanceFromRoute = this.calculateDistanceFromRoute(position);
const wasOffRoute = this.state.isOffRoute;
this.state.isOffRoute = distanceFromRoute > GUIDANCE_THRESHOLDS.OFF_ROUTE;
if (this.state.isOffRoute && !wasOffRoute) {
this.handleOffRoute(position);
return;
}
// Find current step and calculate distances
this.updateStepProgress(position);
// Check for arrival
this.checkArrival(position);
// Fire position updated event
this.emitGuidanceEvent('position-updated', position);
this.callbacks.onRequestUpdate();
}
/**
* Set position (convenience method for external use)
*/
public setPosition(lng: number, lat: number, heading?: number, speed?: number): void {
this.updatePosition({
lng,
lat,
heading,
speed,
timestamp: new Date(),
});
}
// ─── Step Progress Tracking ─────────────────────────────────────────────────
/**
* Update step progress based on current position
*/
private updateStepProgress(position: IGPSPosition): void {
if (this.allSteps.length === 0) return;
// Find the closest step maneuver point
let closestStepIndex = this.state.currentStepIndex;
let minDistance = Infinity;
// Look ahead a few steps (don't go backwards)
const searchEnd = Math.min(this.state.currentStepIndex + 3, this.allSteps.length);
for (let i = this.state.currentStepIndex; i < searchEnd; i++) {
const step = this.allSteps[i];
const [maneuverLng, maneuverLat] = step.maneuver.location;
const distance = haversineDistance(position.lat, position.lng, maneuverLat, maneuverLng);
if (distance < minDistance) {
minDistance = distance;
closestStepIndex = i;
}
}
// Check if we've passed the current maneuver (close to it)
const currentStep = this.allSteps[this.state.currentStepIndex];
if (currentStep) {
const [maneuverLng, maneuverLat] = currentStep.maneuver.location;
const distanceToManeuver = haversineDistance(position.lat, position.lng, maneuverLat, maneuverLng);
// If we're very close to the maneuver or past it, advance to next step
if (distanceToManeuver < GUIDANCE_THRESHOLDS.AT_MANEUVER &&
this.state.currentStepIndex < this.allSteps.length - 1) {
this.advanceToNextStep(position);
return;
}
// Update distance to maneuver
this.state.distanceToNextManeuver = distanceToManeuver;
// Check guidance thresholds
this.checkGuidanceThresholds(position, distanceToManeuver, currentStep);
}
// Calculate remaining distance and time
this.calculateRemaining(position);
}
/**
* Advance to the next step
*/
private advanceToNextStep(position: IGPSPosition): void {
const previousStepIndex = this.state.currentStepIndex;
this.state.currentStepIndex++;
if (this.state.currentStepIndex >= this.allSteps.length) {
// Reached end of route
return;
}
const newStep = this.allSteps[this.state.currentStepIndex];
// Reset announcement tracking for new step
this.lastAnnouncedThreshold = null;
// Emit step change event
this.emitGuidanceEvent('step-change', position, newStep);
// Announce the new step if it's not just departing
if (this.lastAnnouncedStepIndex !== previousStepIndex) {
this.lastAnnouncedStepIndex = previousStepIndex;
}
}
/**
* Check guidance thresholds and announce instructions
*/
private checkGuidanceThresholds(position: IGPSPosition, distance: number, step: IOSRMStep): void {
// Determine current threshold zone
let currentThreshold: number | null = null;
if (distance <= GUIDANCE_THRESHOLDS.AT_MANEUVER) {
currentThreshold = GUIDANCE_THRESHOLDS.AT_MANEUVER;
} else if (distance <= GUIDANCE_THRESHOLDS.NEAR) {
currentThreshold = GUIDANCE_THRESHOLDS.NEAR;
} else if (distance <= GUIDANCE_THRESHOLDS.APPROACHING) {
currentThreshold = GUIDANCE_THRESHOLDS.APPROACHING;
} else if (distance <= GUIDANCE_THRESHOLDS.FAR) {
currentThreshold = GUIDANCE_THRESHOLDS.FAR;
}
// Only announce if we've entered a new threshold zone
if (currentThreshold !== null && currentThreshold !== this.lastAnnouncedThreshold) {
this.lastAnnouncedThreshold = currentThreshold;
this.announceManeuver(position, distance, step, currentThreshold);
}
}
/**
* Announce maneuver based on threshold
*/
private announceManeuver(position: IGPSPosition, distance: number, step: IOSRMStep, threshold: number): void {
const maneuver = formatManeuverForVoice(step.maneuver.type, step.maneuver.modifier);
const streetName = step.name !== '' ? step.name : undefined;
if (threshold === GUIDANCE_THRESHOLDS.AT_MANEUVER) {
// Execute maneuver
this.voiceManager.speakManeuver(maneuver, true);
this.emitGuidanceEvent('execute-maneuver', position, step, maneuver);
} else if (threshold === GUIDANCE_THRESHOLDS.NEAR) {
// "Turn left ahead"
const instruction = `${maneuver} ahead`;
this.voiceManager.speak(instruction);
this.emitGuidanceEvent('approach-maneuver', position, step, instruction);
} else {
// Approach - "In 200 meters, turn left"
const distanceStr = formatDistanceForVoice(distance);
this.voiceManager.speakApproach(distanceStr, maneuver, streetName);
// Use "on" for continue, "onto" for turns/merges
const preposition = maneuver.startsWith('continue') ? 'on' : 'onto';
const instruction = `In ${distanceStr}, ${maneuver}${streetName ? ` ${preposition} ${streetName}` : ''}`;
this.emitGuidanceEvent('approach-maneuver', position, step, instruction);
}
}
// ─── Route Calculations ─────────────────────────────────────────────────────
/**
* Calculate distance from current position to route line
*/
private calculateDistanceFromRoute(position: IGPSPosition): number {
if (!this.route) return 0;
const coords = this.route.geometry.coordinates as [number, number][];
return pointToPolylineDistance(position.lng, position.lat, coords);
}
/**
* Calculate remaining distance and time
*/
private calculateRemaining(position: IGPSPosition): void {
if (!this.route || this.allSteps.length === 0) return;
// Sum distance/duration of remaining steps
let remainingDistance = 0;
let remainingDuration = 0;
for (let i = this.state.currentStepIndex; i < this.allSteps.length; i++) {
remainingDistance += this.allSteps[i].distance;
remainingDuration += this.allSteps[i].duration;
}
// Subtract distance already traveled in current step
if (this.state.currentStepIndex < this.allSteps.length) {
const currentStep = this.allSteps[this.state.currentStepIndex];
const stepProgress = 1 - (this.state.distanceToNextManeuver / currentStep.distance);
const progressDistance = currentStep.distance * Math.max(0, Math.min(1, stepProgress));
remainingDistance -= progressDistance;
}
this.state.distanceRemaining = Math.max(0, remainingDistance);
this.state.timeRemaining = Math.max(0, remainingDuration);
}
/**
* Check if user has arrived at destination
*/
private checkArrival(position: IGPSPosition): void {
if (!this.route || this.state.hasArrived) return;
// Get the last coordinate of the route (destination)
const coords = this.route.geometry.coordinates;
const destination = coords[coords.length - 1] as [number, number];
const distanceToDestination = haversineDistance(
position.lat, position.lng,
destination[1], destination[0]
);
if (distanceToDestination <= GUIDANCE_THRESHOLDS.ARRIVED) {
this.state.hasArrived = true;
this.voiceManager.speakArrival();
this.emitGuidanceEvent('arrived', position);
}
}
/**
* Handle off-route scenario
*/
private handleOffRoute(position: IGPSPosition): void {
this.voiceManager.speakOffRoute();
this.emitGuidanceEvent('off-route', position);
}
// ─── Position Marker ────────────────────────────────────────────────────────
/**
* Update position marker on map and move camera to follow
*/
private updatePositionMarker(position: IGPSPosition): void {
const map = this.callbacks.getMap();
if (!map) return;
if (!this.positionMarker) {
// Create marker element with inline styles (Shadow DOM fix)
// MapLibre markers are added to the light DOM, so CSS classes from
// Shadow DOM won't apply. Using inline styles ensures proper styling.
const el = document.createElement('div');
el.style.cssText = 'width: 32px; height: 32px; cursor: default; filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));';
el.innerHTML = this.createPositionMarkerSVG();
this.positionMarker = new maplibregl.Marker({
element: el,
anchor: 'center',
rotationAlignment: 'map',
pitchAlignment: 'viewport',
})
.setLngLat([position.lng, position.lat])
.addTo(map);
} else {
this.positionMarker.setLngLat([position.lng, position.lat]);
}
// Apply rotation via MapLibre's marker rotation (correct anchor handling)
if (position.heading !== undefined) {
this.positionMarker.setRotation(position.heading);
}
// Calculate animation duration based on time since last update
// This creates smooth, continuous animation that matches the GPS update interval
const now = Date.now();
const timeSinceLastUpdate = this.lastPositionTimestamp > 0
? now - this.lastPositionTimestamp
: 1000;
this.lastPositionTimestamp = now;
// Clamp duration: min 100ms (prevents jarring), max 2000ms (prevents lag)
const animationDuration = Math.max(100, Math.min(2000, timeSinceLastUpdate));
// Move camera to follow position
if (this.cameraConfig.followPosition) {
const bearing = this.cameraConfig.followBearing && position.heading !== undefined
? position.heading
: map.getBearing();
map.easeTo({
center: [position.lng, position.lat],
bearing,
pitch: this.cameraConfig.pitch,
zoom: this.cameraConfig.zoom,
duration: animationDuration,
});
}
}
/**
* Create SVG for position marker with heading indicator
* Note: Rotation is handled by MapLibre's marker.setRotation() for correct anchor handling
*/
private createPositionMarkerSVG(): string {
return `
<svg width="32" height="32" viewBox="0 0 32 32">
<!-- Blue circle -->
<circle cx="16" cy="16" r="10" fill="#3b82f6" stroke="#fff" stroke-width="3"/>
<!-- Heading indicator triangle (pointing up/north, rotation handled by MapLibre) -->
<polygon points="16,2 12,10 20,10" fill="#fff"/>
<!-- Accuracy circle (semi-transparent) -->
<circle cx="16" cy="16" r="14" fill="rgba(59, 130, 246, 0.2)" stroke="none"/>
</svg>
`;
}
/**
* Remove position marker from map
*/
private removePositionMarker(): void {
if (this.positionMarker) {
this.positionMarker.remove();
this.positionMarker = null;
}
}
// ─── Event Emission ─────────────────────────────────────────────────────────
/**
* Emit a guidance event
*/
private emitGuidanceEvent(
type: TGuidanceEventType,
position: IGPSPosition,
step?: IOSRMStep,
instruction?: string
): void {
const event: IGuidanceEvent = {
type,
position,
stepIndex: this.state.currentStepIndex,
step,
distanceToManeuver: this.state.distanceToNextManeuver,
instruction,
};
this.callbacks.onGuidanceEvent(event);
}
// ─── Render ─────────────────────────────────────────────────────────────────
/**
* Render guidance panel UI
*/
public render(): TemplateResult {
if (!this.state.isNavigating || !this.route) {
return html``;
}
const currentStep = this.allSteps[this.state.currentStepIndex];
const distance = this.formatDistance(this.state.distanceToNextManeuver);
const maneuverIcon = this.getManeuverIcon(currentStep?.maneuver.type, currentStep?.maneuver.modifier);
const instruction = currentStep ? this.formatInstruction(currentStep) : '';
return html`
<div class="guidance-panel">
<div class="guidance-maneuver">
<div class="guidance-maneuver-icon">${maneuverIcon}</div>
<div class="guidance-maneuver-distance">${distance}</div>
</div>
<div class="guidance-instruction">${instruction}</div>
<div class="guidance-remaining">
${this.formatDistance(this.state.distanceRemaining)} remaining
&bull;
${this.formatDuration(this.state.timeRemaining)}
</div>
${this.state.isOffRoute ? html`
<div class="guidance-off-route">
Off route - recalculating...
</div>
` : ''}
</div>
`;
}
// ─── Formatting Utilities ───────────────────────────────────────────────────
/**
* Format distance for display
*/
private formatDistance(meters: number): string {
if (meters < 1000) {
return `${Math.round(meters)} m`;
}
return `${(meters / 1000).toFixed(1)} km`;
}
/**
* Format duration for display
*/
private 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
*/
private getManeuverIcon(type?: string, modifier?: string): string {
if (!type) return '➡';
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': '↗',
'new name': '⬆️',
};
const key = modifier ? `${type}-${modifier}` : type;
return icons[key] || icons[type] || '➡';
}
/**
* Format step instruction
*/
private formatInstruction(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 '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 'roundabout':
case 'rotary':
return `At the roundabout, take the exit onto ${name}`;
case 'fork':
return `Take the ${modifier || ''} fork 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} on ${name}`;
}
}
// ─── Cleanup ────────────────────────────────────────────────────────────────
/**
* Clean up resources
*/
public cleanup(): void {
this.stopGuidance();
this.removePositionMarker();
}
}