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:
@@ -9,6 +9,7 @@ import type {
|
||||
EnrollmentResponse,
|
||||
MyGpsStatus,
|
||||
DeviceQrInfo,
|
||||
LocationHistoryResponse,
|
||||
} from '@/types/gps';
|
||||
import toast from 'react-hot-toast';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
@@ -125,19 +126,22 @@ export function useDriverLocation(driverId: string) {
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
return useQuery<Array<{ latitude: number; longitude: number; speed: number; timestamp: Date }>>({
|
||||
return useQuery({
|
||||
queryKey: ['gps', 'locations', driverId, 'history', from, to],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (from) params.append('from', from);
|
||||
if (to) params.append('to', to);
|
||||
params.append('matched', 'true'); // Request road-snapped route
|
||||
const { data } = await api.get(`/gps/locations/${driverId}/history?${params}`);
|
||||
return data;
|
||||
},
|
||||
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 { useAuth } from '@/contexts/AuthContext';
|
||||
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 { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
@@ -151,6 +151,37 @@ export function GpsTracking() {
|
||||
const driverIdForTrail = selectedDriverForTrail || (showTrails ? activeDriverIds[0] : null);
|
||||
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
|
||||
const updateSettings = useUpdateGpsSettings();
|
||||
const traccarSetup = useTraccarSetup();
|
||||
@@ -383,13 +414,14 @@ export function GpsTracking() {
|
||||
{locations && <MapFitBounds locations={locations} />}
|
||||
|
||||
{/* Route trail polyline */}
|
||||
{showTrails && locationHistory && locationHistory.length > 1 && (
|
||||
{showTrails && routePolyline && (
|
||||
<Polyline
|
||||
positions={locationHistory.map(loc => [loc.latitude, loc.longitude])}
|
||||
positions={routePolyline.positions}
|
||||
pathOptions={{
|
||||
color: '#3b82f6',
|
||||
weight: 3,
|
||||
opacity: 0.6,
|
||||
color: routePolyline.isMatched ? '#3B82F6' : '#94a3b8',
|
||||
weight: routePolyline.isMatched ? 4 : 2,
|
||||
opacity: routePolyline.isMatched ? 0.7 : 0.4,
|
||||
dashArray: routePolyline.isMatched ? undefined : '5, 10',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -438,7 +470,7 @@ export function GpsTracking() {
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -450,9 +482,25 @@ export function GpsTracking() {
|
||||
<span className="text-xs text-muted-foreground">Inactive</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-12 h-0.5 bg-blue-500 opacity-60"></div>
|
||||
<span className="text-xs text-muted-foreground">Route Trail</span>
|
||||
<div className="w-12 h-1 bg-blue-500 opacity-70 rounded"></div>
|
||||
<span className="text-xs text-muted-foreground">Road Route</span>
|
||||
</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" />
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
|
||||
Reference in New Issue
Block a user