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} 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 };