feat: Add measurement tool with distance calculation and visual markers on the map
This commit is contained in:
@@ -43,18 +43,72 @@ function ProjectsMapPageContent() {
|
|||||||
const [showLayerPanel, setShowLayerPanel] = useState(true);
|
const [showLayerPanel, setShowLayerPanel] = useState(true);
|
||||||
const [currentTool, setCurrentTool] = useState("move"); // Current map tool
|
const [currentTool, setCurrentTool] = useState("move"); // Current map tool
|
||||||
|
|
||||||
|
// Measurement tool state
|
||||||
|
const [isMeasuring, setIsMeasuring] = useState(false);
|
||||||
|
const [measurementPoints, setMeasurementPoints] = useState([]);
|
||||||
|
const [measurementDistance, setMeasurementDistance] = useState(0);
|
||||||
|
const [measurementLine, setMeasurementLine] = useState(null);
|
||||||
|
|
||||||
|
// Measurement tool functions
|
||||||
|
const startMeasurement = () => {
|
||||||
|
setIsMeasuring(true);
|
||||||
|
setMeasurementPoints([]);
|
||||||
|
setMeasurementDistance(0);
|
||||||
|
setMeasurementLine(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopMeasurement = () => {
|
||||||
|
setIsMeasuring(false);
|
||||||
|
setMeasurementPoints([]);
|
||||||
|
setMeasurementDistance(0);
|
||||||
|
setMeasurementLine(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addMeasurementPoint = (latlng) => {
|
||||||
|
const newPoints = [...measurementPoints, latlng];
|
||||||
|
setMeasurementPoints(newPoints);
|
||||||
|
|
||||||
|
if (newPoints.length > 1) {
|
||||||
|
// Calculate distance between last two points
|
||||||
|
const lastPoint = newPoints[newPoints.length - 2];
|
||||||
|
const currentPoint = newPoints[newPoints.length - 1];
|
||||||
|
|
||||||
|
// Calculate distance using Haversine formula
|
||||||
|
const R = 6371; // Earth's radius in kilometers
|
||||||
|
const dLat = (currentPoint.lat - lastPoint.lat) * Math.PI / 180;
|
||||||
|
const dLon = (currentPoint.lng - lastPoint.lng) * Math.PI / 180;
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat/2) * Math.sin(dLat/2) +
|
||||||
|
Math.cos(lastPoint.lat * Math.PI / 180) * Math.cos(currentPoint.lat * Math.PI / 180) *
|
||||||
|
Math.sin(dLon/2) * Math.sin(dLon/2);
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
|
||||||
|
const distance = R * c * 1000; // Convert to meters
|
||||||
|
|
||||||
|
setMeasurementDistance(prev => prev + distance);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearMeasurement = () => {
|
||||||
|
setMeasurementPoints([]);
|
||||||
|
setMeasurementDistance(0);
|
||||||
|
setMeasurementLine(null);
|
||||||
|
};
|
||||||
|
|
||||||
// Wrapper component for the map with proper loading state
|
// Wrapper component for the map with proper loading state
|
||||||
const MapWrapper = React.useMemo(() => {
|
const MapWrapper = React.useMemo(() => {
|
||||||
return function MapWrapperComponent(props) {
|
return function MapWrapperComponent(props) {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<MapLoadingComponent t={t} />}>
|
<Suspense fallback={<MapLoadingComponent t={t} />}>
|
||||||
<DynamicMap {...props} />
|
<DynamicMap
|
||||||
|
{...props}
|
||||||
|
isMeasuring={isMeasuring}
|
||||||
|
measurementPoints={measurementPoints}
|
||||||
|
onMeasurementClick={addMeasurementPoint}
|
||||||
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
}, [t]);
|
}, [t, isMeasuring, measurementPoints, addMeasurementPoint]);
|
||||||
|
|
||||||
// Status configuration with colors and labels
|
|
||||||
const statusConfig = {
|
const statusConfig = {
|
||||||
registered: {
|
registered: {
|
||||||
color: "#6B7280",
|
color: "#6B7280",
|
||||||
@@ -237,6 +291,13 @@ function ProjectsMapPageContent() {
|
|||||||
});
|
});
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
|
// Stop measurement when switching away from measure tool
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentTool !== "measure" && isMeasuring) {
|
||||||
|
stopMeasurement();
|
||||||
|
}
|
||||||
|
}, [currentTool, isMeasuring]);
|
||||||
|
|
||||||
// Convert projects to map markers with filtering
|
// Convert projects to map markers with filtering
|
||||||
const markers = projects
|
const markers = projects
|
||||||
.filter((project) => project.coordinates)
|
.filter((project) => project.coordinates)
|
||||||
@@ -465,7 +526,15 @@ function ProjectsMapPageContent() {
|
|||||||
? "bg-blue-100 text-blue-700"
|
? "bg-blue-100 text-blue-700"
|
||||||
: "text-gray-700 hover:bg-gray-50"
|
: "text-gray-700 hover:bg-gray-50"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setCurrentTool("measure")}
|
onClick={() => {
|
||||||
|
if (currentTool === "measure") {
|
||||||
|
setCurrentTool("move");
|
||||||
|
stopMeasurement();
|
||||||
|
} else {
|
||||||
|
setCurrentTool("measure");
|
||||||
|
startMeasurement();
|
||||||
|
}
|
||||||
|
}}
|
||||||
title={t('map.measureDistance')}
|
title={t('map.measureDistance')}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -746,82 +815,6 @@ function ProjectsMapPageContent() {
|
|||||||
</div>{" "}
|
</div>{" "}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Status Filter Panel - Bottom Left */}
|
|
||||||
<div className="absolute bottom-4 left-4 z-[1000]">
|
|
||||||
<div className="bg-white/95 backdrop-blur-sm rounded-lg shadow-lg px-4 py-3 border border-gray-200">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-medium text-gray-700 mr-2">
|
|
||||||
{t('map.filters')}
|
|
||||||
</span>
|
|
||||||
{/* Toggle All Button */}
|
|
||||||
<button
|
|
||||||
onClick={toggleAllFilters}
|
|
||||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-gray-100 hover:bg-gray-200 transition-colors duration-200 mr-2"
|
|
||||||
title="Toggle all filters"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-3 h-3"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M4 6h16M4 12h16M4 18h16"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span className="text-gray-600">
|
|
||||||
{Object.values(statusFilters).every((v) => v)
|
|
||||||
? "Hide All"
|
|
||||||
: "Show All"}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{/* Individual Status Filters */}
|
|
||||||
{Object.entries(statusConfig).map(([status, config]) => {
|
|
||||||
const isActive = statusFilters[status];
|
|
||||||
const projectCount = projects.filter(
|
|
||||||
(p) => p.project_status === status && p.coordinates
|
|
||||||
).length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={status}
|
|
||||||
onClick={() => toggleStatusFilter(status)}
|
|
||||||
className={`flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-all duration-200 hover:bg-gray-100 ${
|
|
||||||
isActive ? "opacity-100 scale-100" : "opacity-40 scale-95"
|
|
||||||
}`}
|
|
||||||
title={`Toggle ${config.label} (${projectCount} ${t('map.projects')})`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`w-3 h-3 rounded-full border-2 transition-all duration-200 ${
|
|
||||||
isActive ? "border-white shadow-sm" : "border-gray-300"
|
|
||||||
}`}
|
|
||||||
style={{
|
|
||||||
backgroundColor: isActive ? config.color : "#e5e7eb",
|
|
||||||
}}
|
|
||||||
></div>
|
|
||||||
<span
|
|
||||||
className={`transition-colors duration-200 ${
|
|
||||||
isActive ? "text-gray-700" : "text-gray-400"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{config.shortLabel}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={`ml-1 text-xs transition-colors duration-200 ${
|
|
||||||
isActive ? "text-gray-500" : "text-gray-300"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
({projectCount})
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}{" "}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>{" "}
|
|
||||||
{/* Status Panel - Bottom Left */}
|
{/* Status Panel - Bottom Left */}
|
||||||
{markers.length > 0 && (
|
{markers.length > 0 && (
|
||||||
<div className="bg-white/95 backdrop-blur-sm rounded-lg shadow-lg px-4 py-3 border border-gray-200">
|
<div className="bg-white/95 backdrop-blur-sm rounded-lg shadow-lg px-4 py-3 border border-gray-200">
|
||||||
@@ -899,6 +892,58 @@ function ProjectsMapPageContent() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Measurement Display Panel */}
|
||||||
|
{isMeasuring && (
|
||||||
|
<div className="absolute bottom-20 left-4 z-[1000]">
|
||||||
|
<div className="bg-white/95 backdrop-blur-sm rounded-lg shadow-lg px-4 py-3 border border-gray-200 min-w-64">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900">
|
||||||
|
{t('map.measureDistance')}
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{measurementPoints.length} {t('map.points')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-gray-700">
|
||||||
|
{t('map.totalDistance')}:
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-mono font-semibold text-blue-600">
|
||||||
|
{measurementDistance > 1000
|
||||||
|
? `${(measurementDistance / 1000).toFixed(2)} km`
|
||||||
|
: `${measurementDistance.toFixed(1)} m`
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{measurementPoints.length > 0 && (
|
||||||
|
<div className="text-xs text-gray-600">
|
||||||
|
{t('map.clickToAddPoints')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-2 border-t border-gray-200">
|
||||||
|
<button
|
||||||
|
onClick={clearMeasurement}
|
||||||
|
className="flex-1 px-3 py-1 text-xs bg-gray-100 hover:bg-gray-200 rounded transition-colors duration-200"
|
||||||
|
disabled={measurementPoints.length === 0}
|
||||||
|
>
|
||||||
|
{t('map.clear')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentTool("move")}
|
||||||
|
className="flex-1 px-3 py-1 text-xs bg-blue-500 hover:bg-blue-600 text-white rounded transition-colors duration-200"
|
||||||
|
>
|
||||||
|
{t('map.finish')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}{" "}
|
)}{" "}
|
||||||
{/* Full Screen Map */}
|
{/* Full Screen Map */}
|
||||||
{markers.length === 0 ? (
|
{markers.length === 0 ? (
|
||||||
@@ -943,6 +988,9 @@ function ProjectsMapPageContent() {
|
|||||||
defaultLayer={activeBaseLayer}
|
defaultLayer={activeBaseLayer}
|
||||||
activeOverlays={activeOverlays}
|
activeOverlays={activeOverlays}
|
||||||
onViewChange={handleMapViewChange}
|
onViewChange={handleMapViewChange}
|
||||||
|
isMeasuring={isMeasuring}
|
||||||
|
measurementPoints={measurementPoints}
|
||||||
|
onMeasurementClick={addMeasurementPoint}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}{" "}
|
)}{" "}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
LayersControl,
|
LayersControl,
|
||||||
useMapEvents,
|
useMapEvents,
|
||||||
useMap,
|
useMap,
|
||||||
|
Polyline,
|
||||||
} from "react-leaflet";
|
} from "react-leaflet";
|
||||||
import L from "leaflet";
|
import L from "leaflet";
|
||||||
import "leaflet/dist/leaflet.css";
|
import "leaflet/dist/leaflet.css";
|
||||||
@@ -84,6 +85,32 @@ const createColoredMarkerIcon = (color) => {
|
|||||||
} return null;
|
} return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Create numbered measurement marker icons
|
||||||
|
const createMeasurementMarkerIcon = (number) => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
return new L.DivIcon({
|
||||||
|
html: `<div style="
|
||||||
|
background-color: #ef4444;
|
||||||
|
color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 12px;
|
||||||
|
border: 2px solid white;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||||
|
">${number}</div>`,
|
||||||
|
className: 'custom-measurement-marker',
|
||||||
|
iconSize: [24, 24],
|
||||||
|
iconAnchor: [12, 12],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
// Component to handle map events
|
// Component to handle map events
|
||||||
function MapEventHandler({ onViewChange }) {
|
function MapEventHandler({ onViewChange }) {
|
||||||
const map = useMapEvents({
|
const map = useMapEvents({
|
||||||
@@ -106,6 +133,19 @@ function MapEventHandler({ onViewChange }) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Component to handle measurement events
|
||||||
|
function MeasurementHandler({ isMeasuring, onMeasurementClick, measurementPoints }) {
|
||||||
|
const map = useMapEvents({
|
||||||
|
click: (e) => {
|
||||||
|
if (isMeasuring && onMeasurementClick) {
|
||||||
|
onMeasurementClick(e.latlng);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Custom zoom control component that handles external events
|
// Custom zoom control component that handles external events
|
||||||
function CustomZoomHandler() {
|
function CustomZoomHandler() {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
@@ -143,6 +183,9 @@ export default function EnhancedLeafletMap({
|
|||||||
activeOverlays = [],
|
activeOverlays = [],
|
||||||
onViewChange,
|
onViewChange,
|
||||||
showOverlays = true,
|
showOverlays = true,
|
||||||
|
isMeasuring = false,
|
||||||
|
measurementPoints = [],
|
||||||
|
onMeasurementClick,
|
||||||
}) {
|
}) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fixLeafletIcons();
|
fixLeafletIcons();
|
||||||
@@ -157,6 +200,11 @@ export default function EnhancedLeafletMap({
|
|||||||
>
|
>
|
||||||
<CustomZoomHandler />
|
<CustomZoomHandler />
|
||||||
{onViewChange && <MapEventHandler onViewChange={onViewChange} />}
|
{onViewChange && <MapEventHandler onViewChange={onViewChange} />}
|
||||||
|
{isMeasuring && <MeasurementHandler
|
||||||
|
isMeasuring={isMeasuring}
|
||||||
|
onMeasurementClick={onMeasurementClick}
|
||||||
|
measurementPoints={measurementPoints}
|
||||||
|
/>}
|
||||||
|
|
||||||
{showLayerControl ? (
|
{showLayerControl ? (
|
||||||
<LayersControl position="topright">
|
<LayersControl position="topright">
|
||||||
@@ -255,6 +303,39 @@ export default function EnhancedLeafletMap({
|
|||||||
{marker.popup && <Popup>{marker.popup}</Popup>}
|
{marker.popup && <Popup>{marker.popup}</Popup>}
|
||||||
</Marker>
|
</Marker>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Measurement elements */}
|
||||||
|
{isMeasuring && measurementPoints.length > 0 && (
|
||||||
|
<>
|
||||||
|
{/* Measurement line */}
|
||||||
|
{measurementPoints.length > 1 && (
|
||||||
|
<Polyline
|
||||||
|
positions={measurementPoints}
|
||||||
|
color="red"
|
||||||
|
weight={3}
|
||||||
|
opacity={0.8}
|
||||||
|
dashArray="10, 10"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Measurement point markers */}
|
||||||
|
{measurementPoints.map((point, index) => (
|
||||||
|
<Marker
|
||||||
|
key={`measurement-${index}`}
|
||||||
|
position={point}
|
||||||
|
icon={createMeasurementMarkerIcon(index + 1)}
|
||||||
|
>
|
||||||
|
<Popup>
|
||||||
|
<div className="text-sm">
|
||||||
|
<strong>Point {index + 1}</strong><br />
|
||||||
|
Lat: {point.lat.toFixed(6)}<br />
|
||||||
|
Lng: {point.lng.toFixed(6)}
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user