import { useEffect, useRef, useCallback } from "react"; import { useMap } from "react-leaflet"; import L from "leaflet"; interface RouteInteractionProps { coordinates: [number, number, number][]; // [lon, lat, ele] segmentBoundaries: number[]; onInsertWaypoint: (pointIndex: number, lat: number, lon: number) => void; disabled?: boolean; } const SNAP_TOLERANCE_PX = 15; const ghostIcon = L.divIcon({ className: "route-ghost-marker", html: `
`, iconSize: [0, 0], }); /** * Route interaction layer: shows a ghost marker on hover that can be * clicked or dragged to insert a waypoint. Follows brouter-web's pattern * of a single persistent draggable Marker with distance-based mouseout. */ export function RouteInteraction({ coordinates, segmentBoundaries, onInsertWaypoint, disabled, }: RouteInteractionProps) { const map = useMap(); const markerRef = useRef(null); const draggingRef = useRef(false); const snappedIdxRef = useRef(0); const coordinatesRef = useRef(coordinates); const segBoundsRef = useRef(segmentBoundaries); const onInsertRef = useRef(onInsertWaypoint); coordinatesRef.current = coordinates; segBoundsRef.current = segmentBoundaries; onInsertRef.current = onInsertWaypoint; // Trailer lines (dashed lines from ghost to adjacent waypoints) const trailer1Ref = useRef(null); const trailer2Ref = useRef(null); const findClosestOnRoute = useCallback((latlng: L.LatLng): { idx: number; lat: number; lon: number; distPx: number } | null => { const coords = coordinatesRef.current; if (coords.length < 2) return null; let minDistPx = Infinity; let bestIdx = 0; let bestLat = 0; let bestLon = 0; // Find closest point on route in pixel space for accuracy const clickPt = map.latLngToContainerPoint(latlng); for (let i = 0; i < coords.length; i++) { const c = coords[i]!; const pt = map.latLngToContainerPoint([c[1], c[0]]); const dx = pt.x - clickPt.x; const dy = pt.y - clickPt.y; const dist = Math.sqrt(dx * dx + dy * dy); if (dist < minDistPx) { minDistPx = dist; bestIdx = i; bestLat = c[1]; bestLon = c[0]; } } return { idx: bestIdx, lat: bestLat, lon: bestLon, distPx: minDistPx }; }, [map]); useEffect(() => { if (disabled) return; // Create persistent ghost marker (hidden until hover) const marker = L.marker([0, 0], { icon: ghostIcon, draggable: true, interactive: true, zIndexOffset: 1000, }); markerRef.current = marker; // Trailer lines const trailer1 = L.polyline([], { color: "#2563eb", weight: 2, dashArray: "6,8", opacity: 0 }).addTo(map); const trailer2 = L.polyline([], { color: "#2563eb", weight: 2, dashArray: "6,8", opacity: 0 }).addTo(map); trailer1Ref.current = trailer1; trailer2Ref.current = trailer2; let shown = false; const showMarker = (lat: number, lon: number) => { marker.setLatLng([lat, lon]); if (!shown) { marker.addTo(map); shown = true; } }; const hideMarker = () => { if (shown && !draggingRef.current) { marker.remove(); shown = false; } }; // Listen for mousemove on the map (distance-based approach, not polyline events) const onMouseMove = (e: L.LeafletMouseEvent) => { if (draggingRef.current) return; const snap = findClosestOnRoute(e.latlng); if (!snap || snap.distPx > SNAP_TOLERANCE_PX) { hideMarker(); return; } snappedIdxRef.current = snap.idx; showMarker(snap.lat, snap.lon); }; const onMouseOut = () => { hideMarker(); }; // Drag events on the ghost marker (Leaflet's L.Draggable handles text selection) marker.on("dragstart", () => { draggingRef.current = true; trailer1.setStyle({ opacity: 0.6 }); trailer2.setStyle({ opacity: 0.6 }); }); marker.on("drag", (e) => { const latlng = (e.target as L.Marker).getLatLng(); // Update trailer lines to adjacent waypoints const idx = snappedIdxRef.current; const bounds = segBoundsRef.current; const coords = coordinatesRef.current; // Find which segment this point is in let segIdx = 0; for (let i = bounds.length - 1; i >= 0; i--) { if (idx >= bounds[i]!) { segIdx = i; break; } } // Trailer to previous waypoint (segment start) if (segIdx >= 0) { const startCoordIdx = bounds[segIdx]!; const c = coords[startCoordIdx]; if (c) trailer1.setLatLngs([latlng, [c[1], c[0]]]); } // Trailer to next waypoint (next segment start or end) const nextStart = segIdx + 1 < bounds.length ? bounds[segIdx + 1]! : coords.length - 1; const nc = coords[nextStart]; if (nc) trailer2.setLatLngs([latlng, [nc[1], nc[0]]]); }); marker.on("dragend", (e) => { const latlng = (e.target as L.Marker).getLatLng(); draggingRef.current = false; trailer1.setStyle({ opacity: 0 }); trailer2.setStyle({ opacity: 0 }); marker.remove(); shown = false; onInsertRef.current(snappedIdxRef.current, latlng.lat, latlng.lng); }); // Click on ghost marker inserts at snapped position marker.on("click", () => { if (draggingRef.current) return; const latlng = marker.getLatLng(); marker.remove(); shown = false; onInsertRef.current(snappedIdxRef.current, latlng.lat, latlng.lng); }); // Prevent double-click zoom when clicking ghost marker marker.on("dblclick", (e) => { L.DomEvent.stop(e as unknown as Event); }); map.on("mousemove", onMouseMove); map.on("mouseout", onMouseOut); return () => { map.off("mousemove", onMouseMove); map.off("mouseout", onMouseOut); if (shown) marker.remove(); trailer1.remove(); trailer2.remove(); markerRef.current = null; }; }, [map, disabled, findClosestOnRoute]); return null; }