import { useEffect, useState, useCallback, useRef } from "react"; import { MapContainer, TileLayer, LayersControl, Marker, useMapEvents, useMap } from "react-leaflet"; import L from "leaflet"; import * as Y from "yjs"; import { useTranslation } from "react-i18next"; import type { DayStage } from "@trails-cool/gpx"; import type { YjsState } from "~/lib/use-yjs"; import { baseLayers, overlayLayers } from "@trails-cool/map"; import { parseGpxAsync, extractWaypoints } from "@trails-cool/gpx"; import { isOvernight } from "~/lib/overnight"; import { setOvernight } from "~/lib/overnight"; import { usePois } from "~/lib/use-pois"; import { useProfileDefaults } from "~/lib/use-profile-defaults"; import { snapToPoi } from "~/lib/poi-snap"; import { Z_CURSOR, Z_WAYPOINT, Z_WAYPOINT_HIGHLIGHTED, Z_HIGHLIGHT } from "~/lib/z-index"; import { NoGoAreaLayer } from "./NoGoAreaLayer"; import { ColoredRoute, findSegmentForPoint, type ColorMode } from "./ColoredRoute"; import { RouteInteraction } from "./RouteInteraction"; import { PoiPanel, PoiMarkers } from "./PoiPanel"; import "leaflet/dist/leaflet.css"; function waypointIcon(index: number, overnight?: boolean, highlighted?: boolean): L.DivIcon { const bg = overnight ? "#8B6D3A" : "#2563eb"; const scale = highlighted ? "scale(1.17)" : "scale(1)"; return L.divIcon({ className: "", html: `
${overnight ? "☾" : index + 1}
`, iconSize: [0, 0], }); } function dayLabelIcon(dayNumber: number, distanceKm: string): L.DivIcon { return L.divIcon({ className: "", html: `
Day ${dayNumber} · ${distanceKm} km
`, iconSize: [0, 0], }); } interface WaypointData { lat: number; lon: number; name?: string; overnight: boolean; } 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, overnight: isOvernight(yMap), })); } interface PlannerMapProps { yjs: YjsState; onRouteRequest?: (waypoints: WaypointData[]) => void; onImportError?: (message: string) => void; highlightPosition?: [number, number] | null; highlightedWaypoint?: number | null; days?: DayStage[]; } 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 ( ); } function PoiRefresher({ poiState }: { poiState: ReturnType }) { const map = useMap(); const refreshRef = useRef(poiState.refresh); refreshRef.current = poiState.refresh; useEffect(() => { const refresh = () => { const bounds = map.getBounds(); const zoom = map.getZoom(); refreshRef.current({ south: bounds.getSouth(), west: bounds.getWest(), north: bounds.getNorth(), east: bounds.getEast(), }, zoom); }; map.on("moveend", refresh); // Don't call refresh() immediately — let moveend trigger it return () => { map.off("moveend", refresh); }; }, [map]); // Trigger refresh when categories change (but not on mount) const prevCategories = useRef(poiState.enabledCategories); useEffect(() => { if (prevCategories.current === poiState.enabledCategories) return; prevCategories.current = poiState.enabledCategories; const bounds = map.getBounds(); const zoom = map.getZoom(); poiState.refresh({ south: bounds.getSouth(), west: bounds.getWest(), north: bounds.getNorth(), east: bounds.getEast(), }, zoom); }, [map, poiState.enabledCategories, poiState.refresh]); return null; } export function PlannerMap({ yjs, onRouteRequest, highlightPosition, highlightedWaypoint, onImportError, days }: PlannerMapProps) { const { t } = useTranslation("planner"); const [waypoints, setWaypoints] = useState([]); const poiState = usePois(); useProfileDefaults(yjs, poiState); 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, name?: string) => { const snap = snapToPoi(lat, lng, poiState.pois); yjs.doc.transact(() => { const yMap = new Y.Map(); yMap.set("lat", snap.lat); yMap.set("lon", snap.snapped ? snap.lon : lng); if (snap.name) yMap.set("name", snap.name); else if (name) yMap.set("name", name); // fallback for explicit name if (snap.osmId) yMap.set("osmId", snap.osmId); if (snap.poiTags) yMap.set("poiTags", snap.poiTags); yjs.waypoints.push([yMap]); }, "local"); }, [yjs.doc, yjs.waypoints, poiState.pois], ); const insertWaypointAtSegment = useCallback( (segmentIndex: number, lat: number, lon: number) => { const snap = snapToPoi(lat, lon, poiState.pois); yjs.doc.transact(() => { const yMap = new Y.Map(); yMap.set("lat", snap.lat); yMap.set("lon", snap.snapped ? snap.lon : lon); if (snap.name) yMap.set("name", snap.name); if (snap.osmId) yMap.set("osmId", snap.osmId); if (snap.poiTags) yMap.set("poiTags", snap.poiTags); yjs.waypoints.insert(segmentIndex + 1, [yMap]); }, "local"); }, [yjs.doc, yjs.waypoints, poiState.pois], ); 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 snap = snapToPoi(lat, lng, poiState.pois); const yMap = yjs.waypoints.get(index); if (yMap) { yjs.doc.transact(() => { yMap.set("lat", snap.lat); yMap.set("lon", snap.snapped ? snap.lon : lng); if (snap.snapped && snap.name) { yMap.set("name", snap.name); } else { yMap.delete("name"); } if (snap.osmId) { yMap.set("osmId", snap.osmId); if (snap.poiTags) yMap.set("poiTags", snap.poiTags); } else { yMap.delete("osmId"); yMap.delete("poiTags"); } }, "local"); } }, [yjs.waypoints, yjs.doc, poiState.pois], ); const deleteWaypoint = useCallback( (index: number) => { yjs.doc.transact(() => { yjs.waypoints.delete(index, 1); }, "local"); }, [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); if (wp.name) yMap.set("name", wp.name); if (wp.isDayBreak) yMap.set("overnight", true); 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]); } }, "local"); } catch { onImportError?.(t("importGpxError")); } }, [yjs, t, onImportError]); return (
{draggingOver && (
{t("dropGpxHere")}
)} {baseLayers.map((layer, i) => ( ))} {overlayLayers.map((layer) => ( ))} {} : addWaypoint} suppressRef={suppressMapClickRef} /> {waypoints.map((wp, i) => ( { routeInteractionSuspendedRef.current = true; }, mouseout: () => { if (!waypointDraggingRef.current) { routeInteractionSuspendedRef.current = false; } }, dragstart: () => { waypointDraggingRef.current = true; yjs.undoManager.stopCapturing(); 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); // Middle waypoints: toggle overnight. First/last: delete. if (i > 0 && i < waypoints.length - 1) { setOvernight(yjs, i, !wp.overnight); } else { deleteWaypoint(i); } }, }} /> ))} {/* Day boundary labels on map */} {days && days.length > 1 && days.map((day) => { const wp = waypoints[day.endWaypointIndex]; if (!wp || day.dayNumber === days.length) return null; return ( ); })} {routeCoordinates && routeCoordinates.length >= 2 && ( <> )} {highlightPosition && (
', iconSize: [0, 0], })} /> )} ); }