From cb4a070ad9d33784aeb7df81cb2a8b9fb6aa6816 Mon Sep 17 00:00:00 2001 From: kyle Date: Sun, 8 Feb 2026 17:39:00 +0100 Subject: [PATCH] fix: OSRM sparse data handling, frontend type mismatch, map jumping - Rewrite OsrmService with smart dense/sparse segmentation: dense GPS traces use Match API, sparse gaps use Route API (turn-by-turn directions between waypoints) - Filter stationary points before OSRM processing - Fix critical frontend bug: LocationHistoryResponse type didn't match backend response shape (matchedRoute vs matched), so OSRM routes were never actually displaying - Fix double distance conversion (backend sends miles, frontend was dividing by 1609.34 again) - Fix map jumping: disable popup autoPan on marker data refresh - Extend default history window from 4h to 12h Co-Authored-By: Claude Opus 4.6 --- backend/src/gps/gps.service.ts | 9 +- backend/src/gps/osrm.service.ts | 446 +++++++++++++++++++++++------ frontend/src/pages/GpsTracking.tsx | 24 +- frontend/src/types/gps.ts | 22 +- 4 files changed, 385 insertions(+), 116 deletions(-) diff --git a/backend/src/gps/gps.service.ts b/backend/src/gps/gps.service.ts index 9f47e50..60e065a 100644 --- a/backend/src/gps/gps.service.ts +++ b/backend/src/gps/gps.service.ts @@ -489,9 +489,9 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour} throw new NotFoundException('Driver is not enrolled for GPS tracking'); } - // Default to last 4 hours if no date range specified + // Default to last 12 hours if no date range specified const to = toDate || new Date(); - const from = fromDate || new Date(to.getTime() - 4 * 60 * 60 * 1000); + const from = fromDate || new Date(to.getTime() - 12 * 60 * 60 * 1000); const locations = await this.prisma.gpsLocationHistory.findMany({ where: { @@ -541,9 +541,9 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour} throw new NotFoundException('Driver is not enrolled for GPS tracking'); } - // Default to last 4 hours if no date range specified + // Default to last 12 hours if no date range specified const to = toDate || new Date(); - const from = fromDate || new Date(to.getTime() - 4 * 60 * 60 * 1000); + const from = fromDate || new Date(to.getTime() - 12 * 60 * 60 * 1000); const locations = await this.prisma.gpsLocationHistory.findMany({ where: { @@ -581,6 +581,7 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour} latitude: loc.latitude, longitude: loc.longitude, timestamp: loc.timestamp, + speed: loc.speed ?? undefined, })), ); diff --git a/backend/src/gps/osrm.service.ts b/backend/src/gps/osrm.service.ts index 5c2274c..409773e 100644 --- a/backend/src/gps/osrm.service.ts +++ b/backend/src/gps/osrm.service.ts @@ -8,112 +8,102 @@ interface MatchedRoute { confidence: number; } +interface GpsPoint { + latitude: number; + longitude: number; + timestamp?: Date; + speed?: number; +} + @Injectable() export class OsrmService { private readonly logger = new Logger(OsrmService.name); private readonly baseUrl = 'https://router.project-osrm.org'; + // Max gap (seconds) between points before we switch from match → route + private readonly SPARSE_GAP_THRESHOLD = 120; // 2 minutes + /** - * Match GPS coordinates to actual road network. - * Splits into chunks of 100 (OSRM limit), returns snapped geometry + road distance. - * Coordinates input: array of {latitude, longitude, timestamp} + * Intelligently match/route GPS coordinates to actual road network. + * + * Strategy: + * 1. Split points into "dense" segments (points <2min apart) and "sparse" gaps + * 2. Dense segments: Use OSRM Match API (map matching - snaps to roads) + * 3. Sparse gaps: Use OSRM Route API (turn-by-turn directions between points) + * 4. Stitch everything together in order */ - async matchRoute( - points: Array<{ latitude: number; longitude: number; timestamp?: Date }>, - ): Promise { - if (points.length < 2) { - this.logger.debug('Not enough points for route matching (minimum 2 required)'); + async matchRoute(points: GpsPoint[]): Promise { + // Filter out stationary points (speed=0, same location) to reduce noise + const movingPoints = this.filterStationaryPoints(points); + + if (movingPoints.length < 2) { + this.logger.debug('Not enough moving points for route matching'); return null; } + this.logger.log( + `Processing ${movingPoints.length} moving points (filtered from ${points.length} total)`, + ); + try { - // Split into chunks of 100 (OSRM limit) - const chunks = this.chunkArray(points, 100); + // Split into segments based on time gaps + const segments = this.splitByTimeGaps(movingPoints); + this.logger.log( + `Split into ${segments.length} segments: ` + + segments + .map( + (s) => + `${s.type}(${s.points.length}pts)`, + ) + .join(', '), + ); + let allCoordinates: Array<[number, number]> = []; let totalDistance = 0; let totalDuration = 0; - let totalConfidence = 0; - let matchCount = 0; + let confidenceSum = 0; + let confidenceCount = 0; - this.logger.log( - `Matching route with ${points.length} points (${chunks.length} chunks)`, - ); + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; - for (let i = 0; i < chunks.length; i++) { - const chunk = chunks[i]; - - // OSRM uses lon,lat format (opposite of Leaflet's lat,lng) - const coordString = chunk - .map((p) => `${p.longitude},${p.latitude}`) - .join(';'); - - // Build radiuses (GPS accuracy ~10-25m, allow some flex) - const radiuses = chunk.map(() => 25).join(';'); - - // Build timestamps if available - const timestamps = chunk[0].timestamp - ? chunk - .map((p) => Math.floor((p.timestamp?.getTime() || 0) / 1000)) - .join(';') - : undefined; - - const params: Record = { - overview: 'full', - geometries: 'geojson', - radiuses, - }; - if (timestamps) params.timestamps = timestamps; - - const url = `${this.baseUrl}/match/v1/driving/${coordString}`; - - try { - const response = await axios.get(url, { params, timeout: 10000 }); - - if (response.data.code === 'Ok' && response.data.matchings?.length > 0) { - for (const matching of response.data.matchings) { - // GeoJSON coordinates are [lon, lat] - convert to [lat, lng] for Leaflet - const coords = matching.geometry.coordinates.map( - (c: [number, number]) => [c[1], c[0]] as [number, number], - ); - allCoordinates.push(...coords); - totalDistance += matching.distance || 0; - totalDuration += matching.duration || 0; - totalConfidence += matching.confidence || 0; - matchCount++; - } - - this.logger.debug( - `Chunk ${i + 1}/${chunks.length}: Matched ${response.data.matchings.length} segments`, - ); - } else { - this.logger.warn( - `OSRM match failed for chunk ${i + 1}: ${response.data.code} - ${response.data.message || 'Unknown error'}`, - ); - } - } catch (error) { - this.logger.error( - `OSRM API error for chunk ${i + 1}: ${error.message}`, - ); - // Continue with next chunk even if this one fails + // Rate limit between API calls + if (i > 0) { + await this.delay(1100); } - // Rate limit: ~1 req/sec for public OSRM (be conservative) - if (chunks.length > 1 && i < chunks.length - 1) { - this.logger.debug('Rate limiting: waiting 1.1 seconds before next request'); - await new Promise((resolve) => setTimeout(resolve, 1100)); + let result: MatchedRoute | null = null; + + if (segment.type === 'dense' && segment.points.length >= 2) { + // Dense data: use map matching for accuracy + result = await this.matchSegment(segment.points); + } + + if (segment.type === 'sparse' || (!result && segment.points.length >= 2)) { + // Sparse data or match failed: use routing between waypoints + result = await this.routeSegment(segment.points); + } + + if (result && result.coordinates.length > 0) { + allCoordinates.push(...result.coordinates); + totalDistance += result.distance; + totalDuration += result.duration; + confidenceSum += result.confidence; + confidenceCount++; } } if (allCoordinates.length === 0) { - this.logger.warn('No coordinates matched from any chunks'); + this.logger.warn('No coordinates produced from any segments'); return null; } - const avgConfidence = matchCount > 0 ? totalConfidence / matchCount : 0; + const avgConfidence = + confidenceCount > 0 ? confidenceSum / confidenceCount : 0; this.logger.log( - `Route matching complete: ${allCoordinates.length} coordinates, ` + - `${(totalDistance / 1000).toFixed(2)} km, ` + + `Route complete: ${allCoordinates.length} coords, ` + + `${(totalDistance / 1609.34).toFixed(2)} miles, ` + `confidence ${(avgConfidence * 100).toFixed(1)}%`, ); @@ -124,34 +114,302 @@ export class OsrmService { confidence: avgConfidence, }; } catch (error) { - this.logger.error(`OSRM match failed: ${error.message}`); + this.logger.error(`Route processing failed: ${error.message}`); return null; } } /** - * Split array into overlapping chunks for better continuity. - * Each chunk overlaps by 5 points with the next chunk. + * OSRM Match API - for dense GPS traces with points close together. + * Snaps GPS points to the most likely road path. */ - private chunkArray(array: T[], size: number): T[][] { - if (array.length <= size) { - return [array]; + private async matchSegment(points: GpsPoint[]): Promise { + if (points.length < 2) return null; + + // Chunk to 100 points max (OSRM limit) + const chunks = this.chunkArray(points, 100); + let allCoords: Array<[number, number]> = []; + let totalDist = 0; + let totalDur = 0; + let confSum = 0; + let confCount = 0; + + for (let ci = 0; ci < chunks.length; ci++) { + const chunk = chunks[ci]; + if (ci > 0) await this.delay(1100); + + const coordString = chunk + .map((p) => `${p.longitude},${p.latitude}`) + .join(';'); + + // Use larger radius for sparse data + gaps=split to handle discontinuities + const radiuses = chunk.map(() => 50).join(';'); + + const timestamps = chunk[0].timestamp + ? chunk + .map((p) => + Math.floor((p.timestamp?.getTime() || 0) / 1000), + ) + .join(';') + : undefined; + + const params: Record = { + overview: 'full', + geometries: 'geojson', + radiuses, + gaps: 'split', // Split into separate legs at large gaps + }; + if (timestamps) params.timestamps = timestamps; + + const url = `${this.baseUrl}/match/v1/driving/${coordString}`; + + try { + const response = await axios.get(url, { params, timeout: 15000 }); + + if ( + response.data.code === 'Ok' && + response.data.matchings?.length > 0 + ) { + for (const matching of response.data.matchings) { + const coords = matching.geometry.coordinates.map( + (c: [number, number]) => [c[1], c[0]] as [number, number], + ); + allCoords.push(...coords); + totalDist += matching.distance || 0; + totalDur += matching.duration || 0; + confSum += matching.confidence || 0; + confCount++; + } + + this.logger.debug( + `Match chunk ${ci + 1}/${chunks.length}: ` + + `${response.data.matchings.length} matchings, ` + + `${(totalDist / 1000).toFixed(2)} km`, + ); + } else { + this.logger.warn( + `Match failed for chunk ${ci + 1}: ${response.data.code}`, + ); + return null; // Signal caller to try routing instead + } + } catch (error) { + this.logger.warn(`Match API error chunk ${ci + 1}: ${error.message}`); + return null; + } } - const chunks: T[][] = []; - const overlap = 5; + if (allCoords.length === 0) return null; + + return { + coordinates: allCoords, + distance: totalDist, + duration: totalDur, + confidence: confCount > 0 ? confSum / confCount : 0, + }; + } + + /** + * OSRM Route API - for sparse GPS data with large gaps between points. + * Calculates actual driving route between waypoints (like Google Directions). + * This gives accurate road routes even with 5-10 minute gaps. + */ + private async routeSegment( + points: GpsPoint[], + ): Promise { + if (points.length < 2) return null; + + // OSRM route supports up to 100 waypoints + // For very sparse data, we can usually fit all points + const chunks = this.chunkArray(points, 100); + let allCoords: Array<[number, number]> = []; + let totalDist = 0; + let totalDur = 0; + + for (let ci = 0; ci < chunks.length; ci++) { + const chunk = chunks[ci]; + if (ci > 0) await this.delay(1100); + + const coordString = chunk + .map((p) => `${p.longitude},${p.latitude}`) + .join(';'); + + const url = `${this.baseUrl}/route/v1/driving/${coordString}`; + + try { + const response = await axios.get(url, { + params: { + overview: 'full', + geometries: 'geojson', + }, + timeout: 15000, + }); + + if ( + response.data.code === 'Ok' && + response.data.routes?.length > 0 + ) { + const route = response.data.routes[0]; + const coords = route.geometry.coordinates.map( + (c: [number, number]) => [c[1], c[0]] as [number, number], + ); + allCoords.push(...coords); + totalDist += route.distance || 0; + totalDur += route.duration || 0; + + this.logger.debug( + `Route chunk ${ci + 1}/${chunks.length}: ` + + `${coords.length} coords, ${(route.distance / 1000).toFixed(2)} km`, + ); + } else { + this.logger.warn( + `Route failed for chunk ${ci + 1}: ${response.data.code}`, + ); + } + } catch (error) { + this.logger.warn(`Route API error chunk ${ci + 1}: ${error.message}`); + } + } + + if (allCoords.length === 0) return null; + + // Route API gives exact road distance, so confidence is high + return { + coordinates: allCoords, + distance: totalDist, + duration: totalDur, + confidence: 0.85, // Route is reliable but may not be exact path taken + }; + } + + /** + * Filter out stationary points (GPS jitter while parked). + * Keeps only the first and last of a stationary cluster. + */ + private filterStationaryPoints(points: GpsPoint[]): GpsPoint[] { + if (points.length <= 2) return points; + + const filtered: GpsPoint[] = [points[0]]; + let lastMoving = points[0]; + + for (let i = 1; i < points.length; i++) { + const p = points[i]; + const dist = this.haversineMeters( + lastMoving.latitude, + lastMoving.longitude, + p.latitude, + p.longitude, + ); + + // Point has moved more than 30 meters from last moving point + if (dist > 30) { + filtered.push(p); + lastMoving = p; + } + } + + // Always include last point + const last = points[points.length - 1]; + if (filtered[filtered.length - 1] !== last) { + filtered.push(last); + } + + this.logger.debug( + `Filtered ${points.length - filtered.length} stationary points`, + ); + return filtered; + } + + /** + * Split points into dense and sparse segments based on time gaps. + * Dense segments have points { + if (points.length < 2) { + return [{ type: 'dense', points }]; + } + + const segments: Array<{ type: 'dense' | 'sparse'; points: GpsPoint[] }> = + []; + let currentDense: GpsPoint[] = [points[0]]; + + for (let i = 1; i < points.length; i++) { + const prev = points[i - 1]; + const curr = points[i]; + + const gapSeconds = + prev.timestamp && curr.timestamp + ? (curr.timestamp.getTime() - prev.timestamp.getTime()) / 1000 + : 0; + + if (gapSeconds > this.SPARSE_GAP_THRESHOLD) { + // Large gap detected - save current dense segment, add sparse bridge + if (currentDense.length >= 2) { + segments.push({ type: 'dense', points: [...currentDense] }); + } + + // Create a sparse "bridge" segment from last dense point to next + segments.push({ + type: 'sparse', + points: [prev, curr], + }); + + currentDense = [curr]; + } else { + currentDense.push(curr); + } + } + + // Save remaining dense segment + if (currentDense.length >= 2) { + segments.push({ type: 'dense', points: currentDense }); + } + + return segments; + } + + /** + * Haversine distance in meters (for filtering) + */ + private haversineMeters( + lat1: number, + lon1: number, + lat2: number, + lon2: number, + ): number { + const R = 6371000; // Earth radius in meters + const dLat = ((lat2 - lat1) * Math.PI) / 180; + const dLon = ((lon2 - lon1) * 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(dLon / 2) * + Math.sin(dLon / 2); + return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + } + + /** + * Split array into overlapping chunks. + */ + private chunkArray(array: T[], size: number): T[][] { + if (array.length <= size) return [array]; + + const chunks: T[][] = []; + const overlap = 3; - // Use overlapping chunks (last 5 points overlap with next chunk for continuity) for (let i = 0; i < array.length; i += size - overlap) { const chunk = array.slice(i, Math.min(i + size, array.length)); - chunks.push(chunk); - - // Stop if we've reached the end - if (i + size >= array.length) { - break; - } + if (chunk.length >= 2) chunks.push(chunk); + if (i + size >= array.length) break; } return chunks; } + + private delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } } diff --git a/frontend/src/pages/GpsTracking.tsx b/frontend/src/pages/GpsTracking.tsx index 3cef9d5..839cf14 100644 --- a/frontend/src/pages/GpsTracking.tsx +++ b/frontend/src/pages/GpsTracking.tsx @@ -157,21 +157,21 @@ export function GpsTracking() { const history = locationHistory as LocationHistoryResponse; - // If we have matched coordinates, use those - if (history.matched && history.coordinates && history.coordinates.length > 1) { + // If we have OSRM matched route, use road-snapped coordinates + if (history.matchedRoute && history.matchedRoute.coordinates && history.matchedRoute.coordinates.length > 1) { return { - positions: history.coordinates, + positions: history.matchedRoute.coordinates, isMatched: true, - distance: history.distance, - duration: history.duration, - confidence: history.confidence, + distance: history.matchedRoute.distance, // already in miles from backend + duration: history.matchedRoute.duration, + confidence: history.matchedRoute.confidence, }; } - // Fall back to raw positions - if (history.rawPositions && history.rawPositions.length > 1) { + // Fall back to raw GPS points + if (history.rawPoints && history.rawPoints.length > 1) { return { - positions: history.rawPositions.map(loc => [loc.latitude, loc.longitude] as [number, number]), + positions: history.rawPoints.map(loc => [loc.latitude, loc.longitude] as [number, number]), isMatched: false, distance: undefined, duration: undefined, @@ -433,7 +433,7 @@ export function GpsTracking() { position={[driver.location!.latitude, driver.location!.longitude]} icon={createDriverIcon(true)} > - +

{driver.driverName}

{driver.driverPhone && ( @@ -489,10 +489,10 @@ export function GpsTracking() {
GPS Trail
- {routePolyline && routePolyline.distance && ( + {routePolyline && routePolyline.distance != null && (
- Distance: {(routePolyline.distance / 1609.34).toFixed(1)} mi + Distance: {routePolyline.distance.toFixed(1)} mi
{routePolyline.confidence !== undefined && (
diff --git a/frontend/src/types/gps.ts b/frontend/src/types/gps.ts index 93df4dd..dc0568d 100644 --- a/frontend/src/types/gps.ts +++ b/frontend/src/types/gps.ts @@ -108,10 +108,20 @@ export interface MyGpsStatus { } export interface LocationHistoryResponse { - matched: boolean; - coordinates?: [number, number][]; // road-snapped [lat, lng] pairs - distance?: number; // road distance in meters - duration?: number; // duration in seconds - confidence?: number; // 0-1 confidence score - rawPositions?: Array<{ latitude: number; longitude: number; speed: number; timestamp: Date }>; + rawPoints: Array<{ + latitude: number; + longitude: number; + altitude?: number | null; + speed?: number | null; + course?: number | null; + accuracy?: number | null; + battery?: number | null; + timestamp: string; + }>; + matchedRoute: { + coordinates: [number, number][]; // road-snapped [lat, lng] pairs + distance: number; // road distance in miles + duration: number; // duration in seconds + confidence: number; // 0-1 confidence score + } | null; }