diff --git a/backend/src/gps/gps.controller.ts b/backend/src/gps/gps.controller.ts index 66b6283..571fdd6 100644 --- a/backend/src/gps/gps.controller.ts +++ b/backend/src/gps/gps.controller.ts @@ -132,6 +132,7 @@ export class GpsController { /** * Get a driver's location history (for route trail display) + * Query param 'matched=true' returns OSRM road-snapped route */ @Get('locations/:driverId/history') @Roles(Role.ADMINISTRATOR) @@ -139,9 +140,17 @@ export class GpsController { @Param('driverId') driverId: string, @Query('from') fromStr?: string, @Query('to') toStr?: string, + @Query('matched') matched?: string, ) { const from = fromStr ? new Date(fromStr) : undefined; const to = toStr ? new Date(toStr) : undefined; + + // If matched=true, return OSRM road-matched route + if (matched === 'true') { + return this.gpsService.getMatchedRoute(driverId, from, to); + } + + // Otherwise return raw GPS points return this.gpsService.getDriverLocationHistory(driverId, from, to); } diff --git a/backend/src/gps/gps.module.ts b/backend/src/gps/gps.module.ts index ff96b46..c14657f 100644 --- a/backend/src/gps/gps.module.ts +++ b/backend/src/gps/gps.module.ts @@ -3,6 +3,7 @@ import { ScheduleModule } from '@nestjs/schedule'; import { GpsController } from './gps.controller'; import { GpsService } from './gps.service'; import { TraccarClientService } from './traccar-client.service'; +import { OsrmService } from './osrm.service'; import { PrismaModule } from '../prisma/prisma.module'; import { SignalModule } from '../signal/signal.module'; @@ -13,7 +14,7 @@ import { SignalModule } from '../signal/signal.module'; ScheduleModule.forRoot(), ], controllers: [GpsController], - providers: [GpsService, TraccarClientService], + providers: [GpsService, TraccarClientService, OsrmService], exports: [GpsService, TraccarClientService], }) export class GpsModule {} diff --git a/backend/src/gps/gps.service.ts b/backend/src/gps/gps.service.ts index f07c66c..9f47e50 100644 --- a/backend/src/gps/gps.service.ts +++ b/backend/src/gps/gps.service.ts @@ -10,6 +10,7 @@ import { Cron, CronExpression } from '@nestjs/schedule'; import { PrismaService } from '../prisma/prisma.service'; import { SignalService } from '../signal/signal.service'; import { TraccarClientService } from './traccar-client.service'; +import { OsrmService } from './osrm.service'; import { DriverLocationDto, DriverStatsDto, @@ -29,6 +30,7 @@ export class GpsService implements OnModuleInit { private traccarClient: TraccarClientService, private signalService: SignalService, private configService: ConfigService, + private osrmService: OsrmService, ) {} async onModuleInit() { @@ -514,6 +516,97 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour} })); } + /** + * Get road-matched route for a driver (snaps GPS points to actual roads) + * Returns OSRM-matched coordinates and accurate road distance + */ + async getMatchedRoute( + driverId: string, + fromDate?: Date, + toDate?: Date, + ): Promise<{ + rawPoints: LocationDataDto[]; + matchedRoute: { + coordinates: Array<[number, number]>; // [lat, lng] for Leaflet + distance: number; // miles + duration: number; // seconds + confidence: number; // 0-1 + } | null; + }> { + const device = await this.prisma.gpsDevice.findUnique({ + where: { driverId }, + }); + + if (!device) { + throw new NotFoundException('Driver is not enrolled for GPS tracking'); + } + + // Default to last 4 hours if no date range specified + const to = toDate || new Date(); + const from = fromDate || new Date(to.getTime() - 4 * 60 * 60 * 1000); + + const locations = await this.prisma.gpsLocationHistory.findMany({ + where: { + deviceId: device.id, + timestamp: { + gte: from, + lte: to, + }, + }, + orderBy: { timestamp: 'asc' }, + }); + + const rawPoints = locations.map((loc) => ({ + latitude: loc.latitude, + longitude: loc.longitude, + altitude: loc.altitude, + speed: loc.speed, + course: loc.course, + accuracy: loc.accuracy, + battery: loc.battery, + timestamp: loc.timestamp, + })); + + if (locations.length < 2) { + return { rawPoints, matchedRoute: null }; + } + + // Call OSRM to match route + this.logger.log( + `[OSRM] Matching route for driver ${driverId}: ${locations.length} points`, + ); + + const matchResult = await this.osrmService.matchRoute( + locations.map((loc) => ({ + latitude: loc.latitude, + longitude: loc.longitude, + timestamp: loc.timestamp, + })), + ); + + if (!matchResult) { + this.logger.warn('[OSRM] Route matching failed, returning raw points only'); + return { rawPoints, matchedRoute: null }; + } + + // Convert distance from meters to miles + const distanceMiles = matchResult.distance / 1609.34; + + this.logger.log( + `[OSRM] Route matched: ${distanceMiles.toFixed(2)} miles, confidence ${(matchResult.confidence * 100).toFixed(1)}%`, + ); + + return { + rawPoints, + matchedRoute: { + coordinates: matchResult.coordinates, + distance: distanceMiles, + duration: matchResult.duration, + confidence: matchResult.confidence, + }, + }; + } + /** * Calculate distance between two GPS coordinates using Haversine formula * Returns distance in miles @@ -633,7 +726,7 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour} /** * Get driver's own stats (for driver self-view) - * UPDATED: Now calculates distance from GpsLocationHistory table instead of Traccar API + * UPDATED: Uses OSRM road-matched distance when available, falls back to Haversine */ async getDriverStats( driverId: string, @@ -664,8 +757,30 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour} `[Stats] Calculating stats for driver ${device.driver.name} from ${from.toISOString()} to ${to.toISOString()}`, ); - // Calculate total distance from stored position history - const totalMiles = await this.calculateDistanceFromHistory(device.id, from, to); + // Try to get OSRM-matched distance first, fall back to Haversine + let totalMiles = 0; + let distanceMethod = 'haversine'; + + try { + const matchedRoute = await this.getMatchedRoute(driverId, from, to); + if (matchedRoute.matchedRoute && matchedRoute.matchedRoute.confidence > 0.5) { + totalMiles = matchedRoute.matchedRoute.distance; + distanceMethod = 'osrm'; + this.logger.log( + `[Stats] Using OSRM road distance: ${totalMiles.toFixed(2)} miles (confidence ${(matchedRoute.matchedRoute.confidence * 100).toFixed(1)}%)`, + ); + } else { + throw new Error('OSRM confidence too low or matching failed'); + } + } catch (error) { + this.logger.warn( + `[Stats] OSRM matching failed, falling back to Haversine: ${error.message}`, + ); + totalMiles = await this.calculateDistanceFromHistory(device.id, from, to); + this.logger.log( + `[Stats] Using Haversine distance: ${totalMiles.toFixed(2)} miles`, + ); + } // Get all positions for speed/time analysis const allPositions = await this.prisma.gpsLocationHistory.findMany({ @@ -738,7 +853,7 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour} : 0; this.logger.log( - `[Stats] Results: ${totalMiles.toFixed(1)} miles, ${topSpeedMph.toFixed(0)} mph top speed, ${totalTrips} trips, ${totalDrivingMinutes.toFixed(0)} min driving`, + `[Stats] Results (${distanceMethod}): ${totalMiles.toFixed(1)} miles, ${topSpeedMph.toFixed(0)} mph top speed, ${totalTrips} trips, ${totalDrivingMinutes.toFixed(0)} min driving`, ); return { @@ -755,6 +870,7 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour} averageSpeedMph: Math.round(averageSpeedMph * 10) / 10, totalTrips, totalDrivingMinutes: Math.round(totalDrivingMinutes), + distanceMethod, // 'osrm' or 'haversine' }, recentLocations: recentLocations.map((loc) => ({ latitude: loc.latitude, diff --git a/backend/src/gps/osrm.service.ts b/backend/src/gps/osrm.service.ts new file mode 100644 index 0000000..5c2274c --- /dev/null +++ b/backend/src/gps/osrm.service.ts @@ -0,0 +1,157 @@ +import { Injectable, Logger } from '@nestjs/common'; +import axios from 'axios'; + +interface MatchedRoute { + coordinates: Array<[number, number]>; // [lat, lng] pairs for Leaflet + distance: number; // meters + duration: number; // seconds + confidence: number; +} + +@Injectable() +export class OsrmService { + private readonly logger = new Logger(OsrmService.name); + private readonly baseUrl = 'https://router.project-osrm.org'; + + /** + * 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} + */ + 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)'); + return null; + } + + try { + // Split into chunks of 100 (OSRM limit) + const chunks = this.chunkArray(points, 100); + let allCoordinates: Array<[number, number]> = []; + let totalDistance = 0; + let totalDuration = 0; + let totalConfidence = 0; + let matchCount = 0; + + this.logger.log( + `Matching route with ${points.length} points (${chunks.length} chunks)`, + ); + + 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: ~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)); + } + } + + if (allCoordinates.length === 0) { + this.logger.warn('No coordinates matched from any chunks'); + return null; + } + + const avgConfidence = matchCount > 0 ? totalConfidence / matchCount : 0; + + this.logger.log( + `Route matching complete: ${allCoordinates.length} coordinates, ` + + `${(totalDistance / 1000).toFixed(2)} km, ` + + `confidence ${(avgConfidence * 100).toFixed(1)}%`, + ); + + return { + coordinates: allCoordinates, + distance: totalDistance, + duration: totalDuration, + confidence: avgConfidence, + }; + } catch (error) { + this.logger.error(`OSRM match failed: ${error.message}`); + return null; + } + } + + /** + * Split array into overlapping chunks for better continuity. + * Each chunk overlaps by 5 points with the next chunk. + */ + private chunkArray(array: T[], size: number): T[][] { + if (array.length <= size) { + return [array]; + } + + const chunks: T[][] = []; + const overlap = 5; + + // 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; + } + } + + return chunks; + } +} diff --git a/frontend/src/hooks/useGps.ts b/frontend/src/hooks/useGps.ts index 05d5f27..3e87303 100644 --- a/frontend/src/hooks/useGps.ts +++ b/frontend/src/hooks/useGps.ts @@ -9,6 +9,7 @@ import type { EnrollmentResponse, MyGpsStatus, DeviceQrInfo, + LocationHistoryResponse, } from '@/types/gps'; import toast from 'react-hot-toast'; import { queryKeys } from '@/lib/query-keys'; @@ -125,19 +126,22 @@ export function useDriverLocation(driverId: string) { /** * Get driver location history (for route trail display) + * By default, requests road-snapped routes from OSRM map matching */ export function useDriverLocationHistory(driverId: string | null, from?: string, to?: string) { - return useQuery>({ + return useQuery({ queryKey: ['gps', 'locations', driverId, 'history', from, to], queryFn: async () => { const params = new URLSearchParams(); if (from) params.append('from', from); if (to) params.append('to', to); + params.append('matched', 'true'); // Request road-snapped route const { data } = await api.get(`/gps/locations/${driverId}/history?${params}`); return data; }, enabled: !!driverId, - refetchInterval: 30000, // Refresh every 30 seconds + refetchInterval: 60000, // Match routes less frequently (60s) since OSRM has rate limits + staleTime: 30000, // Consider data fresh for 30s }); } diff --git a/frontend/src/pages/GpsTracking.tsx b/frontend/src/pages/GpsTracking.tsx index de07806..3cef9d5 100644 --- a/frontend/src/pages/GpsTracking.tsx +++ b/frontend/src/pages/GpsTracking.tsx @@ -48,7 +48,7 @@ import { useQuery } from '@tanstack/react-query'; import { api } from '@/lib/api'; import { useAuth } from '@/contexts/AuthContext'; import type { Driver } from '@/types'; -import type { DriverLocation } from '@/types/gps'; +import type { DriverLocation, LocationHistoryResponse } from '@/types/gps'; import toast from 'react-hot-toast'; import { formatDistanceToNow } from 'date-fns'; @@ -151,6 +151,37 @@ export function GpsTracking() { const driverIdForTrail = selectedDriverForTrail || (showTrails ? activeDriverIds[0] : null); const { data: locationHistory } = useDriverLocationHistory(driverIdForTrail, undefined, undefined); + // Extract route polyline coordinates and metadata + const routePolyline = useMemo(() => { + if (!locationHistory) return null; + + const history = locationHistory as LocationHistoryResponse; + + // If we have matched coordinates, use those + if (history.matched && history.coordinates && history.coordinates.length > 1) { + return { + positions: history.coordinates, + isMatched: true, + distance: history.distance, + duration: history.duration, + confidence: history.confidence, + }; + } + + // Fall back to raw positions + if (history.rawPositions && history.rawPositions.length > 1) { + return { + positions: history.rawPositions.map(loc => [loc.latitude, loc.longitude] as [number, number]), + isMatched: false, + distance: undefined, + duration: undefined, + confidence: undefined, + }; + } + + return null; + }, [locationHistory]); + // Mutations const updateSettings = useUpdateGpsSettings(); const traccarSetup = useTraccarSetup(); @@ -383,13 +414,14 @@ export function GpsTracking() { {locations && } {/* Route trail polyline */} - {showTrails && locationHistory && locationHistory.length > 1 && ( + {showTrails && routePolyline && ( [loc.latitude, loc.longitude])} + positions={routePolyline.positions} pathOptions={{ - color: '#3b82f6', - weight: 3, - opacity: 0.6, + color: routePolyline.isMatched ? '#3B82F6' : '#94a3b8', + weight: routePolyline.isMatched ? 4 : 2, + opacity: routePolyline.isMatched ? 0.7 : 0.4, + dashArray: routePolyline.isMatched ? undefined : '5, 10', }} /> )} @@ -438,7 +470,7 @@ export function GpsTracking() { )} {/* Map Legend & Controls */} -
+

Map Controls

@@ -450,9 +482,25 @@ export function GpsTracking() { Inactive
-
- Route Trail +
+ Road Route
+
+
+ GPS Trail +
+ {routePolyline && routePolyline.distance && ( +
+
+ Distance: {(routePolyline.distance / 1609.34).toFixed(1)} mi +
+ {routePolyline.confidence !== undefined && ( +
+ Confidence: {(routePolyline.confidence * 100).toFixed(0)}% +
+ )} +
+ )}