263 lines
7.4 KiB
JavaScript

const axios = require('axios');
const logger = require('../utils/logger');
// OSRM public demo server (free, rate-limited)
// For production, consider self-hosting OSRM or using a paid service
const OSRM_BASE_URL = 'https://router.project-osrm.org';
// Cache for routing results
const routeCache = new Map();
const CACHE_TTL = 30 * 60 * 1000; // 30 minutes
/**
* Get driving directions between two points using OSRM
* @param {number} startLat - Starting latitude
* @param {number} startLng - Starting longitude
* @param {number} endLat - Destination latitude
* @param {number} endLng - Destination longitude
* @param {string} profile - Routing profile: 'driving', 'walking', 'cycling'
* @returns {Promise<Object>} Route with geometry and turn-by-turn instructions
*/
async function getDirections(startLat, startLng, endLat, endLng, profile = 'driving') {
const cacheKey = `${startLat.toFixed(5)},${startLng.toFixed(5)}-${endLat.toFixed(5)},${endLng.toFixed(5)}-${profile}`;
// Check cache
const cached = routeCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
logger.debug('Route cache hit');
return cached.data;
}
try {
// OSRM expects coordinates as lng,lat (GeoJSON order)
const coordinates = `${startLng},${startLat};${endLng},${endLat}`;
const response = await axios.get(
`${OSRM_BASE_URL}/route/v1/${profile}/${coordinates}`,
{
params: {
overview: 'full', // Get full route geometry
geometries: 'geojson', // Return as GeoJSON
steps: true, // Include turn-by-turn steps
annotations: 'duration,distance' // Include duration and distance
},
timeout: 15000,
headers: {
'User-Agent': 'FreeAlbertaFood/1.0'
}
}
);
if (response.data.code !== 'Ok') {
throw new Error(`OSRM error: ${response.data.code}`);
}
const route = response.data.routes[0];
const result = {
distance: route.distance, // meters
duration: route.duration, // seconds
distanceText: formatDistance(route.distance),
durationText: formatDuration(route.duration),
geometry: route.geometry, // GeoJSON LineString
steps: formatSteps(route.legs[0].steps),
bounds: calculateBounds(route.geometry.coordinates),
waypoints: response.data.waypoints.map(wp => ({
name: wp.name || 'Unknown',
location: [wp.location[1], wp.location[0]] // Convert to [lat, lng]
}))
};
// Cache result
routeCache.set(cacheKey, { data: result, timestamp: Date.now() });
return result;
} catch (error) {
logger.error('Routing error:', error.message);
if (error.code === 'ECONNABORTED') {
throw new Error('Routing service timeout');
}
if (error.response?.status === 429) {
throw new Error('Rate limit exceeded. Please try again later.');
}
throw new Error('Could not calculate route');
}
}
/**
* Format turn-by-turn steps with icons and instructions
*/
function formatSteps(steps) {
return steps.map((step, index) => {
const maneuver = step.maneuver;
return {
number: index + 1,
instruction: formatInstruction(step),
distance: step.distance,
duration: step.duration,
distanceText: formatDistance(step.distance),
durationText: formatDuration(step.duration),
name: step.name || 'unnamed road',
mode: step.mode,
maneuver: {
type: maneuver.type,
modifier: maneuver.modifier || null,
location: [maneuver.location[1], maneuver.location[0]], // [lat, lng]
bearingBefore: maneuver.bearing_before,
bearingAfter: maneuver.bearing_after,
icon: getManeuverIcon(maneuver.type, maneuver.modifier)
},
geometry: step.geometry
};
});
}
/**
* Generate human-readable instruction from step
*/
function formatInstruction(step) {
const maneuver = step.maneuver;
const name = step.name || 'the road';
const distance = formatDistance(step.distance);
switch (maneuver.type) {
case 'depart':
return `Start on ${name} heading ${getCardinalDirection(maneuver.bearing_after)}`;
case 'arrive':
return 'Arrive at your destination';
case 'turn':
return `Turn ${maneuver.modifier || ''} onto ${name}`;
case 'continue':
return `Continue on ${name} for ${distance}`;
case 'merge':
return `Merge ${maneuver.modifier || ''} onto ${name}`;
case 'on ramp':
case 'off ramp':
return `Take the ramp onto ${name}`;
case 'fork':
return `Keep ${maneuver.modifier || 'straight'} at the fork onto ${name}`;
case 'end of road':
return `At the end of the road, turn ${maneuver.modifier || ''} onto ${name}`;
case 'new name':
return `Continue onto ${name}`;
case 'roundabout':
const exit = step.maneuver.exit || '';
return `Enter the roundabout and take exit ${exit} onto ${name}`;
case 'rotary':
return `Enter the rotary and take the exit onto ${name}`;
case 'roundabout turn':
return `At the roundabout, take the ${maneuver.modifier || ''} exit onto ${name}`;
case 'notification':
return step.name || 'Continue';
default:
if (maneuver.modifier) {
return `Go ${maneuver.modifier} on ${name}`;
}
return `Continue on ${name}`;
}
}
/**
* Get icon class for maneuver type
*/
function getManeuverIcon(type, modifier) {
const icons = {
'depart': 'start',
'arrive': 'destination',
'turn': modifier?.includes('left') ? 'turn-left' : modifier?.includes('right') ? 'turn-right' : 'straight',
'continue': 'straight',
'merge': modifier?.includes('left') ? 'merge-left' : 'merge-right',
'on ramp': 'ramp',
'off ramp': 'ramp',
'fork': modifier?.includes('left') ? 'fork-left' : 'fork-right',
'end of road': modifier?.includes('left') ? 'turn-left' : 'turn-right',
'roundabout': 'roundabout',
'rotary': 'roundabout'
};
return icons[type] || 'straight';
}
/**
* Get cardinal direction from bearing
*/
function getCardinalDirection(bearing) {
const directions = ['north', 'northeast', 'east', 'southeast', 'south', 'southwest', 'west', 'northwest'];
const index = Math.round(bearing / 45) % 8;
return directions[index];
}
/**
* Format distance in metric/imperial
*/
function formatDistance(meters) {
if (meters < 1000) {
return `${Math.round(meters)} m`;
}
return `${(meters / 1000).toFixed(1)} km`;
}
/**
* Format duration in human readable format
*/
function formatDuration(seconds) {
if (seconds < 60) {
return `${Math.round(seconds)} sec`;
}
if (seconds < 3600) {
const mins = Math.round(seconds / 60);
return `${mins} min`;
}
const hours = Math.floor(seconds / 3600);
const mins = Math.round((seconds % 3600) / 60);
return `${hours} hr ${mins} min`;
}
/**
* Calculate bounding box for route
*/
function calculateBounds(coordinates) {
let minLat = Infinity, maxLat = -Infinity;
let minLng = Infinity, maxLng = -Infinity;
coordinates.forEach(coord => {
const [lng, lat] = coord;
minLat = Math.min(minLat, lat);
maxLat = Math.max(maxLat, lat);
minLng = Math.min(minLng, lng);
maxLng = Math.max(maxLng, lng);
});
return [[minLat, minLng], [maxLat, maxLng]];
}
/**
* Clear routing cache
*/
function clearCache() {
routeCache.clear();
}
module.exports = {
getDirections,
formatDistance,
formatDuration,
clearCache
};