120 lines
3.1 KiB
TypeScript
120 lines
3.1 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { useMap, Polyline, Marker } from 'react-leaflet';
|
|
import L from 'leaflet';
|
|
|
|
interface MeasurementToolProps {
|
|
enabled: boolean;
|
|
onComplete?: (distanceKm: number) => void;
|
|
}
|
|
|
|
function haversineKm(
|
|
lat1: number,
|
|
lon1: number,
|
|
lat2: number,
|
|
lon2: number
|
|
): number {
|
|
const R = 6371;
|
|
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
|
const dLon = ((lon2 - lon1) * Math.PI) / 180;
|
|
const a =
|
|
Math.sin(dLat / 2) ** 2 +
|
|
Math.cos((lat1 * Math.PI) / 180) *
|
|
Math.cos((lat2 * Math.PI) / 180) *
|
|
Math.sin(dLon / 2) ** 2;
|
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
}
|
|
|
|
function totalDistance(pts: [number, number][]): number {
|
|
let total = 0;
|
|
for (let i = 1; i < pts.length; i++) {
|
|
total += haversineKm(pts[i - 1][0], pts[i - 1][1], pts[i][0], pts[i][1]);
|
|
}
|
|
return total;
|
|
}
|
|
|
|
const dotIcon = L.divIcon({
|
|
className: '',
|
|
iconSize: [10, 10],
|
|
iconAnchor: [5, 5],
|
|
html: '<div style="width:10px;height:10px;background:white;border:2px solid #333;border-radius:50%;"></div>',
|
|
});
|
|
|
|
export default function MeasurementTool({ enabled, onComplete }: MeasurementToolProps) {
|
|
const map = useMap();
|
|
const [points, setPoints] = useState<[number, number][]>([]);
|
|
const pointsRef = useRef(points);
|
|
pointsRef.current = points;
|
|
|
|
// Clear on disable
|
|
useEffect(() => {
|
|
if (!enabled) {
|
|
setPoints([]);
|
|
}
|
|
}, [enabled]);
|
|
|
|
// Click handler: add measurement point
|
|
useEffect(() => {
|
|
if (!enabled) return;
|
|
|
|
const handleClick = (e: L.LeafletMouseEvent) => {
|
|
setPoints((prev) => [...prev, [e.latlng.lat, e.latlng.lng]]);
|
|
};
|
|
|
|
const handleRightClick = (e: L.LeafletMouseEvent) => {
|
|
L.DomEvent.preventDefault(e.originalEvent);
|
|
const pts = pointsRef.current;
|
|
if (pts.length >= 2 && onComplete) {
|
|
onComplete(totalDistance(pts));
|
|
}
|
|
setPoints([]);
|
|
};
|
|
|
|
map.on('click', handleClick);
|
|
map.on('contextmenu', handleRightClick);
|
|
|
|
return () => {
|
|
map.off('click', handleClick);
|
|
map.off('contextmenu', handleRightClick);
|
|
};
|
|
}, [map, enabled, onComplete]);
|
|
|
|
if (!enabled || points.length === 0) return null;
|
|
|
|
const dist = totalDistance(points);
|
|
|
|
return (
|
|
<>
|
|
{points.length >= 2 && (
|
|
<Polyline
|
|
positions={points}
|
|
pathOptions={{ color: '#00ff00', weight: 3, dashArray: '10, 5' }}
|
|
/>
|
|
)}
|
|
{points.map((pos, idx) => (
|
|
<Marker key={idx} position={pos} icon={dotIcon} />
|
|
))}
|
|
{dist > 0 && (
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
top: '10px',
|
|
left: '50%',
|
|
transform: 'translateX(-50%)',
|
|
background: 'rgba(0,0,0,0.8)',
|
|
color: 'white',
|
|
padding: '6px 14px',
|
|
borderRadius: '6px',
|
|
zIndex: 2000,
|
|
pointerEvents: 'none',
|
|
fontSize: '13px',
|
|
fontWeight: 600,
|
|
letterSpacing: '0.3px',
|
|
}}
|
|
>
|
|
Distance: {dist.toFixed(2)} km ({(dist * 1000).toFixed(0)} m)
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|