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:
2025-09-26 00:18:10 +02:00
parent 8a0baa02c3
commit 5aac63dfde
6 changed files with 3750 additions and 100 deletions

View File

@@ -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
View 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`;
}