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"
|
"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": {
|
||||||
|
|||||||
@@ -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>
|
||||||
)}{" "}
|
)}{" "}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
const isInRoute = routeProjects.some(p => p.project_id === marker.project?.project_id);
|
||||||
|
const isRouteTool = currentTool === "route";
|
||||||
|
|
||||||
|
return (
|
||||||
<Marker
|
<Marker
|
||||||
key={index}
|
key={index}
|
||||||
position={marker.position}
|
position={marker.position}
|
||||||
icon={
|
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.popup && <Popup>{marker.popup}</Popup>}
|
||||||
</Marker>
|
</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 && (
|
||||||
|
|||||||
@@ -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
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