import type { IOSRMRoute } from './geo-map.navigation.js'; import type { IGPSPosition } from './geo-map.navigation-guide.js'; // ─── Types ──────────────────────────────────────────────────────────────────── export type TSimulationSpeed = 'walking' | 'cycling' | 'city' | 'highway' | 'custom'; export interface ISimulationSpeedConfig { walking: number; // m/s cycling: number; city: number; highway: number; } export interface IMockGPSConfig { speed: TSimulationSpeed; customSpeedMps?: number; // Custom speed in m/s updateInterval: number; // ms between position updates jitterMeters: number; // Random GPS jitter amount startFromBeginning: boolean; } export interface IMockGPSCallbacks { onPositionUpdate: (position: IGPSPosition) => void; onSimulationStart?: () => void; onSimulationPause?: () => void; onSimulationStop?: () => void; onSimulationComplete?: () => void; } // ─── Speed Presets ──────────────────────────────────────────────────────────── const SPEED_PRESETS: ISimulationSpeedConfig = { walking: 1.4, // ~5 km/h cycling: 5.5, // ~20 km/h city: 13.9, // ~50 km/h highway: 27.8, // ~100 km/h }; // ─── 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 bearing between two points (in degrees, 0 = North) */ function calculateBearing( lat1: number, lng1: number, lat2: number, lng2: number ): number { const dLng = (lng2 - lng1) * Math.PI / 180; const lat1Rad = lat1 * Math.PI / 180; const lat2Rad = lat2 * Math.PI / 180; const y = Math.sin(dLng) * Math.cos(lat2Rad); const x = Math.cos(lat1Rad) * Math.sin(lat2Rad) - Math.sin(lat1Rad) * Math.cos(lat2Rad) * Math.cos(dLng); const bearing = Math.atan2(y, x) * 180 / Math.PI; return (bearing + 360) % 360; } /** * Interpolate between two coordinates */ function interpolateCoord( start: [number, number], end: [number, number], fraction: number ): [number, number] { return [ start[0] + (end[0] - start[0]) * fraction, start[1] + (end[1] - start[1]) * fraction, ]; } /** * Add random GPS jitter to coordinates */ function addJitter(lng: number, lat: number, jitterMeters: number): [number, number] { // Convert jitter meters to approximate degrees // 1 degree latitude ≈ 111,000 meters // 1 degree longitude ≈ 111,000 * cos(latitude) meters const jitterLat = ((Math.random() - 0.5) * 2 * jitterMeters) / 111000; const jitterLng = ((Math.random() - 0.5) * 2 * jitterMeters) / (111000 * Math.cos(lat * Math.PI / 180)); return [lng + jitterLng, lat + jitterLat]; } // ─── MockGPSSimulator ───────────────────────────────────────────────────────── /** * Simulates GPS positions along a route for testing/demo purposes */ export class MockGPSSimulator { // Configuration private config: IMockGPSConfig = { speed: 'city', updateInterval: 1000, jitterMeters: 2, startFromBeginning: true, }; // Route data private route: IOSRMRoute | null = null; private coordinates: [number, number][] = []; private segmentDistances: number[] = []; private totalDistance: number = 0; // Simulation state private isRunning: boolean = false; private isPaused: boolean = false; private currentDistanceTraveled: number = 0; private lastPosition: IGPSPosition | null = null; private intervalId: ReturnType | null = null; // Callbacks private callbacks: IMockGPSCallbacks; constructor(callbacks: IMockGPSCallbacks, config?: Partial) { this.callbacks = callbacks; if (config) { this.configure(config); } } // ─── Configuration ────────────────────────────────────────────────────────── /** * Configure simulator settings */ public configure(config: Partial): void { this.config = { ...this.config, ...config }; } /** * Get current configuration */ public getConfig(): IMockGPSConfig { return { ...this.config }; } /** * Set simulation speed */ public setSpeed(speed: TSimulationSpeed, customSpeedMps?: number): void { this.config.speed = speed; if (speed === 'custom' && customSpeedMps !== undefined) { this.config.customSpeedMps = customSpeedMps; } } /** * Get current speed in m/s */ public getSpeedMps(): number { if (this.config.speed === 'custom' && this.config.customSpeedMps !== undefined) { return this.config.customSpeedMps; } return SPEED_PRESETS[this.config.speed] || SPEED_PRESETS.city; } /** * Get speed in km/h */ public getSpeedKmh(): number { return this.getSpeedMps() * 3.6; } // ─── Route Setup ──────────────────────────────────────────────────────────── /** * Set the route to simulate */ public setRoute(route: IOSRMRoute): void { this.route = route; this.coordinates = route.geometry.coordinates as [number, number][]; // Pre-calculate segment distances this.segmentDistances = []; this.totalDistance = 0; for (let i = 0; i < this.coordinates.length - 1; i++) { const [lng1, lat1] = this.coordinates[i]; const [lng2, lat2] = this.coordinates[i + 1]; const distance = haversineDistance(lat1, lng1, lat2, lng2); this.segmentDistances.push(distance); this.totalDistance += distance; } // Reset state this.currentDistanceTraveled = 0; this.lastPosition = null; } /** * Get total route distance */ public getTotalDistance(): number { return this.totalDistance; } /** * Get current distance traveled */ public getDistanceTraveled(): number { return this.currentDistanceTraveled; } /** * Get progress as percentage (0-100) */ public getProgress(): number { if (this.totalDistance === 0) return 0; return (this.currentDistanceTraveled / this.totalDistance) * 100; } // ─── Simulation Control ───────────────────────────────────────────────────── /** * Start the simulation */ public start(): void { if (!this.route || this.coordinates.length < 2) { console.warn('[MockGPSSimulator] No route set or route too short'); return; } if (this.isRunning && !this.isPaused) { return; // Already running } if (this.isPaused) { // Resume from paused state this.isPaused = false; } else { // Fresh start if (this.config.startFromBeginning) { this.currentDistanceTraveled = 0; } } this.isRunning = true; this.callbacks.onSimulationStart?.(); // Start interval this.intervalId = setInterval(() => { this.tick(); }, this.config.updateInterval); // Emit initial position this.tick(); } /** * Pause the simulation */ public pause(): void { if (!this.isRunning || this.isPaused) return; this.isPaused = true; if (this.intervalId) { clearInterval(this.intervalId); this.intervalId = null; } this.callbacks.onSimulationPause?.(); } /** * Stop the simulation */ public stop(): void { this.isRunning = false; this.isPaused = false; if (this.intervalId) { clearInterval(this.intervalId); this.intervalId = null; } this.currentDistanceTraveled = 0; this.lastPosition = null; this.callbacks.onSimulationStop?.(); } /** * Check if simulation is running */ public isSimulationRunning(): boolean { return this.isRunning && !this.isPaused; } /** * Check if simulation is paused */ public isSimulationPaused(): boolean { return this.isPaused; } /** * Jump to a specific point in the route (by percentage) */ public jumpToProgress(percentage: number): void { const clamped = Math.max(0, Math.min(100, percentage)); this.currentDistanceTraveled = (clamped / 100) * this.totalDistance; // Emit the new position immediately if (this.isRunning) { this.emitCurrentPosition(); } } // ─── Simulation Tick ──────────────────────────────────────────────────────── /** * Process one simulation tick */ private tick(): void { if (!this.isRunning || this.isPaused) return; // Calculate distance to travel this tick const speedMps = this.getSpeedMps(); const tickDuration = this.config.updateInterval / 1000; // seconds const distanceThisTick = speedMps * tickDuration; // Update distance traveled this.currentDistanceTraveled += distanceThisTick; // Check if we've reached the end if (this.currentDistanceTraveled >= this.totalDistance) { this.currentDistanceTraveled = this.totalDistance; this.emitCurrentPosition(); this.completeSimulation(); return; } // Emit current position this.emitCurrentPosition(); } /** * Emit the current interpolated position */ private emitCurrentPosition(): void { const position = this.calculatePosition(this.currentDistanceTraveled); if (position) { this.lastPosition = position; this.callbacks.onPositionUpdate(position); } } /** * Calculate position at a given distance along the route */ private calculatePosition(distanceAlongRoute: number): IGPSPosition | null { if (this.coordinates.length < 2) return null; // Find which segment we're on let accumulatedDistance = 0; for (let i = 0; i < this.segmentDistances.length; i++) { const segmentDistance = this.segmentDistances[i]; if (accumulatedDistance + segmentDistance >= distanceAlongRoute) { // We're on this segment const distanceIntoSegment = distanceAlongRoute - accumulatedDistance; const fraction = segmentDistance > 0 ? distanceIntoSegment / segmentDistance : 0; const start = this.coordinates[i]; const end = this.coordinates[i + 1]; // Interpolate position const [lng, lat] = interpolateCoord(start, end, fraction); // Add jitter const [jitteredLng, jitteredLat] = addJitter(lng, lat, this.config.jitterMeters); // Calculate heading const heading = calculateBearing(start[1], start[0], end[1], end[0]); // Calculate speed (current configured speed) const speed = this.getSpeedMps(); return { lng: jitteredLng, lat: jitteredLat, heading, speed, accuracy: this.config.jitterMeters, timestamp: new Date(), }; } accumulatedDistance += segmentDistance; } // If we're past the end, return the last coordinate const lastCoord = this.coordinates[this.coordinates.length - 1]; const prevCoord = this.coordinates[this.coordinates.length - 2]; const heading = calculateBearing(prevCoord[1], prevCoord[0], lastCoord[1], lastCoord[0]); return { lng: lastCoord[0], lat: lastCoord[1], heading, speed: 0, accuracy: this.config.jitterMeters, timestamp: new Date(), }; } /** * Complete the simulation */ private completeSimulation(): void { this.isRunning = false; this.isPaused = false; if (this.intervalId) { clearInterval(this.intervalId); this.intervalId = null; } this.callbacks.onSimulationComplete?.(); } // ─── Cleanup ──────────────────────────────────────────────────────────────── /** * Clean up resources */ public cleanup(): void { this.stop(); this.route = null; this.coordinates = []; this.segmentDistances = []; this.totalDistance = 0; } } // ─── Speed Helper Functions ─────────────────────────────────────────────────── /** * Get display name for speed preset */ export function getSpeedDisplayName(speed: TSimulationSpeed): string { const names: Record = { walking: 'Walking', cycling: 'Cycling', city: 'City Driving', highway: 'Highway', custom: 'Custom', }; return names[speed]; } /** * Get speed in km/h for a preset */ export function getSpeedKmh(speed: TSimulationSpeed): number { if (speed === 'custom') return 0; return SPEED_PRESETS[speed] * 3.6; } /** * Get all speed presets with display info */ export function getSpeedPresets(): Array<{ id: TSimulationSpeed; name: string; kmh: number }> { return [ { id: 'walking', name: 'Walking', kmh: 5 }, { id: 'cycling', name: 'Cycling', kmh: 20 }, { id: 'city', name: 'City Driving', kmh: 50 }, { id: 'highway', name: 'Highway', kmh: 100 }, ]; }