import L, { LatLng } from "leaflet"; import "leaflet/dist/leaflet.css"; import { ExternalLinkIcon, MinusIcon, PlusIcon } from "lucide-react"; import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { MapContainer, Marker, useMap, useMapEvents } from "react-leaflet"; import { cn } from "@/lib/utils"; import { defaultMarkerIcon, MinimalAttributionControl, OpenStreetMapTileLayer } from "./map-utils"; import type { MapPoint } from "./types"; const toLatLng = (point: MapPoint): LatLng => new LatLng(point.lat, point.lng); const fromLatLng = (latlng: LatLng): MapPoint => ({ lat: latlng.lat, lng: latlng.lng }); interface LocationMarkerProps { position: LatLng | undefined; onChange: (position: MapPoint) => void; readonly?: boolean; } const LocationMarker = ({ position: initialPosition, onChange, readonly: readOnly }: LocationMarkerProps) => { const [position, setPosition] = useState(initialPosition); const map = useMapEvents({ click(e) { if (readOnly) { return; } setPosition(e.latlng); onChange(fromLatLng(e.latlng)); }, }); useEffect(() => { if (initialPosition) { setPosition(initialPosition); map.setView(initialPosition); } else { setPosition(undefined); } }, [initialPosition, map]); return position === undefined ? null : ( ); }; // Reusable glass-style button component interface GlassButtonProps { icon: ReactNode; onClick: () => void; ariaLabel: string; title: string; } const GlassButton = ({ icon, onClick, ariaLabel, title }: GlassButtonProps) => { return ( ); }; // Container for all map control buttons interface ControlButtonsProps { position: MapPoint | undefined; onZoomIn: () => void; onZoomOut: () => void; onOpenGoogleMaps: () => void; } const ControlButtons = ({ position, onZoomIn, onZoomOut, onOpenGoogleMaps }: ControlButtonsProps) => { return (
{position && ( } onClick={onOpenGoogleMaps} ariaLabel="Open location in Google Maps" title="Open in Google Maps" /> )} } onClick={onZoomIn} ariaLabel="Zoom in" title="Zoom in" /> } onClick={onZoomOut} ariaLabel="Zoom out" title="Zoom out" />
); }; // Custom Leaflet Control class class MapControlsContainer extends L.Control { private container: HTMLDivElement | undefined = undefined; onAdd() { this.container = L.DomUtil.create("div", ""); this.container.style.pointerEvents = "auto"; // Prevent map interactions when clicking controls L.DomEvent.disableClickPropagation(this.container); L.DomEvent.disableScrollPropagation(this.container); return this.container; } onRemove() { this.container = undefined; } getContainer() { return this.container; } } interface MapControlsProps { position: MapPoint | undefined; } const MapControls = ({ position }: MapControlsProps) => { const map = useMap(); const controlRef = useRef(null); const [container, setContainer] = useState(null); const handleOpenInGoogleMaps = () => { if (!position) return; const url = `https://www.google.com/maps?q=${position.lat},${position.lng}`; window.open(url, "_blank", "noopener,noreferrer"); }; const handleZoomIn = () => { map.zoomIn(); }; const handleZoomOut = () => { map.zoomOut(); }; useEffect(() => { // Create custom Leaflet control const control = new MapControlsContainer({ position: "topright" }); controlRef.current = control; control.addTo(map); setContainer(control.getContainer() ?? null); return () => { if (controlRef.current) { controlRef.current.remove(); controlRef.current = null; } setContainer(null); }; }, [map]); if (!container) { return null; } return createPortal( , container, ); }; const MapCleanup = () => { const map = useMap(); useEffect(() => { return () => { // Cleanup map instance when component unmounts setTimeout(() => { if (map) { try { map.remove(); } catch { // Ignore errors during cleanup } } }, 0); }; }, [map]); return null; }; interface LocationPickerProps { readonly?: boolean; latlng?: MapPoint; onChange?: (position: MapPoint) => void; className?: string; } const DEFAULT_CENTER: MapPoint = { lat: 48.8584, lng: 2.2945 }; const noopOnLocationChange = () => {}; const LocationPicker = ({ readonly: readOnly = false, latlng, onChange = noopOnLocationChange, className }: LocationPickerProps) => { const mapCenter = useMemo(() => toLatLng(latlng ?? DEFAULT_CENTER), [latlng?.lat, latlng?.lng]); const markerPosition = latlng ? mapCenter : undefined; const statusLabel = readOnly ? "Pinned location" : latlng ? "Selected location" : "Choose a location"; return (
{statusLabel}
); }; export default LocationPicker;