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');
}
// 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,
})),
);

View File

@@ -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<MatchedRoute | null> {
if (points.length < 2) {
this.logger.debug('Not enough points for route matching (minimum 2 required)');
async matchRoute(points: GpsPoint[]): Promise<MatchedRoute | null> {
// 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<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 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<T>(array: T[], size: number): T[][] {
if (array.length <= size) {
return [array];
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;
}
}
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<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[][] {
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}