923 lines
29 KiB
TypeScript
923 lines
29 KiB
TypeScript
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
|
|
•
|
|
${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();
|
|
}
|
|
}
|