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, Tooltip } 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 { PluginMapMarkers } from './MapPluginMarkers' import { useTransportRoutes } from '../../hooks/useTransportRoutes' import { visibleRouteReservations } from '../../utils/reservationRoutes' import type { Reservation } from '../../types' import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories' import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults' import { computeMapViewport, TILE_SIZE_RASTER, type ViewportPadding } from '../../utils/mapViewport' 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. `_getIconUrl` is a Leaflet-internal field // not present in the public typings, so narrow to delete it. delete (L.Icon.Default.prototype as { _getIconUrl?: unknown })._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' : (place.category_color || '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 } // Small coloured pin for an OSM "explore" POI — distinct from the photo-circle // markers of planned places; the colour matches its pill category. const poiIconCache = new Map() function createPoiIcon(category: string) { const cached = poiIconCache.get(category) if (cached) return cached const cat = POI_CATEGORY_BY_KEY[category] const color = cat?.color || '#6b7280' const svg = cat ? renderToStaticMarkup(createElement(cat.Icon, { size: 13, color: 'white', strokeWidth: 2.5 })) : '' const icon = L.divIcon({ className: '', html: `
${svg}
`, iconSize: [26, 26], iconAnchor: [13, 13], tooltipAnchor: [0, -14], }) poiIconCache.set(category, icon) return icon } // Clears the hover tooltip the moment the camera starts moving and suppresses // re-showing it until the move ends: after a click-recenter the marker slides // away under a stationary cursor, so the browser never fires mouseout — and // mouseover/mousemove during the pan animation would immediately re-set the // tooltip we just cleared (#1404). function CameraHoverGuard({ movingRef, onMoveStart }: { movingRef: { current: boolean }; onMoveStart: () => void }) { const map = useMap() useEffect(() => { const start = () => { movingRef.current = true; onMoveStart() } const end = () => { movingRef.current = false } map.on('movestart zoomstart', start) map.on('moveend zoomend', end) return () => { map.off('movestart zoomstart', start); map.off('moveend zoomend', end) } }, [map, movingRef, onMoveStart]) return null } // Emits the current viewport bbox on pan/zoom so the POI-explore pill can fetch // OSM places for the visible area. function ViewportController({ onViewportChange }: { onViewportChange?: (b: { south: number; west: number; north: number; east: number }) => void }) { const map = useMap() useEffect(() => { if (!onViewportChange) return const emit = () => { const b = map.getBounds() onViewportChange({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() }) } map.whenReady(emit) // ensure the first bbox is captured once the map is laid out map.on('moveend', emit) map.on('zoomend', emit) return () => { map.off('moveend', emit); map.off('zoomend', emit) } }, [map, onViewportChange]) return null } interface SelectionControllerProps { places: Place[] selectedPlaceId: number | null dayPlaces: Place[] paddingOpts: L.FitBoundsOptions } 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. Offset the centre by the // side-panel + bottom-inspector padding so the pin lands in the middle of the // *visible* map area rather than the geometric centre (where the bottom panel // would cover it). Reuses the same paddingOpts the fit-bounds path uses. const selected = places.find(p => p.id === selectedPlaceId) if (selected?.lat != null && selected?.lng != null) { const latlng: [number, number] = [selected.lat, selected.lng] const tl = paddingOpts.paddingTopLeft as [number, number] | undefined const br = paddingOpts.paddingBottomRight as [number, number] | undefined if (tl && br && typeof map.project === 'function' && typeof map.unproject === 'function') { const point = map.project(latlng).add([(br[0] - tl[0]) / 2, (br[1] - tl[1]) / 2]) map.panTo(map.unproject(point), { animate: true }) } else { map.panTo(latlng, { 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). On a day selection we // fit to that day's destinations immediately, then — once the day's route has // finished computing asynchronously — re-fit once more to include the full route // polyline, so a route that bulges past its stops stays in view (#1128). interface BoundsControllerProps { hasDayDetail?: boolean places: Place[] routeCoords: [number, number][] fitKey: number paddingOpts: L.FitBoundsOptions /** The map was built already framed on these places, so the opening fit has nothing to do. */ framedOnMount?: boolean } function BoundsController({ places, routeCoords, fitKey, paddingOpts, hasDayDetail, framedOnMount = false }: BoundsControllerProps) { const map = useMap() const prevFitKey = useRef(-1) const awaitingRoute = useRef(false) const fitRan = useRef(false) const fitTo = useCallback((coords: [number, number][]) => { if (coords.length === 0) return try { const bounds = L.latLngBounds(coords) if (bounds.isValid()) { map.fitBounds(bounds, { ...paddingOpts, maxZoom: 16, animate: true }) if (hasDayDetail) { setTimeout(() => map.panBy([0, 150], { animate: true }), 300) } } } catch {} }, [map, paddingOpts, hasDayDetail]) // New fitKey (initial trip fit or a day selection): fit to the destinations now // and arm a one-shot re-fit for when the route arrives. useEffect(() => { if (fitKey === prevFitKey.current) return prevFitKey.current = fitKey awaitingRoute.current = false if (places.length === 0) return // The map opened framed on these very places — re-fitting would only re-do that, and its // maxZoom would overrule the gentler zoom a single place opens at. Later fits (picking a // day) still run. if (!fitRan.current && framedOnMount) { fitRan.current = true return } fitRan.current = true fitTo(places.map(p => [p.lat, p.lng] as [number, number])) awaitingRoute.current = true }, [fitKey]) // eslint-disable-line react-hooks/exhaustive-deps // Once the just-selected day's route is ready, expand the fit to include it. // One-shot per day-fit, so later route-profile toggles don't re-zoom the map. useEffect(() => { if (!awaitingRoute.current || routeCoords.length === 0) return awaitingRoute.current = false fitTo([...places.map(p => [p.lat, p.lng] as [number, number]), ...routeCoords]) }, [routeCoords]) // eslint-disable-line react-hooks/exhaustive-deps 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 } // Travel times are shown in the day sidebar (per-segment connectors), not on the map. // 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, hoverDisabled = false, onMarkerClick, onMapClick, onMapContextMenu = null, center = DEFAULT_MAP_CENTER, zoom = DEFAULT_MAP_ZOOM, 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[], showTransitRoutes = true, onReservationClick, pois = [] as Poi[], onPoiClick, onViewportChange, tripId, }: any) { const poiMarkers = useMemo(() => (pois as Poi[]).map((poi: Poi) => ( onPoiClick?.(poi) }} > {poi.name} )), [pois, onPoiClick]) const visibleReservations = useMemo(() => ( visibleRouteReservations(reservations, { visibleConnectionIds, showTransitRoutes }) ), [reservations, visibleConnectionIds, showTransitRoutes]) // Real road geometry for car/bus/taxi/bicycle bookings (straight line until it loads/if it fails). const transportRoutes = useTransportRoutes(visibleReservations) // Dynamic padding: account for sidebars + bottom inspector + day detail panel // The chrome overlaying the map (side panels, day detail). Kept as a plain box so both the // Leaflet fit options and the opening-camera maths can read the same numbers. const paddingBox = useMemo((): ViewportPadding => { const isMobile = typeof window !== 'undefined' && window.innerWidth < 768 if (isMobile) return { top: 20, right: 40, bottom: 20, left: 40 } return { top: 60, right: rightWidth + 40, bottom: hasInspector ? 320 : hasDayDetail ? 280 : 60, left: leftWidth + 40, } }, [leftWidth, rightWidth, hasInspector, hasDayDetail]) const paddingOpts = useMemo((): L.FitBoundsOptions => ({ paddingTopLeft: [paddingBox.left, paddingBox.top], paddingBottomRight: [paddingBox.right, paddingBox.bottom], }), [paddingBox]) // Open framed on the places rather than on the caller's default, so a trip in Japan shows // Japan straight away instead of the world view followed by a flight across the planet. // The initializer runs once, at mount — exactly when this should be decided; afterwards the // camera belongs to the user. `framed` is false when no place has coordinates (a new trip), // and then the caller's center/zoom stands. const [initialView] = useState(() => { const framed = computeMapViewport(dayPlaces.length > 0 ? dayPlaces : places, { tileSize: TILE_SIZE_RASTER, padding: paddingBox, }) return { center: framed?.center ?? center, zoom: framed?.zoom ?? zoom, framed: framed !== null } }) // 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 mapMovingRef = useRef(false) const handleMarkerHover = useCallback((place: any, x: number, y: number) => { if (hoverDisabled || mapMovingRef.current) return setHoveredPlace(place) setTooltipPos({ x, y }) }, [hoverDisabled]) const handleMarkerHoverOut = useCallback(() => { setHoveredPlace(null) setTooltipPos(null) }, []) // A marker's DOM node is replaced when it becomes selected (its icon grows // 36→44px, and the cluster group re-adds it), so the browser never fires // mouseout on the old node and the fixed-position hover tooltip gets orphaned // — it hangs on screen and drifts with page scroll. Drop it on any selection // change and on any scroll so it can never get stuck. useEffect(() => { setHoveredPlace(null); setTooltipPos(null) }, [selectedPlaceId]) useEffect(() => { if (!hoveredPlace) return const clear = () => { setHoveredPlace(null); setTooltipPos(null) } window.addEventListener('scroll', clear, true) return () => window.removeEventListener('scroll', clear, true) }, [hoveredPlace]) const handleMarkerClick = useCallback((id: number) => { // Clear the hover card right away: the recenter that follows moves the // marker out from under the cursor, so no mouseout will ever fire (#1404). setHoveredPlace(null) setTooltipPos(null) onMarkerClick?.(id) }, [onMarkerClick]) const clearHover = useCallback(() => { setHoveredPlace(null) setTooltipPos(null) }, []) // 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]) // Flattened [lat,lng] points of the selected day's route, so the bounds fit can // include the full polyline once it has been computed. const routeCoords = useMemo<[number, number][]>(() => (route || []).flat() as [number, number][], [route]) 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 = !hoverDisabled && 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 // When the day-detail panel is open it slides up over the map (bottom: navh+20, // height var(--day-panel-h)) and covers the button's band, so lift the button // above it; otherwise keep the plain bottom-nav offset. #1348 const locationButtonBottom = hasDayDetail ? 'calc(var(--bottom-nav-h, 84px) + 20px + var(--day-panel-h, 0px) + 12px)' : 'calc(var(--bottom-nav-h, 84px) + 12px)' return ( <>
0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} hasDayDetail={hasDayDetail} framedOnMount={initialView.framed} /> {markers} {/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded. */} {route && route.length > 0 && route.flatMap((seg, i) => seg.length > 1 ? [ , , ] : [])} {/* GPX imported route geometries */} {gpxPolylines} {poiMarkers} {isMobile && }
{TooltipOverlay && (
{hoveredPlace.name}
{hoveredPlace.category_name && CatIcon && (
{hoveredPlace.category_name}
)} {hoveredPlace.address && (
{hoveredPlace.address}
)}
)} ) })