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:
@@ -132,6 +132,7 @@ export class GpsController {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a driver's location history (for route trail display)
|
* Get a driver's location history (for route trail display)
|
||||||
|
* Query param 'matched=true' returns OSRM road-snapped route
|
||||||
*/
|
*/
|
||||||
@Get('locations/:driverId/history')
|
@Get('locations/:driverId/history')
|
||||||
@Roles(Role.ADMINISTRATOR)
|
@Roles(Role.ADMINISTRATOR)
|
||||||
@@ -139,9 +140,17 @@ export class GpsController {
|
|||||||
@Param('driverId') driverId: string,
|
@Param('driverId') driverId: string,
|
||||||
@Query('from') fromStr?: string,
|
@Query('from') fromStr?: string,
|
||||||
@Query('to') toStr?: string,
|
@Query('to') toStr?: string,
|
||||||
|
@Query('matched') matched?: string,
|
||||||
) {
|
) {
|
||||||
const from = fromStr ? new Date(fromStr) : undefined;
|
const from = fromStr ? new Date(fromStr) : undefined;
|
||||||
const to = toStr ? new Date(toStr) : 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);
|
return this.gpsService.getDriverLocationHistory(driverId, from, to);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ScheduleModule } from '@nestjs/schedule';
|
|||||||
import { GpsController } from './gps.controller';
|
import { GpsController } from './gps.controller';
|
||||||
import { GpsService } from './gps.service';
|
import { GpsService } from './gps.service';
|
||||||
import { TraccarClientService } from './traccar-client.service';
|
import { TraccarClientService } from './traccar-client.service';
|
||||||
|
import { OsrmService } from './osrm.service';
|
||||||
import { PrismaModule } from '../prisma/prisma.module';
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
import { SignalModule } from '../signal/signal.module';
|
import { SignalModule } from '../signal/signal.module';
|
||||||
|
|
||||||
@@ -13,7 +14,7 @@ import { SignalModule } from '../signal/signal.module';
|
|||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
],
|
],
|
||||||
controllers: [GpsController],
|
controllers: [GpsController],
|
||||||
providers: [GpsService, TraccarClientService],
|
providers: [GpsService, TraccarClientService, OsrmService],
|
||||||
exports: [GpsService, TraccarClientService],
|
exports: [GpsService, TraccarClientService],
|
||||||
})
|
})
|
||||||
export class GpsModule {}
|
export class GpsModule {}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Cron, CronExpression } from '@nestjs/schedule';
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { SignalService } from '../signal/signal.service';
|
import { SignalService } from '../signal/signal.service';
|
||||||
import { TraccarClientService } from './traccar-client.service';
|
import { TraccarClientService } from './traccar-client.service';
|
||||||
|
import { OsrmService } from './osrm.service';
|
||||||
import {
|
import {
|
||||||
DriverLocationDto,
|
DriverLocationDto,
|
||||||
DriverStatsDto,
|
DriverStatsDto,
|
||||||
@@ -29,6 +30,7 @@ export class GpsService implements OnModuleInit {
|
|||||||
private traccarClient: TraccarClientService,
|
private traccarClient: TraccarClientService,
|
||||||
private signalService: SignalService,
|
private signalService: SignalService,
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
|
private osrmService: OsrmService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onModuleInit() {
|
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
|
* Calculate distance between two GPS coordinates using Haversine formula
|
||||||
* Returns distance in miles
|
* 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)
|
* 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(
|
async getDriverStats(
|
||||||
driverId: string,
|
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()}`,
|
`[Stats] Calculating stats for driver ${device.driver.name} from ${from.toISOString()} to ${to.toISOString()}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Calculate total distance from stored position history
|
// Try to get OSRM-matched distance first, fall back to Haversine
|
||||||
const totalMiles = await this.calculateDistanceFromHistory(device.id, from, to);
|
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
|
// Get all positions for speed/time analysis
|
||||||
const allPositions = await this.prisma.gpsLocationHistory.findMany({
|
const allPositions = await this.prisma.gpsLocationHistory.findMany({
|
||||||
@@ -738,7 +853,7 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
this.logger.log(
|
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 {
|
return {
|
||||||
@@ -755,6 +870,7 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
averageSpeedMph: Math.round(averageSpeedMph * 10) / 10,
|
averageSpeedMph: Math.round(averageSpeedMph * 10) / 10,
|
||||||
totalTrips,
|
totalTrips,
|
||||||
totalDrivingMinutes: Math.round(totalDrivingMinutes),
|
totalDrivingMinutes: Math.round(totalDrivingMinutes),
|
||||||
|
distanceMethod, // 'osrm' or 'haversine'
|
||||||
},
|
},
|
||||||
recentLocations: recentLocations.map((loc) => ({
|
recentLocations: recentLocations.map((loc) => ({
|
||||||
latitude: loc.latitude,
|
latitude: loc.latitude,
|
||||||
|
|||||||
157
backend/src/gps/osrm.service.ts
Normal file
157
backend/src/gps/osrm.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
EnrollmentResponse,
|
EnrollmentResponse,
|
||||||
MyGpsStatus,
|
MyGpsStatus,
|
||||||
DeviceQrInfo,
|
DeviceQrInfo,
|
||||||
|
LocationHistoryResponse,
|
||||||
} from '@/types/gps';
|
} from '@/types/gps';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { queryKeys } from '@/lib/query-keys';
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
@@ -125,19 +126,22 @@ export function useDriverLocation(driverId: string) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get driver location history (for route trail display)
|
* 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) {
|
export function useDriverLocationHistory(driverId: string | null, from?: string, to?: string) {
|
||||||
return useQuery<Array<{ latitude: number; longitude: number; speed: number; timestamp: Date }>>({
|
return useQuery({
|
||||||
queryKey: ['gps', 'locations', driverId, 'history', from, to],
|
queryKey: ['gps', 'locations', driverId, 'history', from, to],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (from) params.append('from', from);
|
if (from) params.append('from', from);
|
||||||
if (to) params.append('to', to);
|
if (to) params.append('to', to);
|
||||||
|
params.append('matched', 'true'); // Request road-snapped route
|
||||||
const { data } = await api.get(`/gps/locations/${driverId}/history?${params}`);
|
const { data } = await api.get(`/gps/locations/${driverId}/history?${params}`);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
enabled: !!driverId,
|
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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import type { Driver } from '@/types';
|
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 toast from 'react-hot-toast';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
|
||||||
@@ -151,6 +151,37 @@ export function GpsTracking() {
|
|||||||
const driverIdForTrail = selectedDriverForTrail || (showTrails ? activeDriverIds[0] : null);
|
const driverIdForTrail = selectedDriverForTrail || (showTrails ? activeDriverIds[0] : null);
|
||||||
const { data: locationHistory } = useDriverLocationHistory(driverIdForTrail, undefined, undefined);
|
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
|
// Mutations
|
||||||
const updateSettings = useUpdateGpsSettings();
|
const updateSettings = useUpdateGpsSettings();
|
||||||
const traccarSetup = useTraccarSetup();
|
const traccarSetup = useTraccarSetup();
|
||||||
@@ -383,13 +414,14 @@ export function GpsTracking() {
|
|||||||
{locations && <MapFitBounds locations={locations} />}
|
{locations && <MapFitBounds locations={locations} />}
|
||||||
|
|
||||||
{/* Route trail polyline */}
|
{/* Route trail polyline */}
|
||||||
{showTrails && locationHistory && locationHistory.length > 1 && (
|
{showTrails && routePolyline && (
|
||||||
<Polyline
|
<Polyline
|
||||||
positions={locationHistory.map(loc => [loc.latitude, loc.longitude])}
|
positions={routePolyline.positions}
|
||||||
pathOptions={{
|
pathOptions={{
|
||||||
color: '#3b82f6',
|
color: routePolyline.isMatched ? '#3B82F6' : '#94a3b8',
|
||||||
weight: 3,
|
weight: routePolyline.isMatched ? 4 : 2,
|
||||||
opacity: 0.6,
|
opacity: routePolyline.isMatched ? 0.7 : 0.4,
|
||||||
|
dashArray: routePolyline.isMatched ? undefined : '5, 10',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -438,7 +470,7 @@ export function GpsTracking() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Map Legend & Controls */}
|
{/* Map Legend & Controls */}
|
||||||
<div className="absolute bottom-4 left-4 bg-card/90 backdrop-blur-sm border border-border rounded-lg p-3 z-[1000]">
|
<div className="absolute bottom-4 left-4 bg-card/90 backdrop-blur-sm border border-border rounded-lg p-3 z-[1000] max-w-[220px]">
|
||||||
<h4 className="text-sm font-medium text-foreground mb-2">Map Controls</h4>
|
<h4 className="text-sm font-medium text-foreground mb-2">Map Controls</h4>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -450,9 +482,25 @@ export function GpsTracking() {
|
|||||||
<span className="text-xs text-muted-foreground">Inactive</span>
|
<span className="text-xs text-muted-foreground">Inactive</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-12 h-0.5 bg-blue-500 opacity-60"></div>
|
<div className="w-12 h-1 bg-blue-500 opacity-70 rounded"></div>
|
||||||
<span className="text-xs text-muted-foreground">Route Trail</span>
|
<span className="text-xs text-muted-foreground">Road Route</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
{routePolyline && routePolyline.distance && (
|
||||||
|
<div className="pt-1 border-t border-border">
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Distance: <span className="font-medium text-foreground">{(routePolyline.distance / 1609.34).toFixed(1)} mi</span>
|
||||||
|
</div>
|
||||||
|
{routePolyline.confidence !== undefined && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Confidence: <span className="font-medium text-foreground">{(routePolyline.confidence * 100).toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<hr className="border-border" />
|
<hr className="border-border" />
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
|
|||||||
Reference in New Issue
Block a user