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

2781
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,9 +16,14 @@
"test:e2e:ui": "playwright test --ui" "test:e2e:ui": "playwright test --ui"
}, },
"dependencies": { "dependencies": {
"@mapbox/polyline": "^1.2.1",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"better-sqlite3": "^11.10.0", "better-sqlite3": "^11.10.0",
"date-fns": "^4.1.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", "leaflet": "^1.9.4",
"next": "15.1.8", "next": "15.1.8",
"next-auth": "^5.0.0-beta.29", "next-auth": "^5.0.0-beta.29",
@@ -29,6 +34,7 @@
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
"recharts": "^2.15.3", "recharts": "^2.15.3",
"xlsx": "^0.18.5",
"zod": "^3.25.67" "zod": "^3.25.67"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -8,6 +8,16 @@ import { useTranslation } from "@/lib/i18n";
import Button from "@/components/ui/Button"; import Button from "@/components/ui/Button";
import { mapLayers } from "@/components/ui/mapLayers"; import { mapLayers } from "@/components/ui/mapLayers";
import { formatProjectStatus } from "@/lib/utils"; import { formatProjectStatus } from "@/lib/utils";
import {
calculateRouteForCoordinates,
optimizeRoute,
extractOptimizedOrder,
detectOrderChange,
optimizeRouteLocally,
decodeRouteGeometry,
formatDuration,
formatDistance
} from "@/lib/routeUtils";
// Loading component that can access translations // Loading component that can access translations
function MapLoadingComponent({ t }) { function MapLoadingComponent({ t }) {
@@ -49,6 +59,20 @@ function ProjectsMapPageContent() {
const [measurementDistance, setMeasurementDistance] = useState(0); const [measurementDistance, setMeasurementDistance] = useState(0);
const [measurementLine, setMeasurementLine] = useState(null); 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 // Measurement tool functions
const startMeasurement = () => { const startMeasurement = () => {
setIsMeasuring(true); setIsMeasuring(true);
@@ -188,6 +212,421 @@ function ProjectsMapPageContent() {
}, 500); // Wait 500ms after the last move to update URL }, 500); // Wait 500ms after the last move to update URL
}, [updateURL]); }, [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 // Hide navigation and ensure full-screen layout
useEffect(() => { useEffect(() => {
// Check for URL parameters for coordinates and zoom // Check for URL parameters for coordinates and zoom
@@ -303,6 +742,7 @@ function ProjectsMapPageContent() {
return { return {
position: [lat, lng], position: [lat, lng],
color: statusInfo.color, color: statusInfo.color,
project: project, // Add project data for route planning
popup: ( popup: (
<div className="min-w-72 max-w-80"> <div className="min-w-72 max-w-80">
<div className="mb-3 pb-2 border-b border-gray-200"> <div className="mb-3 pb-2 border-b border-gray-200">
@@ -617,8 +1057,253 @@ function ProjectsMapPageContent() {
/> />
</svg> </svg>
</button> </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>
</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 */} {/* Layer Control Panel - Right Side */}
<div className="absolute top-4 right-4 z-[1000] flex flex-col gap-3"> <div className="absolute top-4 right-4 z-[1000] flex flex-col gap-3">
{/* Action Buttons */} {/* Action Buttons */}
@@ -979,6 +1664,10 @@ function ProjectsMapPageContent() {
isMeasuring={isMeasuring} isMeasuring={isMeasuring}
measurementPoints={measurementPoints} measurementPoints={measurementPoints}
onMeasurementClick={addMeasurementPoint} onMeasurementClick={addMeasurementPoint}
currentTool={currentTool}
routeProjects={routeProjects}
onProjectClick={handleProjectClick}
routeData={routeData}
/> />
</div> </div>
)}{" "} )}{" "}

View File

@@ -186,6 +186,10 @@ export default function EnhancedLeafletMap({
isMeasuring = false, isMeasuring = false,
measurementPoints = [], measurementPoints = [],
onMeasurementClick, onMeasurementClick,
currentTool = "move",
routeProjects = [],
onProjectClick,
routeData = null,
}) { }) {
useEffect(() => { useEffect(() => {
fixLeafletIcons(); fixLeafletIcons();
@@ -292,17 +296,88 @@ export default function EnhancedLeafletMap({
} }
</> </>
)}{" "} )}{" "}
{markers.map((marker, index) => ( {markers.map((marker, index) => {
<Marker const isInRoute = routeProjects.some(p => p.project_id === marker.project?.project_id);
key={index} const isRouteTool = currentTool === "route";
position={marker.position}
icon={ return (
marker.color ? createColoredMarkerIcon(marker.color) : undefined <Marker
} key={index}
> position={marker.position}
{marker.popup && <Popup>{marker.popup}</Popup>} icon={
</Marker> 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 */} {/* Measurement elements */}
{isMeasuring && measurementPoints.length > 0 && ( {isMeasuring && measurementPoints.length > 0 && (

View File

@@ -307,7 +307,34 @@ const translations = {
viewAllProjects: "Zobacz wszystkie projekty", viewAllProjects: "Zobacz wszystkie projekty",
zoomIn: "Przybliż", zoomIn: "Przybliż",
zoomOut: "Oddal", 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 // Tasks
@@ -753,7 +780,34 @@ const translations = {
viewAllProjects: "View All Projects", viewAllProjects: "View All Projects",
zoomIn: "Zoom In", zoomIn: "Zoom In",
zoomOut: "Zoom Out", 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: { 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`;
}