import { useState, useCallback, useRef } from "react"; import { MapContainer, TileLayer, LayersControl, Marker, Polyline, Tooltip } from "react-leaflet"; import L from "leaflet"; 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 { 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 { Z_WAYPOINT, Z_WAYPOINT_HIGHLIGHTED, Z_HIGHLIGHT } from "@trails-cool/map-core"; import { NoGoAreaLayer } from "./NoGoAreaLayer"; import { ColoredRoute } from "./ColoredRoute"; import { RouteInteraction } from "./RouteInteraction"; import { PoiPanel, PoiMarkers } from "./PoiPanel"; import { WaypointContextMenu } from "./WaypointContextMenu"; import { MapExposer, RouteFitter, MapClickHandler, CursorTracker, NoGoAreaButton, OverlaySync, PoiRefresher, } from "./MapHelpers"; import { useWaypointManager } from "~/lib/use-waypoint-manager"; import { useGpxDrop } from "~/lib/use-gpx-drop"; import { useNearbyPois } from "~/lib/use-nearby-pois"; import { NearbyPoiMarkers } from "./NearbyPoiMarkers"; import { poiCategories } from "@trails-cool/map-core"; import type { Poi } from "~/lib/overpass"; import "leaflet/dist/leaflet.css"; function waypointIcon(index: number, overnight?: boolean, highlighted?: boolean, hasNote?: boolean): L.DivIcon { const bg = overnight ? "#8B6D3A" : "#2563eb"; const scale = highlighted ? "scale(1.17)" : "scale(1)"; const noteIndicator = hasNote ? `` : ""; return L.divIcon({ className: "", html: `
${overnight ? "☾" : index + 1}
${noteIndicator}
`, iconSize: [0, 0], }); } function dayLabelIcon(dayNumber: number, distanceKm: string): L.DivIcon { return L.divIcon({ className: "", html: `
Day ${dayNumber} · ${distanceKm} km
`, iconSize: [0, 0], }); } interface PlannerMapProps { yjs: YjsState; sessionId: string; onRouteRequest?: (waypoints: { lat: number; lon: number; name?: string; overnight: boolean }[]) => void; onImportError?: (message: string) => void; highlightPosition?: [number, number] | null; highlightedWaypoint?: number | null; selectedWaypointIndex?: number | null; onRouteHover?: (distance: number | null) => void; days?: DayStage[]; } export function PlannerMap({ yjs, sessionId, onRouteRequest, highlightPosition, highlightedWaypoint, selectedWaypointIndex, onRouteHover, onImportError, days }: PlannerMapProps) { const { t } = useTranslation("planner"); const poiState = usePois(sessionId); 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 [noGoDrawing, setNoGoDrawing] = useState(false); const toggleNoGoDraw = useCallback(() => setNoGoDrawing((v) => !v), []); const suppressMapClickRef = useRef(false); const routeInteractionSuspendedRef = useRef(false); const waypointDraggingRef = useRef(false); const { waypoints, routeCoordinates, segmentBoundaries, surfaces, highways, maxspeeds, smoothnesses, tracktypes, cycleways, bikeroutes, colorMode, addWaypoint, handleRouteInsert, moveWaypoint, deleteWaypoint, handleRoutePolylineHover, } = useWaypointManager(yjs, poiState, suppressMapClickRef, onRouteRequest); const { draggingOver, handleDragEnter, handleDragLeave, handleDragOver, handleDrop } = useGpxDrop(yjs, onImportError); const selectedWp = selectedWaypointIndex != null ? waypoints[selectedWaypointIndex] : undefined; const nearbyPoisState = useNearbyPois(selectedWp?.lat, selectedWp?.lon); const handleSnapToPoi = useCallback((poi: Poi) => { if (selectedWaypointIndex == null) return; const yMap = yjs.waypoints.get(selectedWaypointIndex); if (!yMap) return; const cat = poiCategories.find((c) => c.id === poi.category); const notePrefix = cat ? `${cat.icon} ${poi.name ?? cat.id}` : (poi.name ?? ""); yjs.doc.transact(() => { yMap.set("lat", poi.lat); yMap.set("lon", poi.lon); if (poi.name && !yMap.get("name")) yMap.set("name", poi.name); const existing = yMap.get("note") as string | undefined; yMap.set("note", existing ? `${notePrefix}\n${existing}` : notePrefix); if (poi.id) yMap.set("osmId", poi.id); }, "local"); }, [selectedWaypointIndex, yjs, poiCategories]); const handleRoutePolylineOut = useCallback(() => { onRouteHover?.(null); }, [onRouteHover]); return (
{draggingOver && (
{t("dropGpxHere")}
)} {baseLayers.map((layer) => ( ))} {overlayLayers.map((layer) => ( ))} {} : addWaypoint} suppressRef={suppressMapClickRef} /> {selectedWaypointIndex != null && nearbyPoisState.pois.length > 0 && ( )} {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.originalEvent); const orig = e.originalEvent as MouseEvent; setContextMenu({ x: orig.clientX, y: orig.clientY, index: i }); }, }} > {wp.note && ( {wp.note.length > 120 ? wp.note.slice(0, 120) + "…" : wp.note} )} ))} {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: (e) => handleRoutePolylineHover(e, onRouteHover), mousemove: (e) => handleRoutePolylineHover(e, onRouteHover), mouseout: handleRoutePolylineOut, }} /> )} )} {highlightPosition && (
', iconSize: [0, 0], })} /> )} {contextMenu && ( deleteWaypoint(contextMenu.index)} onToggleOvernight={() => setOvernight(yjs, contextMenu.index, !waypoints[contextMenu.index]?.overnight)} onClose={() => setContextMenu(null)} /> )} ); }