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');
|
||||
}
|
||||
|
||||
// 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,
|
||||
})),
|
||||
);
|
||||
|
||||
|
||||
@@ -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++;
|
||||
// Rate limit between API calls
|
||||
if (i > 0) {
|
||||
await this.delay(1100);
|
||||
}
|
||||
|
||||
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
|
||||
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);
|
||||
}
|
||||
|
||||
// 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 (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 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[][] {
|
||||
if (array.length <= size) {
|
||||
return [array];
|
||||
}
|
||||
if (array.length <= size) return [array];
|
||||
|
||||
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) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,21 +157,21 @@ export function GpsTracking() {
|
||||
|
||||
const history = locationHistory as LocationHistoryResponse;
|
||||
|
||||
// If we have matched coordinates, use those
|
||||
if (history.matched && history.coordinates && history.coordinates.length > 1) {
|
||||
// If we have OSRM matched route, use road-snapped coordinates
|
||||
if (history.matchedRoute && history.matchedRoute.coordinates && history.matchedRoute.coordinates.length > 1) {
|
||||
return {
|
||||
positions: history.coordinates,
|
||||
positions: history.matchedRoute.coordinates,
|
||||
isMatched: true,
|
||||
distance: history.distance,
|
||||
duration: history.duration,
|
||||
confidence: history.confidence,
|
||||
distance: history.matchedRoute.distance, // already in miles from backend
|
||||
duration: history.matchedRoute.duration,
|
||||
confidence: history.matchedRoute.confidence,
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to raw positions
|
||||
if (history.rawPositions && history.rawPositions.length > 1) {
|
||||
// Fall back to raw GPS points
|
||||
if (history.rawPoints && history.rawPoints.length > 1) {
|
||||
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,
|
||||
distance: undefined,
|
||||
duration: undefined,
|
||||
@@ -433,7 +433,7 @@ export function GpsTracking() {
|
||||
position={[driver.location!.latitude, driver.location!.longitude]}
|
||||
icon={createDriverIcon(true)}
|
||||
>
|
||||
<Popup>
|
||||
<Popup autoPan={false}>
|
||||
<div className="min-w-[180px]">
|
||||
<h3 className="font-semibold text-base">{driver.driverName}</h3>
|
||||
{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>
|
||||
<span className="text-xs text-muted-foreground">GPS Trail</span>
|
||||
</div>
|
||||
{routePolyline && routePolyline.distance && (
|
||||
{routePolyline && routePolyline.distance != null && (
|
||||
<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>
|
||||
Distance: <span className="font-medium text-foreground">{routePolyline.distance.toFixed(1)} mi</span>
|
||||
</div>
|
||||
{routePolyline.confidence !== undefined && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -108,10 +108,20 @@ export interface MyGpsStatus {
|
||||
}
|
||||
|
||||
export interface LocationHistoryResponse {
|
||||
matched: boolean;
|
||||
coordinates?: [number, number][]; // road-snapped [lat, lng] pairs
|
||||
distance?: number; // road distance in meters
|
||||
duration?: number; // duration in seconds
|
||||
confidence?: number; // 0-1 confidence score
|
||||
rawPositions?: Array<{ latitude: number; longitude: number; speed: number; timestamp: Date }>;
|
||||
rawPoints: Array<{
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
altitude?: number | null;
|
||||
speed?: number | null;
|
||||
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