import { useEffect, useState, useCallback, useRef } from "react"; import { MapContainer, TileLayer, LayersControl, Marker, CircleMarker, useMapEvents, useMap } from "react-leaflet"; import L from "leaflet"; import * as Y from "yjs"; import { useTranslation } from "react-i18next"; import type { YjsState } from "~/lib/use-yjs"; import { baseLayers } from "@trails-cool/map"; import { parseGpxAsync, extractWaypoints } from "@trails-cool/gpx"; import { NoGoAreaLayer } from "./NoGoAreaLayer"; import { ColoredRoute, findSegmentForPoint, type ColorMode } from "./ColoredRoute"; import { RouteInteraction } from "./RouteInteraction"; import "leaflet/dist/leaflet.css"; function waypointIcon(index: number): L.DivIcon { return L.divIcon({ className: "", html: `
${index + 1}
`, iconSize: [0, 0], }); } interface WaypointData { lat: number; lon: number; name?: string; } function getWaypointsFromYjs(waypoints: Y.Array>): WaypointData[] { return waypoints.toArray().map((yMap) => ({ lat: yMap.get("lat") as number, lon: yMap.get("lon") as number, name: yMap.get("name") as string | undefined, })); } interface PlannerMapProps { yjs: YjsState; onRouteRequest?: (waypoints: WaypointData[]) => void; onImportError?: (message: string) => void; highlightPosition?: [number, number] | null; } function MapExposer() { const map = useMap(); useEffect(() => { if (typeof window !== "undefined") { (window as unknown as Record).__leafletMap = map; } }, [map]); return null; } function RouteFitter({ coordinates }: { coordinates: [number, number, number][] | null }) { const map = useMap(); const hasFitted = useRef(false); useEffect(() => { if (hasFitted.current || !coordinates || coordinates.length < 2) return; // Coordinates are in [lon, lat, elevation] GeoJSON format const bounds = L.latLngBounds( coordinates.map((c) => [c[1]!, c[0]!] as [number, number]), ); if (!bounds.isValid()) return; // Delay fitBounds so the layout has settled (elevation chart may resize the map) const raf = requestAnimationFrame(() => { map.invalidateSize(); map.fitBounds(bounds, { padding: [50, 50], maxZoom: 16 }); hasFitted.current = true; }); return () => cancelAnimationFrame(raf); }, [coordinates, map]); return null; } function MapClickHandler({ onAdd, suppressRef }: { onAdd: (lat: number, lng: number) => void; suppressRef: React.RefObject }) { useMapEvents({ click(e) { if (suppressRef.current) { suppressRef.current = false; return; } onAdd(e.latlng.lat, e.latlng.lng); }, }); return null; } function CursorTracker({ awareness }: { awareness: YjsState["awareness"] }) { const map = useMap(); const [cursors, setCursors] = useState>(new Map()); useEffect(() => { const localId = awareness.clientID; const handleMouseMove = (e: L.LeafletMouseEvent) => { awareness.setLocalStateField("cursor", { lat: e.latlng.lat, lng: e.latlng.lng, }); }; const handleMouseOut = () => { awareness.setLocalStateField("cursor", null); }; map.on("mousemove", handleMouseMove); map.on("mouseout", handleMouseOut); const updateCursors = () => { const states = awareness.getStates(); const newCursors = new Map(); states.forEach((state, clientId) => { if (clientId !== localId && state.cursor && state.user) { newCursors.set(clientId, { lat: state.cursor.lat, lng: state.cursor.lng, color: state.user.color, name: state.user.name, }); } }); setCursors(newCursors); }; awareness.on("change", updateCursors); return () => { map.off("mousemove", handleMouseMove); map.off("mouseout", handleMouseOut); awareness.off("change", updateCursors); }; }, [map, awareness]); return ( <> {Array.from(cursors.entries()).map(([clientId, cursor]) => ( ${cursor.name} `, iconSize: [0, 0], })} /> ))} ); } function NoGoAreaButton({ active, onClick }: { active: boolean; onClick: () => void }) { const ref = useRef(null); // Prevent clicks from reaching the Leaflet map (same as built-in controls) useEffect(() => { if (ref.current) L.DomEvent.disableClickPropagation(ref.current); }, []); return ( ); } export function PlannerMap({ yjs, onRouteRequest, highlightPosition, onImportError }: PlannerMapProps) { const { t } = useTranslation("planner"); const [waypoints, setWaypoints] = useState([]); const [draggingOver, setDraggingOver] = useState(false); const dragCounterRef = useRef(0); const [routeCoordinates, setRouteCoordinates] = useState<[number, number, number][] | null>(null); const [segmentBoundaries, setSegmentBoundaries] = useState([]); const [surfaces, setSurfaces] = useState([]); const [colorMode, setColorMode] = useState("plain"); const [noGoDrawing, setNoGoDrawing] = useState(false); const toggleNoGoDraw = useCallback(() => setNoGoDrawing((v) => !v), []); const suppressMapClickRef = useRef(false); const routeInteractionSuspendedRef = useRef(false); const waypointDraggingRef = useRef(false); // Sync waypoints from Yjs useEffect(() => { const update = () => { const wps = getWaypointsFromYjs(yjs.waypoints); setWaypoints(wps); if (wps.length >= 2 && onRouteRequest) { onRouteRequest(wps); } }; yjs.waypoints.observeDeep(update); update(); return () => { yjs.waypoints.unobserveDeep(update); }; }, [yjs.waypoints, onRouteRequest]); // Sync route data from Yjs (enriched: coordinates + segment boundaries) useEffect(() => { const update = () => { const coordsJson = yjs.routeData.get("coordinates") as string | undefined; const boundsJson = yjs.routeData.get("segmentBoundaries") as string | undefined; const modeVal = yjs.routeData.get("colorMode") as ColorMode | undefined; if (coordsJson) { try { setRouteCoordinates(JSON.parse(coordsJson)); } catch { setRouteCoordinates(null); } } else { // Fallback: parse from geojson for backwards compat const geojson = yjs.routeData.get("geojson") as string | undefined; if (geojson) { try { const parsed = JSON.parse(geojson); const coords = parsed.features?.[0]?.geometry?.coordinates; if (coords) { setRouteCoordinates(coords.map((c: number[]) => [c[0]!, c[1]!, c[2] ?? 0] as [number, number, number])); } } catch { setRouteCoordinates(null); } } else { setRouteCoordinates(null); } } if (boundsJson) { try { setSegmentBoundaries(JSON.parse(boundsJson)); } catch { setSegmentBoundaries([]); } } else { setSegmentBoundaries([]); } const surfacesJson = yjs.routeData.get("surfaces") as string | undefined; if (surfacesJson) { try { setSurfaces(JSON.parse(surfacesJson)); } catch { setSurfaces([]); } } else { setSurfaces([]); } if (modeVal) setColorMode(modeVal); }; yjs.routeData.observe(update); update(); return () => { yjs.routeData.unobserve(update); }; }, [yjs.routeData]); const addWaypoint = useCallback( (lat: number, lng: number) => { const yMap = new Y.Map(); yMap.set("lat", lat); yMap.set("lon", lng); yjs.waypoints.push([yMap]); }, [yjs.waypoints], ); const insertWaypointAtSegment = useCallback( (segmentIndex: number, lat: number, lon: number) => { const yMap = new Y.Map(); yMap.set("lat", lat); yMap.set("lon", lon); // Insert after the segment's start waypoint yjs.waypoints.insert(segmentIndex + 1, [yMap]); }, [yjs.waypoints], ); const handleRouteInsert = useCallback( (pointIndex: number, lat: number, lon: number) => { suppressMapClickRef.current = true; const segIdx = findSegmentForPoint(pointIndex, segmentBoundaries); insertWaypointAtSegment(segIdx, lat, lon); }, [segmentBoundaries, insertWaypointAtSegment], ); const moveWaypoint = useCallback( (index: number, lat: number, lng: number) => { const yMap = yjs.waypoints.get(index); if (yMap) { yjs.doc.transact(() => { yMap.set("lat", lat); yMap.set("lon", lng); }); } }, [yjs.waypoints, yjs.doc], ); const deleteWaypoint = useCallback( (index: number) => { yjs.waypoints.delete(index, 1); }, [yjs.waypoints], ); const handleDragEnter = useCallback((e: React.DragEvent) => { e.preventDefault(); dragCounterRef.current++; if (dragCounterRef.current === 1) setDraggingOver(true); }, []); const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); dragCounterRef.current--; if (dragCounterRef.current === 0) setDraggingOver(false); }, []); const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); }, []); const handleDrop = useCallback(async (e: React.DragEvent) => { e.preventDefault(); dragCounterRef.current = 0; setDraggingOver(false); const file = e.dataTransfer.files[0]; if (!file) return; if (!file.name.toLowerCase().endsWith(".gpx")) { onImportError?.(t("importGpxError")); return; } try { const text = await file.text(); const gpxData = await parseGpxAsync(text); const newWaypoints = extractWaypoints(gpxData); if (newWaypoints.length < 2) return; if (!window.confirm(t("replaceRouteConfirm"))) return; yjs.doc.transact(() => { // Replace waypoints yjs.waypoints.delete(0, yjs.waypoints.length); for (const wp of newWaypoints) { const yMap = new Y.Map(); yMap.set("lat", wp.lat); yMap.set("lon", wp.lon); yjs.waypoints.push([yMap]); } // Replace no-go areas yjs.noGoAreas.delete(0, yjs.noGoAreas.length); for (const area of gpxData.noGoAreas) { const yMap = new Y.Map(); yMap.set("points", area.points); yjs.noGoAreas.push([yMap]); } }); } catch { onImportError?.(t("importGpxError")); } }, [yjs, t, onImportError]); return (
{draggingOver && (
{t("dropGpxHere")}
)} {baseLayers.map((layer, i) => ( ))} {} : addWaypoint} suppressRef={suppressMapClickRef} /> {waypoints.map((wp, i) => ( { routeInteractionSuspendedRef.current = true; }, mouseout: () => { if (!waypointDraggingRef.current) { routeInteractionSuspendedRef.current = false; } }, dragstart: () => { waypointDraggingRef.current = true; routeInteractionSuspendedRef.current = true; }, dragend: (e) => { waypointDraggingRef.current = false; routeInteractionSuspendedRef.current = false; const { lat, lng } = e.target.getLatLng(); moveWaypoint(i, lat, lng); }, contextmenu: (e) => { L.DomEvent.preventDefault(e as unknown as Event); deleteWaypoint(i); }, }} /> ))} {routeCoordinates && routeCoordinates.length >= 2 && ( <> )} {highlightPosition && ( )}
); }