feat: Add measurement tool with distance calculation and visual markers on the map

This commit is contained in:
2025-09-16 16:41:08 +02:00
parent e4a4261a0e
commit 0bb0b07429
2 changed files with 210 additions and 81 deletions

View File

@@ -43,18 +43,72 @@ function ProjectsMapPageContent() {
const [showLayerPanel, setShowLayerPanel] = useState(true);
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
const MapWrapper = React.useMemo(() => {
return function MapWrapperComponent(props) {
return (
<Suspense fallback={<MapLoadingComponent t={t} />}>
<DynamicMap {...props} />
<DynamicMap
{...props}
isMeasuring={isMeasuring}
measurementPoints={measurementPoints}
onMeasurementClick={addMeasurementPoint}
/>
</Suspense>
);
};
}, [t]);
// Status configuration with colors and labels
}, [t, isMeasuring, measurementPoints, addMeasurementPoint]);
const statusConfig = {
registered: {
color: "#6B7280",
@@ -237,6 +291,13 @@ function ProjectsMapPageContent() {
});
}, [searchParams]);
// Stop measurement when switching away from measure tool
useEffect(() => {
if (currentTool !== "measure" && isMeasuring) {
stopMeasurement();
}
}, [currentTool, isMeasuring]);
// Convert projects to map markers with filtering
const markers = projects
.filter((project) => project.coordinates)
@@ -465,7 +526,15 @@ function ProjectsMapPageContent() {
? "bg-blue-100 text-blue-700"
: "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')}
>
<svg
@@ -746,82 +815,6 @@ function ProjectsMapPageContent() {
</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 */}
{markers.length > 0 && (
<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>
)}
{/* 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 */}
{markers.length === 0 ? (
@@ -943,6 +988,9 @@ function ProjectsMapPageContent() {
defaultLayer={activeBaseLayer}
activeOverlays={activeOverlays}
onViewChange={handleMapViewChange}
isMeasuring={isMeasuring}
measurementPoints={measurementPoints}
onMeasurementClick={addMeasurementPoint}
/>
</div>
)}{" "}