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 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 17:39:00 +01:00
parent 12b9361ae0
commit cb4a070ad9
4 changed files with 385 additions and 116 deletions

View File

@@ -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'); 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 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({ const locations = await this.prisma.gpsLocationHistory.findMany({
where: { 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'); 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 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({ const locations = await this.prisma.gpsLocationHistory.findMany({
where: { where: {
@@ -581,6 +581,7 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
latitude: loc.latitude, latitude: loc.latitude,
longitude: loc.longitude, longitude: loc.longitude,
timestamp: loc.timestamp, timestamp: loc.timestamp,
speed: loc.speed ?? undefined,
})), })),
); );

View File

@@ -8,112 +8,102 @@ interface MatchedRoute {
confidence: number; confidence: number;
} }
interface GpsPoint {
latitude: number;
longitude: number;
timestamp?: Date;
speed?: number;
}
@Injectable() @Injectable()
export class OsrmService { export class OsrmService {
private readonly logger = new Logger(OsrmService.name); private readonly logger = new Logger(OsrmService.name);
private readonly baseUrl = 'https://router.project-osrm.org'; 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. * Intelligently match/route 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} * 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( async matchRoute(points: GpsPoint[]): Promise<MatchedRoute | null> {
points: Array<{ latitude: number; longitude: number; timestamp?: Date }>, // Filter out stationary points (speed=0, same location) to reduce noise
): Promise<MatchedRoute | null> { const movingPoints = this.filterStationaryPoints(points);
if (points.length < 2) {
this.logger.debug('Not enough points for route matching (minimum 2 required)'); if (movingPoints.length < 2) {
this.logger.debug('Not enough moving points for route matching');
return null; return null;
} }
this.logger.log(
`Processing ${movingPoints.length} moving points (filtered from ${points.length} total)`,
);
try { try {
// Split into chunks of 100 (OSRM limit) // Split into segments based on time gaps
const chunks = this.chunkArray(points, 100); 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 allCoordinates: Array<[number, number]> = [];
let totalDistance = 0; let totalDistance = 0;
let totalDuration = 0; let totalDuration = 0;
let totalConfidence = 0; let confidenceSum = 0;
let matchCount = 0; let confidenceCount = 0;
this.logger.log( for (let i = 0; i < segments.length; i++) {
`Matching route with ${points.length} points (${chunks.length} chunks)`, const segment = segments[i];
);
for (let i = 0; i < chunks.length; i++) { // Rate limit between API calls
const chunk = chunks[i]; if (i > 0) {
await this.delay(1100);
// 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( let result: MatchedRoute | null = null;
`Chunk ${i + 1}/${chunks.length}: Matched ${response.data.matchings.length} segments`,
); if (segment.type === 'dense' && segment.points.length >= 2) {
} else { // Dense data: use map matching for accuracy
this.logger.warn( result = await this.matchSegment(segment.points);
`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 (segment.type === 'sparse' || (!result && segment.points.length >= 2)) {
if (chunks.length > 1 && i < chunks.length - 1) { // Sparse data or match failed: use routing between waypoints
this.logger.debug('Rate limiting: waiting 1.1 seconds before next request'); result = await this.routeSegment(segment.points);
await new Promise((resolve) => setTimeout(resolve, 1100)); }
if (result && result.coordinates.length > 0) {
allCoordinates.push(...result.coordinates);
totalDistance += result.distance;
totalDuration += result.duration;
confidenceSum += result.confidence;
confidenceCount++;
} }
} }
if (allCoordinates.length === 0) { if (allCoordinates.length === 0) {
this.logger.warn('No coordinates matched from any chunks'); this.logger.warn('No coordinates produced from any segments');
return null; return null;
} }
const avgConfidence = matchCount > 0 ? totalConfidence / matchCount : 0; const avgConfidence =
confidenceCount > 0 ? confidenceSum / confidenceCount : 0;
this.logger.log( this.logger.log(
`Route matching complete: ${allCoordinates.length} coordinates, ` + `Route complete: ${allCoordinates.length} coords, ` +
`${(totalDistance / 1000).toFixed(2)} km, ` + `${(totalDistance / 1609.34).toFixed(2)} miles, ` +
`confidence ${(avgConfidence * 100).toFixed(1)}%`, `confidence ${(avgConfidence * 100).toFixed(1)}%`,
); );
@@ -124,34 +114,302 @@ export class OsrmService {
confidence: avgConfidence, confidence: avgConfidence,
}; };
} catch (error) { } catch (error) {
this.logger.error(`OSRM match failed: ${error.message}`); this.logger.error(`Route processing failed: ${error.message}`);
return null; return null;
} }
} }
/** /**
* Split array into overlapping chunks for better continuity. * OSRM Match API - for dense GPS traces with points close together.
* Each chunk overlaps by 5 points with the next chunk. * Snaps GPS points to the most likely road path.
*/
private async matchSegment(points: GpsPoint[]): Promise<MatchedRoute | null> {
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<string, string> = {
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;
}
}
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<MatchedRoute | null> {
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 <SPARSE_GAP_THRESHOLD apart.
* Sparse segments bridge gaps between dense segments.
*/
private splitByTimeGaps(
points: GpsPoint[],
): Array<{ type: 'dense' | 'sparse'; points: GpsPoint[] }> {
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<T>(array: T[], size: number): T[][] { private chunkArray<T>(array: T[], size: number): T[][] {
if (array.length <= size) { if (array.length <= size) return [array];
return [array];
}
const chunks: T[][] = []; const chunks: T[][] = [];
const overlap = 5; 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) { for (let i = 0; i < array.length; i += size - overlap) {
const chunk = array.slice(i, Math.min(i + size, array.length)); const chunk = array.slice(i, Math.min(i + size, array.length));
chunks.push(chunk); if (chunk.length >= 2) chunks.push(chunk);
if (i + size >= array.length) break;
// Stop if we've reached the end
if (i + size >= array.length) {
break;
}
} }
return chunks; return chunks;
} }
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
} }

View File

@@ -157,21 +157,21 @@ export function GpsTracking() {
const history = locationHistory as LocationHistoryResponse; const history = locationHistory as LocationHistoryResponse;
// If we have matched coordinates, use those // If we have OSRM matched route, use road-snapped coordinates
if (history.matched && history.coordinates && history.coordinates.length > 1) { if (history.matchedRoute && history.matchedRoute.coordinates && history.matchedRoute.coordinates.length > 1) {
return { return {
positions: history.coordinates, positions: history.matchedRoute.coordinates,
isMatched: true, isMatched: true,
distance: history.distance, distance: history.matchedRoute.distance, // already in miles from backend
duration: history.duration, duration: history.matchedRoute.duration,
confidence: history.confidence, confidence: history.matchedRoute.confidence,
}; };
} }
// Fall back to raw positions // Fall back to raw GPS points
if (history.rawPositions && history.rawPositions.length > 1) { if (history.rawPoints && history.rawPoints.length > 1) {
return { 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, isMatched: false,
distance: undefined, distance: undefined,
duration: undefined, duration: undefined,
@@ -433,7 +433,7 @@ export function GpsTracking() {
position={[driver.location!.latitude, driver.location!.longitude]} position={[driver.location!.latitude, driver.location!.longitude]}
icon={createDriverIcon(true)} icon={createDriverIcon(true)}
> >
<Popup> <Popup autoPan={false}>
<div className="min-w-[180px]"> <div className="min-w-[180px]">
<h3 className="font-semibold text-base">{driver.driverName}</h3> <h3 className="font-semibold text-base">{driver.driverName}</h3>
{driver.driverPhone && ( {driver.driverPhone && (
@@ -489,10 +489,10 @@ export function GpsTracking() {
<div className="w-12 h-0.5 bg-gray-400 opacity-40" style={{ borderTop: '2px dashed #94a3b8' }}></div> <div className="w-12 h-0.5 bg-gray-400 opacity-40" style={{ borderTop: '2px dashed #94a3b8' }}></div>
<span className="text-xs text-muted-foreground">GPS Trail</span> <span className="text-xs text-muted-foreground">GPS Trail</span>
</div> </div>
{routePolyline && routePolyline.distance && ( {routePolyline && routePolyline.distance != null && (
<div className="pt-1 border-t border-border"> <div className="pt-1 border-t border-border">
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
Distance: <span className="font-medium text-foreground">{(routePolyline.distance / 1609.34).toFixed(1)} mi</span> Distance: <span className="font-medium text-foreground">{routePolyline.distance.toFixed(1)} mi</span>
</div> </div>
{routePolyline.confidence !== undefined && ( {routePolyline.confidence !== undefined && (
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">

View File

@@ -108,10 +108,20 @@ export interface MyGpsStatus {
} }
export interface LocationHistoryResponse { export interface LocationHistoryResponse {
matched: boolean; rawPoints: Array<{
coordinates?: [number, number][]; // road-snapped [lat, lng] pairs latitude: number;
distance?: number; // road distance in meters longitude: number;
duration?: number; // duration in seconds altitude?: number | null;
confidence?: number; // 0-1 confidence score speed?: number | null;
rawPositions?: Array<{ latitude: number; longitude: number; speed: number; timestamp: Date }>; 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;
} }