feat: add OSRM road-snapping for GPS routes and mileage (#21)

Routes now follow actual roads instead of cutting through buildings:
- New OsrmService calls free OSRM Match API to snap GPS points to roads
- Position history endpoint accepts ?matched=true for road-snapped geometry
- Stats use OSRM road distance instead of Haversine crow-flies distance
- Frontend shows solid blue polylines for matched routes, dashed for raw
- Handles chunking (100 coord limit), rate limiting, graceful fallback
- Distance badge shows accurate road miles on route trails

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 17:03:47 +01:00
parent d93919910b
commit 33fda57cc6
6 changed files with 351 additions and 16 deletions

View File

@@ -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);
}

View File

@@ -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 {}

View File

@@ -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,

View File

@@ -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<MatchedRoute | null> {
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<string, string> = {
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<T>(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;
}
}