import { useEffect, useState, useCallback, useRef } from "react"; import { MapContainer, TileLayer, LayersControl, Marker, Polyline, 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 { useYjsPoiSync } from "~/lib/use-yjs-poi-sync"; import { snapToPoi } from "~/lib/poi-snap"; import { Z_CURSOR, Z_WAYPOINT, Z_WAYPOINT_HIGHLIGHTED, Z_HIGHLIGHT } from "@trails-cool/map-core"; import { NoGoAreaLayer } from "./NoGoAreaLayer"; import { ColoredRoute, findSegmentForPoint, type ColorMode } from "./ColoredRoute"; import { RouteInteraction } from "./RouteInteraction"; import { PoiPanel, PoiMarkers } from "./PoiPanel"; import { WaypointContextMenu } from "./WaypointContextMenu"; import "leaflet/dist/leaflet.css"; /** Distance from a point to a line segment in degrees (approximate) */ function pointToSegmentDist( pLat: number, pLon: number, aLat: number, aLon: number, bLat: number, bLon: number, ): number { const dx = bLon - aLon; const dy = bLat - aLat; const lenSq = dx * dx + dy * dy; if (lenSq === 0) return Math.sqrt((pLon - aLon) ** 2 + (pLat - aLat) ** 2); const t = Math.max(0, Math.min(1, ((pLon - aLon) * dx + (pLat - aLat) * dy) / lenSq)); const projLon = aLon + t * dx; const projLat = aLat + t * dy; return Math.sqrt((pLon - projLon) ** 2 + (pLat - projLat) ** 2); } 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; onRouteHover?: (distance: number | null) => void; 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("mapCursor", { lat: e.latlng.lat, lng: e.latlng.lng, }); }; const handleMouseOut = () => { awareness.setLocalStateField("mapCursor", 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.mapCursor && state.user) { newCursors.set(clientId, { lat: state.mapCursor.lat, lng: state.mapCursor.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 OverlaySync({ yjs, onOverlayChange, onBaseLayerChange }: { yjs: YjsState; onOverlayChange: (ids: string[]) => void; onBaseLayerChange: (name: string) => void }) { const map = useMap(); const suppressRef = useRef(false); // Map events → Yjs useEffect(() => { const handleAdd = (e: L.LayersControlEvent) => { if (suppressRef.current) return; const name = e.name; const layer = overlayLayers.find((l) => l.name === name); if (!layer) return; const raw = yjs.routeData.get("overlays") as string | undefined; const current: string[] = raw ? JSON.parse(raw) : []; if (!current.includes(layer.id)) { const updated = [...current, layer.id]; yjs.routeData.set("overlays", JSON.stringify(updated)); onOverlayChange(updated); } }; const handleRemove = (e: L.LayersControlEvent) => { if (suppressRef.current) return; const name = e.name; const layer = overlayLayers.find((l) => l.name === name); if (!layer) return; const raw = yjs.routeData.get("overlays") as string | undefined; const current: string[] = raw ? JSON.parse(raw) : []; const updated = current.filter((id) => id !== layer.id); yjs.routeData.set("overlays", JSON.stringify(updated)); onOverlayChange(updated); }; // Base layer change → Yjs const handleBaseChange = (e: L.LayersControlEvent) => { if (suppressRef.current) return; yjs.routeData.set("baseLayer", e.name); }; map.on("overlayadd", handleAdd as L.LeafletEventHandlerFn); map.on("overlayremove", handleRemove as L.LeafletEventHandlerFn); map.on("baselayerchange", handleBaseChange as L.LeafletEventHandlerFn); return () => { map.off("overlayadd", handleAdd as L.LeafletEventHandlerFn); map.off("overlayremove", handleRemove as L.LeafletEventHandlerFn); map.off("baselayerchange", handleBaseChange as L.LeafletEventHandlerFn); }; }, [map, yjs, onOverlayChange]); // Yjs → Map: load initial state from Yjs useEffect(() => { const handleChange = () => { const raw = yjs.routeData.get("overlays") as string | undefined; if (raw) { try { onOverlayChange(JSON.parse(raw)); } catch { /* ignore */ } } const base = yjs.routeData.get("baseLayer") as string | undefined; if (base) onBaseLayerChange(base); }; yjs.routeData.observe(handleChange); handleChange(); return () => yjs.routeData.unobserve(handleChange); }, [yjs, onOverlayChange, onBaseLayerChange]); return null; } 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, onRouteHover, onImportError, days }: PlannerMapProps) { const { t } = useTranslation("planner"); const [waypoints, setWaypoints] = useState([]); const poiState = usePois(); useProfileDefaults(yjs, poiState); useYjsPoiSync(yjs, poiState); const [enabledOverlays, setEnabledOverlays] = useState([]); const [selectedBaseLayer, setSelectedBaseLayer] = useState(baseLayers[0]!.name); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; index: number } | null>(null); 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 [highways, setHighways] = useState([]); const [maxspeeds, setMaxspeeds] = useState([]); const [smoothnesses, setSmoothnesses] = useState([]); const [tracktypes, setTracktypes] = useState([]); const [cycleways, setCycleways] = useState([]); const [bikeroutes, setBikeroutes] = 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([]); } const highwaysJson = yjs.routeData.get("highways") as string | undefined; if (highwaysJson) { try { setHighways(JSON.parse(highwaysJson)); } catch { setHighways([]); } } else { setHighways([]); } const maxspeedsJson = yjs.routeData.get("maxspeeds") as string | undefined; if (maxspeedsJson) { try { setMaxspeeds(JSON.parse(maxspeedsJson)); } catch { setMaxspeeds([]); } } else { setMaxspeeds([]); } const smoothnessesJson = yjs.routeData.get("smoothnesses") as string | undefined; if (smoothnessesJson) { try { setSmoothnesses(JSON.parse(smoothnessesJson)); } catch { setSmoothnesses([]); } } else { setSmoothnesses([]); } const tracktypesJson = yjs.routeData.get("tracktypes") as string | undefined; if (tracktypesJson) { try { setTracktypes(JSON.parse(tracktypesJson)); } catch { setTracktypes([]); } } else { setTracktypes([]); } const cyclewaysJson = yjs.routeData.get("cycleways") as string | undefined; if (cyclewaysJson) { try { setCycleways(JSON.parse(cyclewaysJson)); } catch { setCycleways([]); } } else { setCycleways([]); } const bikeroutesJson = yjs.routeData.get("bikeroutes") as string | undefined; if (bikeroutesJson) { try { setBikeroutes(JSON.parse(bikeroutesJson)); } catch { setBikeroutes([]); } } else { setBikeroutes([]); } 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); const finalLat = snap.lat; const finalLon = snap.snapped ? snap.lon : lng; // Find the best insertion index: if the point is near the route, // insert between the closest segment's waypoints instead of appending let insertIndex = yjs.waypoints.length; // default: append if (routeCoordinates && routeCoordinates.length >= 2 && segmentBoundaries.length > 0) { let bestDist = Infinity; let bestSegment = -1; // For each segment, find the closest point on the route for (let seg = 0; seg < segmentBoundaries.length; seg++) { const start = segmentBoundaries[seg]!; const end = seg + 1 < segmentBoundaries.length ? segmentBoundaries[seg + 1]! : routeCoordinates.length; for (let i = start; i < end - 1; i++) { const c1 = routeCoordinates[i]!; const c2 = routeCoordinates[i + 1]!; const dist = pointToSegmentDist(finalLat, finalLon, c1[1]!, c1[0]!, c2[1]!, c2[0]!); if (dist < bestDist) { bestDist = dist; bestSegment = seg; } } } // If within ~1km of the route, insert after the segment's waypoint if (bestDist < 0.01 && bestSegment >= 0) { insertIndex = bestSegment + 1; } } yjs.doc.transact(() => { const yMap = new Y.Map(); yMap.set("lat", finalLat); yMap.set("lon", finalLon); if (snap.name) yMap.set("name", snap.name); else if (name) yMap.set("name", name); if (snap.osmId) yMap.set("osmId", snap.osmId); if (snap.poiTags) yMap.set("poiTags", snap.poiTags); yjs.waypoints.insert(insertIndex, [yMap]); }, "local"); }, [yjs.doc, yjs.waypoints, poiState.pois, routeCoordinates, segmentBoundaries], ); 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 handleRoutePolylineHover = useCallback( (e: L.LeafletMouseEvent) => { if (!routeCoordinates || routeCoordinates.length < 2 || !onRouteHover) return; const { lat, lng } = e.latlng; // Find the closest coordinate index let bestIdx = 0; let bestDist = Infinity; for (let i = 0; i < routeCoordinates.length; i++) { const c = routeCoordinates[i]!; const dLat = c[1]! - lat; const dLon = c[0]! - lng; const dist = dLat * dLat + dLon * dLon; if (dist < bestDist) { bestDist = dist; bestIdx = i; } } // Compute cumulative distance to this point using haversine let totalDist = 0; for (let i = 1; i <= bestIdx; i++) { const prev = routeCoordinates[i - 1]!; const curr = routeCoordinates[i]!; const R = 6371000; const toRad = (d: number) => (d * Math.PI) / 180; const dLat = toRad(curr[1]! - prev[1]!); const dLon = toRad(curr[0]! - prev[0]!); const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(prev[1]!)) * Math.cos(toRad(curr[1]!)) * Math.sin(dLon / 2) ** 2; totalDist += R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); } onRouteHover(totalDist); }, [routeCoordinates, onRouteHover], ); const handleRoutePolylineOut = useCallback(() => { onRouteHover?.(null); }, [onRouteHover]); 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]); } // Replace notes if GPX has a description if (gpxData.description) { yjs.notes.delete(0, yjs.notes.length); yjs.notes.insert(0, gpxData.description); } }, "local"); } catch { onImportError?.(t("importGpxError")); } }, [yjs, t, onImportError]); return (
{draggingOver && (
{t("dropGpxHere")}
)} {baseLayers.map((layer) => ( ))} {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); const orig = e.originalEvent as MouseEvent; setContextMenu({ x: orig.clientX, y: orig.clientY, index: 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 && ( <> {onRouteHover && ( [c[1]!, c[0]!] as [number, number])} pathOptions={{ weight: 20, opacity: 0, interactive: true }} eventHandlers={{ mouseover: handleRoutePolylineHover, mousemove: handleRoutePolylineHover, mouseout: handleRoutePolylineOut, }} /> )} )} {highlightPosition && (
', iconSize: [0, 0], })} /> )} {contextMenu && ( deleteWaypoint(contextMenu.index)} onToggleOvernight={() => setOvernight(yjs, contextMenu.index, !waypoints[contextMenu.index]?.overnight)} onClose={() => setContextMenu(null)} /> )} ); }