import { useEffect, useRef, useState, useMemo, useCallback, createElement, memo } from 'react' import DOM from 'react-dom' import { renderToStaticMarkup } from 'react-dom/server' import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap } from 'react-leaflet' import MarkerClusterGroup from 'react-leaflet-cluster' import L from 'leaflet' import 'leaflet.markercluster/dist/MarkerCluster.css' import 'leaflet.markercluster/dist/MarkerCluster.Default.css' import { mapsApi } from '../../api/client' import { getCategoryIcon, CATEGORY_ICON_MAP } from '../shared/categoryIcons' import ReservationOverlay from './ReservationOverlay' import type { Reservation } from '../../types' function categoryIconSvg(iconName: string | null | undefined, size: number): string { const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin'] try { return renderToStaticMarkup(createElement(IconComponent, { size, color: 'white', strokeWidth: 2.5 })) } catch { return '' } } import type { Place } from '../../types' // Fix default marker icons for vite delete L.Icon.Default.prototype._getIconUrl L.Icon.Default.mergeOptions({ iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png', iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png', shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png', }) /** * Create a round photo-circle marker. * Shows image_url if available, otherwise category icon in colored circle. */ function escAttr(s) { if (!s) return '' return s.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>') } const iconCache = new Map() function createPlaceIcon(place, orderNumbers, isSelected) { const cacheKey = `${place.id}:${isSelected}:${place.image_url || ''}:${place.category_color || ''}:${place.category_icon || ''}:${orderNumbers?.join(',') || ''}` const cached = iconCache.get(cacheKey) if (cached) return cached const size = isSelected ? 44 : 36 const borderColor = isSelected ? '#111827' : 'white' const borderWidth = isSelected ? 3 : 2.5 const shadow = isSelected ? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 14px rgba(0,0,0,0.3)' : '0 2px 8px rgba(0,0,0,0.22)' const bgColor = place.category_color || '#6b7280' // Number badges (bottom-right) let badgeHtml = '' if (orderNumbers && orderNumbers.length > 0) { const label = orderNumbers.join(' · ') badgeHtml = `${label}` } // Prefer base64 data URLs (no zoom lag); also accept same-origin proxy URLs as a fallback // while the thumb is still being generated in the background if (place.image_url && (place.image_url.startsWith('data:') || place.image_url.startsWith('/api/maps/place-photo/'))) { const imgIcon = L.divIcon({ className: '', html: `
${badgeHtml}
`, iconSize: [size, size], iconAnchor: [size / 2, size / 2], tooltipAnchor: [size / 2 + 6, 0], }) iconCache.set(cacheKey, imgIcon) return imgIcon } const fallbackIcon = L.divIcon({ className: '', html: `
${categoryIconSvg(place.category_icon, isSelected ? 18 : 15)} ${badgeHtml}
`, iconSize: [size, size], iconAnchor: [size / 2, size / 2], tooltipAnchor: [size / 2 + 6, 0], }) iconCache.set(cacheKey, fallbackIcon) return fallbackIcon } interface SelectionControllerProps { places: Place[] selectedPlaceId: number | null dayPlaces: Place[] paddingOpts: Record } function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts }: SelectionControllerProps) { const map = useMap() const prev = useRef(null) useEffect(() => { if (selectedPlaceId && selectedPlaceId !== prev.current) { // Pan to the selected place without changing zoom const selected = places.find(p => p.id === selectedPlaceId) if (selected?.lat && selected?.lng) { map.panTo([selected.lat, selected.lng], { animate: true }) } } prev.current = selectedPlaceId }, [selectedPlaceId, places, map]) return null } interface MapControllerProps { center: [number, number] zoom: number } function MapController({ center, zoom }: MapControllerProps) { const map = useMap() const prevCenter = useRef(center) useEffect(() => { if (prevCenter.current[0] !== center[0] || prevCenter.current[1] !== center[1]) { map.setView(center, zoom) prevCenter.current = center } }, [center, zoom, map]) return null } // Fit bounds when places change (fitKey triggers re-fit) interface BoundsControllerProps { hasDayDetail?: boolean places: Place[] fitKey: number paddingOpts: Record } function BoundsController({ places, fitKey, paddingOpts, hasDayDetail }: BoundsControllerProps) { const map = useMap() const prevFitKey = useRef(-1) useEffect(() => { if (fitKey === prevFitKey.current) return prevFitKey.current = fitKey if (places.length === 0) return try { const bounds = L.latLngBounds(places.map(p => [p.lat, p.lng])) if (bounds.isValid()) { map.fitBounds(bounds, { ...paddingOpts, maxZoom: 16, animate: true }) if (hasDayDetail) { setTimeout(() => map.panBy([0, 150], { animate: true }), 300) } } } catch {} }, [fitKey, places, paddingOpts, map, hasDayDetail]) return null } interface MapClickHandlerProps { onClick: ((e: L.LeafletMouseEvent) => void) | null } function ZoomTracker({ onZoomStart, onZoomEnd }: { onZoomStart: () => void; onZoomEnd: () => void }) { const map = useMap() useEffect(() => { map.on('zoomstart', onZoomStart) map.on('zoomend', onZoomEnd) return () => { map.off('zoomstart', onZoomStart); map.off('zoomend', onZoomEnd) } }, [map, onZoomStart, onZoomEnd]) return null } function MapClickHandler({ onClick }: MapClickHandlerProps) { const map = useMap() useEffect(() => { if (!onClick) return map.on('click', onClick) return () => map.off('click', onClick) }, [map, onClick]) return null } function MapContextMenuHandler({ onContextMenu }: { onContextMenu: ((e: L.LeafletMouseEvent) => void) | null }) { const map = useMap() useEffect(() => { if (!onContextMenu) return map.on('contextmenu', onContextMenu) return () => map.off('contextmenu', onContextMenu) }, [map, onContextMenu]) return null } // ── Route travel time label ── interface RouteLabelProps { midpoint: [number, number] walkingText: string drivingText: string } function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) { const map = useMap() const [visible, setVisible] = useState(map ? map.getZoom() >= 12 : false) useEffect(() => { if (!map) return const check = () => setVisible(map.getZoom() >= 12) check() map.on('zoomend', check) return () => map.off('zoomend', check) }, [map]) if (!visible || !midpoint) return null const icon = L.divIcon({ className: 'route-info-pill', html: `
${walkingText} | ${drivingText}
`, iconSize: [0, 0], iconAnchor: [0, 0], }) return } // Module-level photo cache shared with PlaceAvatar import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService' import { useAuthStore } from '../../store/authStore' import { useGeolocation } from '../../hooks/useGeolocation' import LocationButton from './LocationButton' // Live-location rendering inside the Leaflet map. Subscribes via the // shared useGeolocation hook so the Leaflet and Mapbox variants behave // identically. Heading is shown as a rotated conic SVG when available. import type { GeoPosition, TrackingMode } from '../../hooks/useGeolocation' function LeafletLocationLayer({ position, mode }: { position: GeoPosition | null; mode: TrackingMode }) { const map = useMap() // When the user is in follow mode, keep the map centred on the dot. // setView (no animation) is what Google Maps does during navigation — // it feels responsive and avoids animation jitter at walking speed. useEffect(() => { if (mode !== 'follow' || !position) return try { map.setView([position.lat, position.lng], Math.max(map.getZoom(), 16), { animate: true, duration: 0.35 }) } catch { /* noop */ } }, [position, mode, map]) // Once, when the user first acquires a fix in "show" mode, pan to it so // they don't have to scroll the map. Subsequent fixes only move the dot. const centeredRef = useRef(false) useEffect(() => { if (mode === 'off') { centeredRef.current = false; return } if (!position || centeredRef.current) return try { map.setView([position.lat, position.lng], Math.max(map.getZoom(), 15)) } catch { /* noop */ } centeredRef.current = true }, [position, mode, map]) if (!position) return null const headingIcon = position.heading === null || Number.isNaN(position.heading) ? null : L.divIcon({ className: '', iconSize: [60, 60], iconAnchor: [30, 30], html: `
`, }) return ( <> {position.accuracy < 500 && ( )} {headingIcon && ( )} ) } interface MemoMarkerProps { place: any isSelected: boolean orderNumbers: number[] | null photoUrl: string | null onClickPlace: (id: number) => void onHover: (place: any, x: number, y: number) => void onHoverOut: () => void } const MemoMarker = memo(function MemoMarker({ place, isSelected, orderNumbers, photoUrl, onClickPlace, onHover, onHoverOut, }: MemoMarkerProps) { const icon = createPlaceIcon({ ...place, image_url: photoUrl }, orderNumbers, isSelected) return ( onClickPlace(place.id), mouseover: (e: any) => onHover(place, e.originalEvent.clientX, e.originalEvent.clientY), mousemove: (e: any) => onHover(place, e.originalEvent.clientX, e.originalEvent.clientY), mouseout: onHoverOut, }} zIndexOffset={isSelected ? 1000 : 0} /> ) }) export const MapView = memo(function MapView({ places = [], dayPlaces = [], route = null, routeSegments = [], selectedPlaceId = null, onMarkerClick, onMapClick, onMapContextMenu = null, center = [48.8566, 2.3522], zoom = 10, tileUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', fitKey = 0, dayOrderMap = {}, leftWidth = 0, rightWidth = 0, hasInspector = false, hasDayDetail = false, reservations = [] as Reservation[], showReservationStats = false, visibleConnectionIds = [] as number[], onReservationClick, }: any) { const visibleReservations = useMemo(() => { if (!visibleConnectionIds || visibleConnectionIds.length === 0) return [] const set = new Set(visibleConnectionIds) return reservations.filter((r: Reservation) => set.has(r.id)) }, [reservations, visibleConnectionIds]) // Dynamic padding: account for sidebars + bottom inspector + day detail panel const paddingOpts = useMemo(() => { const isMobile = typeof window !== 'undefined' && window.innerWidth < 768 if (isMobile) return { padding: [40, 20] } const top = 60 const bottom = hasInspector ? 320 : hasDayDetail ? 280 : 60 const left = leftWidth + 40 const right = rightWidth + 40 return { paddingTopLeft: [left, top], paddingBottomRight: [right, bottom] } }, [leftWidth, rightWidth, hasInspector, hasDayDetail]) // Hover state for the single tooltip overlay (replaces per-marker ) const [hoveredPlace, setHoveredPlace] = useState(null) const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null) const handleMarkerHover = useCallback((place: any, x: number, y: number) => { setHoveredPlace(place) setTooltipPos({ x, y }) }, []) const handleMarkerHoverOut = useCallback(() => { setHoveredPlace(null) }, []) const handleMarkerClick = useCallback((id: number) => { onMarkerClick?.(id) }, [onMarkerClick]) // photoUrls: only base64 thumbs for smooth map zoom const [photoUrls, setPhotoUrls] = useState>(getAllThumbs) const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled) // Batch photo state updates through a RAF so N simultaneous photo loads // collapse into a single re-render instead of N separate renders. const pendingThumbsRef = useRef>({}) const thumbRafRef = useRef(null) const placeIds = useMemo(() => places.map(p => p.id).join(','), [places]) useEffect(() => { if (!places || places.length === 0 || !placesPhotosEnabled) return const cleanups: (() => void)[] = [] const setThumb = (cacheKey: string, thumb: string) => { pendingThumbsRef.current[cacheKey] = thumb if (thumbRafRef.current !== null) return thumbRafRef.current = requestAnimationFrame(() => { thumbRafRef.current = null const pending = pendingThumbsRef.current pendingThumbsRef.current = {} setPhotoUrls(prev => { const hasChange = Object.entries(pending).some(([k, v]) => prev[k] !== v) return hasChange ? { ...prev, ...pending } : prev }) }) } for (const place of places) { const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}` if (!cacheKey) continue const cached = getCached(cacheKey) if (cached?.thumbDataUrl) { setThumb(cacheKey, cached.thumbDataUrl) continue } cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb))) if (!cached && !isLoading(cacheKey)) { const photoId = (place.image_url?.startsWith('/api/maps/place-photo/') ? place.image_url : null) || place.google_place_id || place.osm_id || place.image_url if (photoId || (place.lat && place.lng)) { fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name) } } } return () => { cleanups.forEach(fn => fn()) if (thumbRafRef.current !== null) { cancelAnimationFrame(thumbRafRef.current) thumbRafRef.current = null } } }, [placeIds, placesPhotosEnabled]) const clusterIconCreateFunction = useCallback((cluster) => { const count = cluster.getChildCount() const size = count < 10 ? 36 : count < 50 ? 42 : 48 return L.divIcon({ html: `
${count}
`, className: 'marker-cluster-wrapper', iconSize: L.point(size, size), }) }, []) const isTouchDevice = typeof window !== 'undefined' && navigator.maxTouchPoints > 0 const markers = useMemo(() => places.map((place) => { const isSelected = place.id === selectedPlaceId const pck = place.google_place_id || place.osm_id || `${place.lat},${place.lng}` const photoUrl = (pck && photoUrls[pck]) || place.image_url || null const orderNumbers = dayOrderMap[place.id] ?? null return ( ) }), [places, selectedPlaceId, dayOrderMap, photoUrls, handleMarkerClick, handleMarkerHover, handleMarkerHoverOut]) const gpxPolylines = useMemo(() => places.flatMap(place => { if (!place.route_geometry) return [] try { const coords = JSON.parse(place.route_geometry) as [number, number][] if (!coords || coords.length < 2) return [] return [( )] } catch { return [] } }), [places]) const TooltipOverlay = hoveredPlace && tooltipPos && !isTouchDevice const CatIcon = TooltipOverlay ? getCategoryIcon(hoveredPlace.category_icon) : null const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode } = useGeolocation() // Desktop browsers only get IP-based geolocation (city-level accuracy), // so the button would be misleading. Mobile, where real GPS lives, keeps it. const isMobile = typeof window !== 'undefined' && window.innerWidth < 768 const locationButtonBottom = 'calc(var(--bottom-nav-h, 84px) + 12px)' return ( <>
0 ? dayPlaces : places} fitKey={fitKey} paddingOpts={paddingOpts} hasDayDetail={hasDayDetail} /> {markers} {route && route.length > 0 && ( <> {route.map((seg, i) => seg.length > 1 && ( ))} {routeSegments.map((seg, i) => ( ))} )} {/* GPX imported route geometries */} {gpxPolylines} {isMobile && }
{TooltipOverlay && (
{hoveredPlace.name}
{hoveredPlace.category_name && CatIcon && (
{hoveredPlace.category_name}
)} {hoveredPlace.address && (
{hoveredPlace.address}
)}
)} ) })