import { Injectable, Logger } from '@nestjs/common';
export interface Stop {
id: string;
lat: number;
lng: number;
type: string; // 'pickup' | 'delivery'
address: string;
}
export interface OptimizedRoute {
stops: Stop[];
totalDistanceKm: number;
estimatedDurationMin: number;
}
@Injectable()
export class RouteOptimizer {
private readonly logger = new Logger(RouteOptimizer.name);
/**
* Optimize stop sequence using nearest-neighbor heuristic + 2-opt improvement.
*/
optimizeRoute(
stops: Stop[],
startLat?: number,
startLng?: number,
): OptimizedRoute {
if (stops.length <= 2) {
return {
stops,
totalDistanceKm: this.calculateTotalDistance(stops),
estimatedDurationMin: this.estimateDuration(
this.calculateTotalDistance(stops),
),
};
}
// Nearest neighbor
let route = this.nearestNeighbor(stops, startLat, startLng);
// 2-opt improvement
route = this.twoOpt(route);
const totalKm = this.calculateTotalDistance(route);
return {
stops: route,
totalDistanceKm: Math.round(totalKm * 10) / 10,
estimatedDurationMin: Math.round(this.estimateDuration(totalKm)),
};
}
/**
* Calculate ETA for a trip given current position and remaining stops.
*/
calculateETA(
currentLat: number,
currentLng: number,
remainingStops: Stop[],
): Array<{ stopId: string; etaMinutes: number; distanceKm: number }> {
const etas: Array<{
stopId: string;
etaMinutes: number;
distanceKm: number;
}> = [];
let prevLat = currentLat;
let prevLng = currentLng;
let cumulativeMin = 0;
for (const stop of remainingStops) {
const distKm = this.haversineDistance(
prevLat,
prevLng,
stop.lat,
stop.lng,
);
const durationMin = this.estimateDuration(distKm);
cumulativeMin += durationMin + 15; // 15 min dwell time per stop
etas.push({
stopId: stop.id,
etaMinutes: Math.round(cumulativeMin),
distanceKm: Math.round(distKm * 10) / 10,
});
prevLat = stop.lat;
prevLng = stop.lng;
}
return etas;
}
private nearestNeighbor(
stops: Stop[],
startLat?: number,
startLng?: number,
): Stop[] {
const unvisited = [...stops];
const route: Stop[] = [];
let currentLat = startLat || unvisited[0].lat;
let currentLng = startLng || unvisited[0].lng;
while (unvisited.length > 0) {
let nearestIdx = 0;
let nearestDist = Infinity;
for (let i = 0; i < unvisited.length; i++) {
const dist = this.haversineDistance(
currentLat,
currentLng,
unvisited[i].lat,
unvisited[i].lng,
);
if (dist < nearestDist) {
nearestDist = dist;
nearestIdx = i;
}
}
const nearest = unvisited.splice(nearestIdx, 1)[0];
route.push(nearest);
currentLat = nearest.lat;
currentLng = nearest.lng;
}
return route;
}
private twoOpt(route: Stop[]): Stop[] {
let improved = true;
let best = [...route];
while (improved) {
improved = false;
for (let i = 1; i < best.length - 1; i++) {
for (let j = i + 1; j < best.length; j++) {
const newRoute = [
...best.slice(0, i),
...best.slice(i, j + 1).reverse(),
...best.slice(j + 1),
];
if (
this.calculateTotalDistance(newRoute) <
this.calculateTotalDistance(best)
) {
best = newRoute;
improved = true;
}
}
}
}
return best;
}
private calculateTotalDistance(stops: Stop[]): number {
let total = 0;
for (let i = 1; i < stops.length; i++) {
total += this.haversineDistance(
stops[i - 1].lat,
stops[i - 1].lng,
stops[i].lat,
stops[i].lng,
);
}
return total;
}
private estimateDuration(distKm: number): number {
// Assume average 60 km/h for East African roads
return (distKm / 60) * 60;
}
private haversineDistance(
lat1: number,
lon1: number,
lat2: number,
lon2: number,
): number {
const R = 6371;
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLon = ((lon2 - lon1) * Math.PI) / 180;
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos((lat1 * Math.PI) / 180) *
Math.cos((lat2 * Math.PI) / 180) *
Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
}