263 lines
7.4 KiB
JavaScript
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
|
|
};
|