495 lines
14 KiB
TypeScript
495 lines
14 KiB
TypeScript
|
|
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<typeof setInterval> | null = null;
|
||
|
|
|
||
|
|
// Callbacks
|
||
|
|
private callbacks: IMockGPSCallbacks;
|
||
|
|
|
||
|
|
constructor(callbacks: IMockGPSCallbacks, config?: Partial<IMockGPSConfig>) {
|
||
|
|
this.callbacks = callbacks;
|
||
|
|
if (config) {
|
||
|
|
this.configure(config);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── Configuration ──────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Configure simulator settings
|
||
|
|
*/
|
||
|
|
public configure(config: Partial<IMockGPSConfig>): 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<TSimulationSpeed, string> = {
|
||
|
|
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 },
|
||
|
|
];
|
||
|
|
}
|