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:
@@ -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,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
|
||||||
`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)
|
let result: MatchedRoute | null = null;
|
||||||
if (chunks.length > 1 && i < chunks.length - 1) {
|
|
||||||
this.logger.debug('Rate limiting: waiting 1.1 seconds before next request');
|
if (segment.type === 'dense' && segment.points.length >= 2) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
// 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) {
|
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 chunkArray<T>(array: T[], size: number): T[][] {
|
private async matchSegment(points: GpsPoint[]): Promise<MatchedRoute | null> {
|
||||||
if (array.length <= size) {
|
if (points.length < 2) return null;
|
||||||
return [array];
|
|
||||||
|
// 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[][] = [];
|
if (allCoords.length === 0) return null;
|
||||||
const overlap = 5;
|
|
||||||
|
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) {
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user