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:
2781
package-lock.json
generated
2781
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -16,9 +16,14 @@
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mapbox/polyline": "^1.2.1",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"exceljs": "^4.4.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^3.0.3",
|
||||
"jspdf-autotable": "^5.0.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "15.1.8",
|
||||
"next-auth": "^5.0.0-beta.29",
|
||||
@@ -29,6 +34,7 @@
|
||||
"react-dom": "^19.0.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"recharts": "^2.15.3",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^3.25.67"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -8,6 +8,16 @@ import { useTranslation } from "@/lib/i18n";
|
||||
import Button from "@/components/ui/Button";
|
||||
import { mapLayers } from "@/components/ui/mapLayers";
|
||||
import { formatProjectStatus } from "@/lib/utils";
|
||||
import {
|
||||
calculateRouteForCoordinates,
|
||||
optimizeRoute,
|
||||
extractOptimizedOrder,
|
||||
detectOrderChange,
|
||||
optimizeRouteLocally,
|
||||
decodeRouteGeometry,
|
||||
formatDuration,
|
||||
formatDistance
|
||||
} from "@/lib/routeUtils";
|
||||
|
||||
// Loading component that can access translations
|
||||
function MapLoadingComponent({ t }) {
|
||||
@@ -49,6 +59,20 @@ function ProjectsMapPageContent() {
|
||||
const [measurementDistance, setMeasurementDistance] = useState(0);
|
||||
const [measurementLine, setMeasurementLine] = useState(null);
|
||||
|
||||
// Route planning state
|
||||
const [routeProjects, setRouteProjects] = useState([]);
|
||||
const [routeData, setRouteData] = useState(null);
|
||||
const [isCalculatingRoute, setIsCalculatingRoute] = useState(false);
|
||||
const [showRoutePanel, setShowRoutePanel] = useState(false);
|
||||
const [routeStartId, setRouteStartId] = useState(null);
|
||||
const [routeEndId, setRouteEndId] = useState(null);
|
||||
const [routeConfigChanged, setRouteConfigChanged] = useState(false);
|
||||
|
||||
// Route search state
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState([]);
|
||||
const [showSearchResults, setShowSearchResults] = useState(false);
|
||||
|
||||
// Measurement tool functions
|
||||
const startMeasurement = () => {
|
||||
setIsMeasuring(true);
|
||||
@@ -188,6 +212,421 @@ function ProjectsMapPageContent() {
|
||||
}, 500); // Wait 500ms after the last move to update URL
|
||||
}, [updateURL]);
|
||||
|
||||
// Handle project click for route planning
|
||||
const handleProjectClick = React.useCallback((project) => {
|
||||
if (currentTool === "route") {
|
||||
setRouteProjects(prev => {
|
||||
const isAlreadySelected = prev.some(p => p.project_id === project.project_id);
|
||||
if (isAlreadySelected) {
|
||||
// Remove from route - also clear start/end points if they were set
|
||||
const newProjects = prev.filter(p => p.project_id !== project.project_id);
|
||||
if (routeStartId === project.project_id) {
|
||||
setRouteStartId(null);
|
||||
}
|
||||
if (routeEndId === project.project_id) {
|
||||
setRouteEndId(null);
|
||||
}
|
||||
console.log('Removed project from route, clearing route data');
|
||||
return newProjects;
|
||||
} else {
|
||||
// Add to route
|
||||
console.log('Added project to route, clearing route data');
|
||||
return [...prev, project];
|
||||
}
|
||||
});
|
||||
setRouteConfigChanged(true);
|
||||
// Clear route data when adding/removing projects
|
||||
setRouteData(null);
|
||||
}
|
||||
}, [currentTool, routeStartId, routeEndId]);
|
||||
|
||||
// Set start/end point functions
|
||||
const setStartPoint = React.useCallback((projectId) => {
|
||||
console.log('Setting start point to project:', projectId);
|
||||
setRouteStartId(projectId);
|
||||
// If this project was set as end point, clear it
|
||||
if (routeEndId === projectId) {
|
||||
console.log('Clearing end point because it was the same as start');
|
||||
setRouteEndId(null);
|
||||
}
|
||||
setRouteConfigChanged(true);
|
||||
// Clear route data since start point designation changed
|
||||
setRouteData(null);
|
||||
console.log('Route data cleared due to start point change');
|
||||
// Auto-recalculate route when start point changes
|
||||
setTimeout(() => {
|
||||
console.log('Auto-recalculating route after start point change');
|
||||
calculateRoute(true); // Force recalculation
|
||||
}, 100);
|
||||
}, [routeEndId]);
|
||||
|
||||
const setEndPoint = React.useCallback((projectId) => {
|
||||
console.log('Setting end point to project:', projectId);
|
||||
setRouteEndId(projectId);
|
||||
// If this project was set as start point, clear it
|
||||
if (routeStartId === projectId) {
|
||||
console.log('Clearing start point because it was the same as end');
|
||||
setRouteStartId(null);
|
||||
}
|
||||
setRouteConfigChanged(true);
|
||||
// Clear route data since end point designation changed
|
||||
setRouteData(null);
|
||||
console.log('Route data cleared due to end point change');
|
||||
// Auto-recalculate route when end point changes
|
||||
setTimeout(() => {
|
||||
console.log('Auto-recalculating route after end point change');
|
||||
calculateRoute(true); // Force recalculation
|
||||
}, 100);
|
||||
}, [routeStartId]);
|
||||
|
||||
const clearStartEndPoints = React.useCallback(() => {
|
||||
setRouteStartId(null);
|
||||
setRouteEndId(null);
|
||||
setRouteConfigChanged(true);
|
||||
// Clear route data since designations were cleared
|
||||
setRouteData(null);
|
||||
console.log('Route data cleared due to clearing start/end designations');
|
||||
// Auto-recalculate route when designations are cleared
|
||||
setTimeout(() => calculateRoute(true), 100); // Force recalculation
|
||||
}, []);
|
||||
|
||||
// Route search functions
|
||||
const handleSearchChange = React.useCallback((e) => {
|
||||
const query = e.target.value;
|
||||
setSearchQuery(query);
|
||||
|
||||
if (query.trim() === '') {
|
||||
setSearchResults([]);
|
||||
setShowSearchResults(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter projects that are not already in the route
|
||||
const availableProjects = projects.filter(project =>
|
||||
!routeProjects.some(routeProject => routeProject.project_id === project.project_id)
|
||||
);
|
||||
|
||||
// Search by project name or WP (assuming WP is in project name or a separate field)
|
||||
const filtered = availableProjects.filter(project =>
|
||||
project.project_name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
(project.wp && project.wp.toLowerCase().includes(query.toLowerCase()))
|
||||
).slice(0, 10); // Limit to 10 results
|
||||
|
||||
setSearchResults(filtered);
|
||||
setShowSearchResults(true);
|
||||
}, [projects, routeProjects]);
|
||||
|
||||
const addProjectFromSearch = React.useCallback((project) => {
|
||||
// Add project to route (similar to clicking on map marker)
|
||||
setRouteProjects(prev => [...prev, project]);
|
||||
setRouteConfigChanged(true);
|
||||
// Clear route data since project was added
|
||||
setRouteData(null);
|
||||
console.log('Added project from search, clearing route data');
|
||||
|
||||
// Clear search
|
||||
setSearchQuery('');
|
||||
setSearchResults([]);
|
||||
setShowSearchResults(false);
|
||||
}, []);
|
||||
|
||||
const handleSearchKeyDown = React.useCallback((e) => {
|
||||
if (e.key === 'Enter' && searchResults.length > 0) {
|
||||
// Select first result on Enter
|
||||
addProjectFromSearch(searchResults[0]);
|
||||
} else if (e.key === 'Escape') {
|
||||
// Close search on Escape
|
||||
setSearchQuery('');
|
||||
setSearchResults([]);
|
||||
setShowSearchResults(false);
|
||||
}
|
||||
}, [searchResults, addProjectFromSearch]);
|
||||
|
||||
// Close search results when clicking outside
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (showSearchResults && !event.target.closest('.search-container')) {
|
||||
setShowSearchResults(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [showSearchResults]);
|
||||
|
||||
// Drag and drop handlers for route reordering
|
||||
const [draggedIndex, setDraggedIndex] = useState(null);
|
||||
|
||||
const handleDragStart = React.useCallback((e, index) => {
|
||||
setDraggedIndex(index);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/html', e.target.outerHTML);
|
||||
e.target.style.opacity = '0.5';
|
||||
}, []);
|
||||
|
||||
const handleDragOver = React.useCallback((e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
const handleDragEnter = React.useCallback((e, index) => {
|
||||
e.preventDefault();
|
||||
// Visual feedback could be added here
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = React.useCallback((e) => {
|
||||
e.preventDefault();
|
||||
// Visual feedback could be added here
|
||||
}, []);
|
||||
|
||||
const handleDrop = React.useCallback((e, dropIndex) => {
|
||||
e.preventDefault();
|
||||
const dragIndex = draggedIndex;
|
||||
setDraggedIndex(null);
|
||||
|
||||
if (dragIndex === null || dragIndex === dropIndex) return;
|
||||
|
||||
const newRouteProjects = [...routeProjects];
|
||||
const draggedItem = newRouteProjects[dragIndex];
|
||||
|
||||
// Remove dragged item
|
||||
newRouteProjects.splice(dragIndex, 1);
|
||||
// Insert at new position
|
||||
newRouteProjects.splice(dropIndex, 0, draggedItem);
|
||||
|
||||
setRouteProjects(newRouteProjects);
|
||||
setRouteConfigChanged(true);
|
||||
// Clear route data since manual reordering invalidates the current route
|
||||
setRouteData(null);
|
||||
console.log('Route data cleared due to drag and drop reordering');
|
||||
}, [draggedIndex, routeProjects]);
|
||||
|
||||
const handleDragEnd = React.useCallback((e) => {
|
||||
setDraggedIndex(null);
|
||||
e.target.style.opacity = '1';
|
||||
}, []);
|
||||
|
||||
// Calculate route between selected projects
|
||||
const calculateRoute = React.useCallback(async (forceRecalculation = false) => {
|
||||
console.log('calculateRoute called with:', {
|
||||
routeProjectsLength: routeProjects.length,
|
||||
routeConfigChanged,
|
||||
hasRouteData: !!routeData,
|
||||
forceRecalculation,
|
||||
routeStartId,
|
||||
routeEndId
|
||||
});
|
||||
|
||||
if (routeProjects.length < 2) return;
|
||||
|
||||
// If route configuration hasn't changed and not forced, don't recalculate
|
||||
if (!routeConfigChanged && routeData && !forceRecalculation) {
|
||||
console.log('Route configuration unchanged, skipping recalculation');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCalculatingRoute(true);
|
||||
|
||||
// Clear route data since we're recalculating
|
||||
setRouteData(null);
|
||||
|
||||
try {
|
||||
// Extract coordinates from selected projects
|
||||
const coordinates = routeProjects
|
||||
.map(project => {
|
||||
if (project.coordinates) {
|
||||
const [lat, lng] = project.coordinates.split(",").map(coord => parseFloat(coord.trim()));
|
||||
if (!isNaN(lat) && !isNaN(lng)) {
|
||||
return [lng, lat]; // ORS expects [lng, lat]
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(coord => coord !== null);
|
||||
|
||||
if (coordinates.length < 2) {
|
||||
throw new Error('Not enough valid coordinates for routing');
|
||||
}
|
||||
|
||||
let finalCoordinates = coordinates;
|
||||
let optimizationMethod = 'direct';
|
||||
let wasOptimized = false;
|
||||
|
||||
// Handle start/end point designations
|
||||
let startCoord = null;
|
||||
let endCoord = null;
|
||||
let middleCoordinates = coordinates;
|
||||
|
||||
if (routeStartId) {
|
||||
const startProject = routeProjects.find(p => p.project_id === routeStartId);
|
||||
if (startProject && startProject.coordinates) {
|
||||
const [lat, lng] = startProject.coordinates.split(",").map(coord => parseFloat(coord.trim()));
|
||||
startCoord = [lng, lat];
|
||||
console.log(`Start point designated: ${startProject.project_name} at [${lng}, ${lat}]`);
|
||||
}
|
||||
}
|
||||
|
||||
if (routeEndId) {
|
||||
const endProject = routeProjects.find(p => p.project_id === routeEndId);
|
||||
if (endProject && endProject.coordinates) {
|
||||
const [lat, lng] = endProject.coordinates.split(",").map(coord => parseFloat(coord.trim()));
|
||||
endCoord = [lng, lat];
|
||||
console.log(`End point designated: ${endProject.project_name} at [${lng}, ${lat}]`);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter middle coordinates (exclude designated start/end points)
|
||||
if (startCoord || endCoord) {
|
||||
middleCoordinates = coordinates.filter(coord => {
|
||||
const isStart = startCoord && coord[0] === startCoord[0] && coord[1] === startCoord[1];
|
||||
const isEnd = endCoord && coord[0] === endCoord[0] && coord[1] === endCoord[1];
|
||||
return !isStart && !isEnd;
|
||||
});
|
||||
}
|
||||
|
||||
// For 3+ points, try optimization
|
||||
if (coordinates.length >= 3) {
|
||||
try {
|
||||
console.log('Attempting route optimization...');
|
||||
|
||||
let optimizedMiddleCoordinates = middleCoordinates;
|
||||
|
||||
// Only optimize if we have middle points to optimize
|
||||
if (middleCoordinates.length >= 2) {
|
||||
const optimizationData = await optimizeRoute(middleCoordinates);
|
||||
const tempOptimized = extractOptimizedOrder(optimizationData, middleCoordinates);
|
||||
|
||||
// Check if order actually changed
|
||||
const orderChanged = detectOrderChange(middleCoordinates, tempOptimized);
|
||||
|
||||
if (orderChanged) {
|
||||
optimizedMiddleCoordinates = tempOptimized;
|
||||
console.log('Middle points optimized successfully');
|
||||
} else {
|
||||
console.log('Optimization did not improve middle points order');
|
||||
}
|
||||
}
|
||||
|
||||
// Reconstruct final coordinates with fixed start/end and optimized middle
|
||||
let finalCoordinates;
|
||||
if (startCoord && endCoord) {
|
||||
finalCoordinates = [startCoord, ...optimizedMiddleCoordinates, endCoord];
|
||||
optimizationMethod = 'Fixed_Start_End_Optimized';
|
||||
console.log('Using fixed start and end points:', { startCoord, endCoord, middleCount: optimizedMiddleCoordinates.length });
|
||||
} else if (startCoord) {
|
||||
finalCoordinates = [startCoord, ...optimizedMiddleCoordinates];
|
||||
optimizationMethod = 'Fixed_Start_Optimized';
|
||||
console.log('Using fixed start point:', { startCoord, middleCount: optimizedMiddleCoordinates.length });
|
||||
} else if (endCoord) {
|
||||
finalCoordinates = [...optimizedMiddleCoordinates, endCoord];
|
||||
optimizationMethod = 'Fixed_End_Optimized';
|
||||
console.log('Using fixed end point:', { endCoord, middleCount: optimizedMiddleCoordinates.length });
|
||||
} else {
|
||||
// No fixed points, use standard optimization
|
||||
const optimizationData = await optimizeRoute(coordinates);
|
||||
finalCoordinates = extractOptimizedOrder(optimizationData, coordinates);
|
||||
const orderChanged = detectOrderChange(coordinates, finalCoordinates);
|
||||
optimizationMethod = orderChanged ? 'Standard_Optimization' : 'Direct';
|
||||
console.log('No fixed points, using standard optimization');
|
||||
}
|
||||
|
||||
wasOptimized = optimizationMethod !== 'Direct';
|
||||
console.log(`Route optimization method: ${optimizationMethod}`);
|
||||
console.log('Final coordinates order:', finalCoordinates);
|
||||
} catch (optimizationError) {
|
||||
console.warn('Route optimization failed, using direct routing:', optimizationError.message);
|
||||
// Fallback: keep original order but respect start/end positions
|
||||
if (startCoord && endCoord) {
|
||||
finalCoordinates = [startCoord, ...middleCoordinates, endCoord];
|
||||
} else if (startCoord) {
|
||||
finalCoordinates = [startCoord, ...middleCoordinates];
|
||||
} else if (endCoord) {
|
||||
finalCoordinates = [...middleCoordinates, endCoord];
|
||||
} else {
|
||||
finalCoordinates = coordinates;
|
||||
}
|
||||
optimizationMethod = 'Direct_Fallback';
|
||||
wasOptimized = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate the actual route with final coordinates
|
||||
console.log('Calculating route with coordinates:', finalCoordinates);
|
||||
const routeData = await calculateRouteForCoordinates(finalCoordinates);
|
||||
|
||||
// Extract route information
|
||||
// Handle both ORS format (routes[0].summary) and GeoJSON format (features[0].properties.summary)
|
||||
let summary = null;
|
||||
if (routeData.routes && routeData.routes[0] && routeData.routes[0].summary) {
|
||||
summary = routeData.routes[0].summary;
|
||||
} else if (routeData.features && routeData.features[0] && routeData.features[0].properties) {
|
||||
summary = routeData.features[0].properties.summary;
|
||||
}
|
||||
|
||||
const geometry = decodeRouteGeometry(routeData);
|
||||
|
||||
setRouteData({
|
||||
distance: summary ? formatDistance(summary.distance) : 'Unknown',
|
||||
duration: summary ? formatDuration(summary.duration) : 'Unknown',
|
||||
optimized: wasOptimized,
|
||||
method: optimizationMethod,
|
||||
geometry: geometry,
|
||||
summary: summary
|
||||
});
|
||||
|
||||
// Always reorder routeProjects to match the final calculated order
|
||||
// This ensures the UI list reflects the actual route order
|
||||
console.log('Reordering projects. Final coordinates:', finalCoordinates);
|
||||
console.log('Current routeProjects:', routeProjects.map(p => ({ id: p.project_id, name: p.project_name, coords: p.coordinates })));
|
||||
|
||||
const reorderedProjects = finalCoordinates.map(optimizedCoord => {
|
||||
// Find the project that matches these coordinates (with tolerance for floating point precision)
|
||||
const matchingProject = routeProjects.find(project => {
|
||||
if (project.coordinates) {
|
||||
const [lat, lng] = project.coordinates.split(",").map(coord => parseFloat(coord.trim()));
|
||||
// Use a reasonable tolerance for coordinate matching (about 10 meters)
|
||||
const tolerance = 0.0001; // ~11 meters at equator
|
||||
const matches = Math.abs(lat - optimizedCoord[1]) < tolerance && Math.abs(lng - optimizedCoord[0]) < tolerance;
|
||||
if (matches) {
|
||||
console.log(`Found matching project ${project.project_name} for coordinates [${optimizedCoord[0]}, ${optimizedCoord[1]}]`);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (!matchingProject) {
|
||||
console.warn(`No project found for coordinates [${optimizedCoord[0]}, ${optimizedCoord[1]}]`);
|
||||
}
|
||||
return matchingProject;
|
||||
}).filter(project => project !== undefined);
|
||||
|
||||
if (reorderedProjects.length === routeProjects.length) {
|
||||
setRouteProjects(reorderedProjects);
|
||||
console.log('Route projects reordered to match calculated route');
|
||||
}
|
||||
|
||||
console.log('Route calculated successfully:', {
|
||||
distance: summary?.distance,
|
||||
duration: summary?.duration,
|
||||
points: geometry.length,
|
||||
optimized: wasOptimized
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Route calculation error:', error);
|
||||
setRouteData({
|
||||
error: error.message,
|
||||
distance: 'Error',
|
||||
duration: 'Error',
|
||||
optimized: false
|
||||
});
|
||||
} finally {
|
||||
setIsCalculatingRoute(false);
|
||||
setRouteConfigChanged(false);
|
||||
}
|
||||
}, [routeProjects]);
|
||||
|
||||
// Hide navigation and ensure full-screen layout
|
||||
useEffect(() => {
|
||||
// Check for URL parameters for coordinates and zoom
|
||||
@@ -303,6 +742,7 @@ function ProjectsMapPageContent() {
|
||||
return {
|
||||
position: [lat, lng],
|
||||
color: statusInfo.color,
|
||||
project: project, // Add project data for route planning
|
||||
popup: (
|
||||
<div className="min-w-72 max-w-80">
|
||||
<div className="mb-3 pb-2 border-b border-gray-200">
|
||||
@@ -617,8 +1057,253 @@ function ProjectsMapPageContent() {
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/* Route Planning Tool */}
|
||||
<button
|
||||
className={`p-3 transition-colors duration-200 ${
|
||||
currentTool === "route"
|
||||
? "bg-blue-100 text-blue-700"
|
||||
: "text-gray-700 hover:bg-gray-50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (currentTool === "route") {
|
||||
setCurrentTool("move");
|
||||
setShowRoutePanel(false);
|
||||
setRouteProjects([]);
|
||||
setRouteData(null);
|
||||
setRouteStartId(null);
|
||||
setRouteEndId(null);
|
||||
setRouteConfigChanged(false);
|
||||
} else {
|
||||
setCurrentTool("route");
|
||||
setShowRoutePanel(true);
|
||||
}
|
||||
}}
|
||||
title={t('map.routePlanning.planRoutes')}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Route Planning Panel */}
|
||||
{showRoutePanel && (
|
||||
<div className="absolute top-48 left-20 z-[1000]">
|
||||
<div className="bg-white/95 backdrop-blur-sm rounded-lg shadow-lg border border-gray-200 p-4 min-w-[300px]">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-gray-700">{t('map.routePlanning.title')}</h3>
|
||||
<span className="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded-full">
|
||||
{routeProjects.length} {t('map.routePlanning.projects')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="mb-3 relative search-container">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
placeholder={t('map.routePlanning.searchPlaceholder')}
|
||||
className="w-full text-sm border border-gray-300 rounded-md px-3 py-2 pr-8 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Results Dropdown */}
|
||||
{showSearchResults && searchResults.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 z-10 bg-white border border-gray-300 rounded-md shadow-lg max-h-48 overflow-y-auto mt-1">
|
||||
{searchResults.map((project) => (
|
||||
<div
|
||||
key={project.project_id}
|
||||
onClick={() => addProjectFromSearch(project)}
|
||||
className="px-3 py-2 hover:bg-gray-50 cursor-pointer border-b border-gray-100 last:border-b-0"
|
||||
>
|
||||
<div className="text-sm font-medium text-gray-900">{project.project_name}</div>
|
||||
{project.wp && (
|
||||
<div className="text-xs text-gray-500">WP: {project.wp}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No Results Message */}
|
||||
{showSearchResults && searchQuery.trim() !== '' && searchResults.length === 0 && (
|
||||
<div className="absolute top-full left-0 right-0 z-10 bg-white border border-gray-300 rounded-md shadow-lg p-3 mt-1">
|
||||
<div className="text-sm text-gray-500">{t('map.routePlanning.noProjectsFound', { query: searchQuery })}</div>
|
||||
<div className="text-xs text-gray-400 mt-1">{t('map.routePlanning.searchHintText')}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Hint */}
|
||||
{searchQuery.trim() === '' && (
|
||||
<div className="text-xs text-gray-400 mt-1 flex items-center gap-1">
|
||||
<span>{t('map.routePlanning.searchHint')}</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<svg className="w-3 h-3 text-green-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<svg className="w-3 h-3 text-red-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 00-1 1v4a1 1 0 001 1h4a1 1 0 001-1V8a1 1 0 00-1-1H8z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{t('map.routePlanning.searchHintIcons')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{routeProjects.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<p className="text-xs text-gray-500 flex items-center gap-1">
|
||||
<span>{t('map.routePlanning.dragToReorder')}</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<svg className="w-3 h-3 text-green-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{t('map.routePlanning.setStart')}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<svg className="w-3 h-3 text-red-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 00-1 1v4a1 1 0 001 1h4a1 1 0 001-1V8a1 1 0 00-1-1H8z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{t('map.routePlanning.setEnd')}
|
||||
</span>
|
||||
<span>• × {t('map.routePlanning.remove')}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{routeProjects.length > 0 && (
|
||||
<div className="mb-3 max-h-32 overflow-y-auto">
|
||||
{routeProjects.map((project, index) => {
|
||||
const isStart = routeStartId === project.project_id;
|
||||
const isEnd = routeEndId === project.project_id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={project.project_id}
|
||||
className={`flex items-center justify-between py-1 px-2 rounded cursor-move ${
|
||||
draggedIndex === index ? 'bg-blue-50 border border-blue-200' :
|
||||
isStart ? 'bg-green-50 border border-green-200' :
|
||||
isEnd ? 'bg-red-50 border border-red-200' :
|
||||
'hover:bg-gray-50'
|
||||
}`}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, index)}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={(e) => handleDragEnter(e, index)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<span className="text-xs font-medium text-gray-600 w-6">⋮⋮</span>
|
||||
<span className="text-xs font-medium text-gray-600 w-6">{index + 1}.</span>
|
||||
<span className="text-sm text-gray-700 truncate">{project.project_name}</span>
|
||||
{isStart && <span className="text-xs bg-green-100 text-green-800 px-1 rounded">{t('map.routePlanning.start')}</span>}
|
||||
{isEnd && <span className="text-xs bg-red-100 text-red-800 px-1 rounded">{t('map.routePlanning.end')}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setStartPoint(project.project_id);
|
||||
}}
|
||||
className={`text-xs px-1.5 py-1 rounded text-white transition-colors ${
|
||||
isStart ? 'bg-green-600' : 'bg-green-500 hover:bg-green-600'
|
||||
}`}
|
||||
title={t('map.routePlanning.setAsStartPoint')}
|
||||
>
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEndPoint(project.project_id);
|
||||
}}
|
||||
className={`text-xs px-1.5 py-1 rounded text-white transition-colors ${
|
||||
isEnd ? 'bg-red-600' : 'bg-red-500 hover:bg-red-600'
|
||||
}`}
|
||||
title={t('map.routePlanning.setAsEndPoint')}
|
||||
>
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 00-1 1v4a1 1 0 001 1h4a1 1 0 001-1V8a1 1 0 00-1-1H8z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// Clear start/end if this project was designated
|
||||
if (routeStartId === project.project_id) {
|
||||
setRouteStartId(null);
|
||||
}
|
||||
if (routeEndId === project.project_id) {
|
||||
setRouteEndId(null);
|
||||
}
|
||||
setRouteProjects(routeProjects.filter(p => p.project_id !== project.project_id));
|
||||
setRouteConfigChanged(true);
|
||||
// Clear route data since project was removed
|
||||
setRouteData(null);
|
||||
console.log('Removed project from route panel, clearing route data');
|
||||
}}
|
||||
className="text-gray-400 hover:text-red-500 text-sm ml-1"
|
||||
title={t('map.routePlanning.removeFromRoute')}
|
||||
>
|
||||
×</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{routeProjects.length > 1 && (
|
||||
<button
|
||||
onClick={() => calculateRoute(true)}
|
||||
disabled={isCalculatingRoute}
|
||||
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md text-sm font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isCalculatingRoute ? t('map.routePlanning.calculating') : routeProjects.length > 2 ? t('map.routePlanning.findOptimalRoute') : t('map.routePlanning.calculateRoute')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{routeData && (
|
||||
<div className="mt-3 p-2 bg-green-50 border border-green-200 rounded">
|
||||
<div className="text-sm font-medium text-green-800">{t('map.routePlanning.routeCalculated')}</div>
|
||||
<div className="text-xs text-green-600 mt-1">
|
||||
{routeData.distance} • {routeData.duration}
|
||||
{routeData.optimized && <span className="ml-2">✓ {t('map.routePlanning.optimized')}</span>}
|
||||
</div>
|
||||
{routeData.method && (
|
||||
<div className="text-xs text-green-500 mt-1">
|
||||
{t('map.routePlanning.method')}: {routeData.method.replace(/_/g, ' ')}
|
||||
</div>
|
||||
)}
|
||||
{routeData.error && (
|
||||
<div className="text-xs text-red-600 mt-1">
|
||||
{t('map.routePlanning.error')}: {routeData.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{routeProjects.length === 0 && (
|
||||
<div className="text-xs text-gray-500 text-center py-4">
|
||||
{t('map.routePlanning.clickToAddProjects')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Layer Control Panel - Right Side */}
|
||||
<div className="absolute top-4 right-4 z-[1000] flex flex-col gap-3">
|
||||
{/* Action Buttons */}
|
||||
@@ -979,6 +1664,10 @@ function ProjectsMapPageContent() {
|
||||
isMeasuring={isMeasuring}
|
||||
measurementPoints={measurementPoints}
|
||||
onMeasurementClick={addMeasurementPoint}
|
||||
currentTool={currentTool}
|
||||
routeProjects={routeProjects}
|
||||
onProjectClick={handleProjectClick}
|
||||
routeData={routeData}
|
||||
/>
|
||||
</div>
|
||||
)}{" "}
|
||||
|
||||
@@ -186,6 +186,10 @@ export default function EnhancedLeafletMap({
|
||||
isMeasuring = false,
|
||||
measurementPoints = [],
|
||||
onMeasurementClick,
|
||||
currentTool = "move",
|
||||
routeProjects = [],
|
||||
onProjectClick,
|
||||
routeData = null,
|
||||
}) {
|
||||
useEffect(() => {
|
||||
fixLeafletIcons();
|
||||
@@ -292,17 +296,88 @@ export default function EnhancedLeafletMap({
|
||||
}
|
||||
</>
|
||||
)}{" "}
|
||||
{markers.map((marker, index) => (
|
||||
{markers.map((marker, index) => {
|
||||
const isInRoute = routeProjects.some(p => p.project_id === marker.project?.project_id);
|
||||
const isRouteTool = currentTool === "route";
|
||||
|
||||
return (
|
||||
<Marker
|
||||
key={index}
|
||||
position={marker.position}
|
||||
icon={
|
||||
marker.color ? createColoredMarkerIcon(marker.color) : undefined
|
||||
isRouteTool && isInRoute
|
||||
? createColoredMarkerIcon("blue")
|
||||
: marker.color ? createColoredMarkerIcon(marker.color) : undefined
|
||||
}
|
||||
eventHandlers={{
|
||||
click: () => {
|
||||
if (isRouteTool && marker.project && onProjectClick) {
|
||||
onProjectClick(marker.project);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{marker.popup && <Popup>{marker.popup}</Popup>}
|
||||
</Marker>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Route visualization */}
|
||||
{routeData && routeData.geometry && routeData.geometry.length > 0 && (
|
||||
<>
|
||||
<Polyline
|
||||
positions={routeData.geometry}
|
||||
color="blue"
|
||||
weight={4}
|
||||
opacity={0.8}
|
||||
/>
|
||||
{/* Start marker */}
|
||||
{routeData.geometry.length > 0 && (
|
||||
<Marker
|
||||
position={routeData.geometry[0]}
|
||||
icon={L.divIcon({
|
||||
html: '<div style="background-color: green; width: 12px; height: 12px; border-radius: 50%; border: 2px solid white; box-shadow: 0 0 4px rgba(0,0,0,0.3);"></div>',
|
||||
className: 'custom-route-marker',
|
||||
iconSize: [12, 12],
|
||||
iconAnchor: [6, 6]
|
||||
})}
|
||||
>
|
||||
<Popup>Route Start</Popup>
|
||||
</Marker>
|
||||
)}
|
||||
{/* End marker */}
|
||||
{routeData.geometry.length > 1 && (
|
||||
<Marker
|
||||
position={routeData.geometry[routeData.geometry.length - 1]}
|
||||
icon={L.divIcon({
|
||||
html: '<div style="background-color: red; width: 12px; height: 12px; border-radius: 50%; border: 2px solid white; box-shadow: 0 0 4px rgba(0,0,0,0.3);"></div>',
|
||||
className: 'custom-route-marker',
|
||||
iconSize: [12, 12],
|
||||
iconAnchor: [6, 6]
|
||||
})}
|
||||
>
|
||||
<Popup>Route End</Popup>
|
||||
</Marker>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Fallback: straight line route if no geometry */}
|
||||
{routeProjects.length > 1 && (!routeData || !routeData.geometry || routeData.geometry.length === 0) && (
|
||||
<Polyline
|
||||
positions={routeProjects.map(project => {
|
||||
if (project.coordinates) {
|
||||
const [lat, lng] = project.coordinates.split(",").map(coord => parseFloat(coord.trim()));
|
||||
return [lat, lng];
|
||||
}
|
||||
return null;
|
||||
}).filter(pos => pos !== null)}
|
||||
color="blue"
|
||||
weight={4}
|
||||
opacity={0.8}
|
||||
dashArray="10, 10"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Measurement elements */}
|
||||
{isMeasuring && measurementPoints.length > 0 && (
|
||||
|
||||
@@ -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