feat: Implement route planning feature with project selection and optimization
- Added route planning functionality to the map page, allowing users to select projects for routing. - Implemented state management for route projects, start/end points, and search functionality. - Integrated OpenRouteService API for route calculation and optimization. - Enhanced UI with a route planning panel, including search and drag-and-drop reordering of projects. - Added visual indicators for route start and end points on the map. - Included translations for route planning features in both Polish and English. - Created utility functions for route calculations, optimizations, and formatting of route data.
This commit is contained in:
@@ -307,7 +307,34 @@ const translations = {
|
||||
viewAllProjects: "Zobacz wszystkie projekty",
|
||||
zoomIn: "Przybliż",
|
||||
zoomOut: "Oddal",
|
||||
viewProjectDetails: "Zobacz szczegóły projektu"
|
||||
viewProjectDetails: "Zobacz szczegóły projektu",
|
||||
routePlanning: {
|
||||
title: "Planowanie trasy",
|
||||
projects: "projektów",
|
||||
searchPlaceholder: "Szukaj projektów...",
|
||||
noProjectsFound: "Nie znaleziono projektów pasujących do \"{{query}}\"",
|
||||
searchHintText: "Spróbuj szukać po nazwie projektu lub numerze WP",
|
||||
searchHint: "Przeciągnij aby zmienić kolejność",
|
||||
searchHintIcons: "Ustaw początek • Ustaw koniec • Usuń",
|
||||
dragToReorder: "Przeciągnij aby zmienić kolejność",
|
||||
setStart: "Ustaw początek",
|
||||
setEnd: "Ustaw koniec",
|
||||
remove: "Usuń",
|
||||
start: "START",
|
||||
end: "KONIEC",
|
||||
setAsStartPoint: "Ustaw jako punkt początkowy",
|
||||
setAsEndPoint: "Ustaw jako punkt końcowy",
|
||||
removeFromRoute: "Usuń z trasy",
|
||||
calculating: "Obliczanie...",
|
||||
findOptimalRoute: "Znajdź optymalną trasę",
|
||||
calculateRoute: "Oblicz trasę",
|
||||
routeCalculated: "Trasa obliczona",
|
||||
optimized: "zoptymalizowana",
|
||||
method: "Metoda",
|
||||
error: "Błąd",
|
||||
planRoutes: "Planuj trasy między projektami",
|
||||
clickToAddProjects: "Kliknij na znaczniki projektów, aby dodać je do trasy"
|
||||
}
|
||||
},
|
||||
|
||||
// Tasks
|
||||
@@ -753,7 +780,34 @@ const translations = {
|
||||
viewAllProjects: "View All Projects",
|
||||
zoomIn: "Zoom In",
|
||||
zoomOut: "Zoom Out",
|
||||
viewProjectDetails: "View Project Details"
|
||||
viewProjectDetails: "View Project Details",
|
||||
routePlanning: {
|
||||
title: "Route Planning",
|
||||
projects: "projects",
|
||||
searchPlaceholder: "Search projects...",
|
||||
noProjectsFound: "No projects found matching \"{{query}}\"",
|
||||
searchHintText: "Try searching by project name or WP number",
|
||||
searchHint: "Drag to reorder",
|
||||
searchHintIcons: "Set start • Set end • Remove",
|
||||
dragToReorder: "Drag to reorder",
|
||||
setStart: "Set start",
|
||||
setEnd: "Set end",
|
||||
remove: "Remove",
|
||||
start: "START",
|
||||
end: "END",
|
||||
setAsStartPoint: "Set as start point",
|
||||
setAsEndPoint: "Set as end point",
|
||||
removeFromRoute: "Remove from route",
|
||||
calculating: "Calculating...",
|
||||
findOptimalRoute: "Find Optimal Route",
|
||||
calculateRoute: "Calculate Route",
|
||||
routeCalculated: "Route Calculated",
|
||||
optimized: "optimized",
|
||||
method: "Method",
|
||||
error: "Error",
|
||||
planRoutes: "Plan routes between projects",
|
||||
clickToAddProjects: "Click on project markers to add them to your route"
|
||||
}
|
||||
},
|
||||
|
||||
contracts: {
|
||||
|
||||
219
src/lib/routeUtils.js
Normal file
219
src/lib/routeUtils.js
Normal file
@@ -0,0 +1,219 @@
|
||||
// Route planning utilities using OpenRouteService API
|
||||
import polyline from '@mapbox/polyline';
|
||||
|
||||
/**
|
||||
* Calculate route between coordinates using OpenRouteService Directions API
|
||||
*/
|
||||
export async function calculateRouteForCoordinates(coordinates) {
|
||||
if (!process.env.NEXT_PUBLIC_ORS_API_KEY) {
|
||||
throw new Error('OpenRouteService API key not configured');
|
||||
}
|
||||
|
||||
console.log('Using API key:', process.env.NEXT_PUBLIC_ORS_API_KEY ? 'Present' : 'Missing');
|
||||
console.log('Requesting route for coordinates:', coordinates);
|
||||
|
||||
const requestBody = {
|
||||
coordinates: coordinates,
|
||||
format: 'geojson',
|
||||
instructions: true,
|
||||
geometry_simplify: false,
|
||||
continue_straight: false,
|
||||
roundabout_exits: true,
|
||||
attributes: ['avgspeed', 'percentage']
|
||||
};
|
||||
|
||||
console.log('Request body:', JSON.stringify(requestBody, null, 2));
|
||||
|
||||
const response = await fetch('https://api.openrouteservice.org/v2/directions/driving-car', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': process.env.NEXT_PUBLIC_ORS_API_KEY,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
console.log('API response status:', response.status);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('API error response:', errorText);
|
||||
throw new Error(`OpenRouteService API error: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('API response data structure:', {
|
||||
hasFeatures: !!data.features,
|
||||
featureCount: data.features?.length,
|
||||
firstFeature: data.features?.[0]
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize route order for multiple points using OpenRouteService Optimization API
|
||||
*/
|
||||
export async function optimizeRoute(coordinates) {
|
||||
if (!process.env.NEXT_PUBLIC_ORS_API_KEY) {
|
||||
throw new Error('OpenRouteService API key not configured');
|
||||
}
|
||||
|
||||
const optimizationRequest = {
|
||||
jobs: coordinates.map((coord, index) => ({
|
||||
id: index,
|
||||
location: coord,
|
||||
service: 0
|
||||
})),
|
||||
vehicles: [{
|
||||
id: 0,
|
||||
profile: 'driving-car',
|
||||
capacity: [coordinates.length]
|
||||
}],
|
||||
options: { g: true }
|
||||
};
|
||||
|
||||
const response = await fetch('https://api.openrouteservice.org/optimization', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': process.env.NEXT_PUBLIC_ORS_API_KEY,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(optimizationRequest)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenRouteService Optimization API error: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract optimized coordinate order from ORS optimization response
|
||||
*/
|
||||
export function extractOptimizedOrder(optimizationData, originalCoordinates) {
|
||||
if (!optimizationData.routes || !optimizationData.routes[0] || !optimizationData.routes[0].steps) {
|
||||
return originalCoordinates; // Return original if no optimization data
|
||||
}
|
||||
|
||||
const optimizedOrder = [];
|
||||
const steps = optimizationData.routes[0].steps;
|
||||
|
||||
// Skip the start depot (id: 0) and extract job locations in optimized order
|
||||
for (const step of steps) {
|
||||
if (step.type === 'job' && step.id !== undefined) {
|
||||
const jobIndex = step.id;
|
||||
if (jobIndex < originalCoordinates.length) {
|
||||
optimizedOrder.push(originalCoordinates[jobIndex]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return optimizedOrder.length > 0 ? optimizedOrder : originalCoordinates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if route order was actually optimized vs original order
|
||||
*/
|
||||
export function detectOrderChange(originalCoords, optimizedCoords) {
|
||||
if (originalCoords.length !== optimizedCoords.length) return true;
|
||||
|
||||
for (let i = 0; i < originalCoords.length; i++) {
|
||||
const orig = originalCoords[i];
|
||||
const opt = optimizedCoords[i];
|
||||
|
||||
// Compare coordinates with small tolerance for floating point
|
||||
if (Math.abs(orig[0] - opt[0]) > 0.0001 || Math.abs(orig[1] - opt[1]) > 0.0001) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback route optimization using simple nearest neighbor algorithm
|
||||
*/
|
||||
export function optimizeRouteLocally(coordinates) {
|
||||
if (coordinates.length <= 2) return coordinates;
|
||||
|
||||
const optimized = [coordinates[0]]; // Start with first point
|
||||
const remaining = [...coordinates.slice(1)];
|
||||
|
||||
while (remaining.length > 0) {
|
||||
const lastPoint = optimized[optimized.length - 1];
|
||||
let nearestIndex = 0;
|
||||
let nearestDistance = Number.MAX_VALUE;
|
||||
|
||||
// Find nearest remaining point
|
||||
remaining.forEach((point, index) => {
|
||||
const distance = Math.sqrt(
|
||||
Math.pow(point[0] - lastPoint[0], 2) + Math.pow(point[1] - lastPoint[1], 2)
|
||||
);
|
||||
if (distance < nearestDistance) {
|
||||
nearestDistance = distance;
|
||||
nearestIndex = index;
|
||||
}
|
||||
});
|
||||
|
||||
optimized.push(remaining[nearestIndex]);
|
||||
remaining.splice(nearestIndex, 1);
|
||||
}
|
||||
|
||||
return optimized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert route geometry to Leaflet-compatible coordinates
|
||||
* Handles both GeoJSON and ORS polyline formats
|
||||
*/
|
||||
export function decodeRouteGeometry(routeData) {
|
||||
// Check if it's ORS format (has routes array)
|
||||
if (routeData.routes && routeData.routes[0]) {
|
||||
const route = routeData.routes[0];
|
||||
|
||||
// ORS returns geometry as encoded polyline
|
||||
if (route.geometry && typeof route.geometry === 'string') {
|
||||
try {
|
||||
// Decode the polyline using @mapbox/polyline
|
||||
const decoded = polyline.decode(route.geometry);
|
||||
return decoded; // Already in [lat, lng] format
|
||||
} catch (error) {
|
||||
console.error('Error decoding ORS geometry:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to GeoJSON format (if any API returns this)
|
||||
if (routeData.features && routeData.features[0]) {
|
||||
const feature = routeData.features[0];
|
||||
if (feature.geometry && feature.geometry.coordinates) {
|
||||
return feature.geometry.coordinates.map(coord => [coord[1], coord[0]]);
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration from seconds to readable string
|
||||
*/
|
||||
export function formatDuration(seconds) {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format distance from meters to readable string
|
||||
*/
|
||||
export function formatDistance(meters) {
|
||||
const km = meters / 1000;
|
||||
return `${km.toFixed(1)} km`;
|
||||
}
|
||||
Reference in New Issue
Block a user