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

495 lines
14 KiB
TypeScript
Raw Normal View History

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 },
];
}