import React, { useEffect, useRef, useState, useMemo } from 'react' import { MapContainer, TileLayer, Marker, Tooltip, Polyline, 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 } from '../shared/categoryIcons' // 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, '>') } function createPlaceIcon(place, orderNumber, isSelected) { 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' const icon = place.category_icon || '📍' // White semi-transparent number badge (bottom-right), only when orderNumber is set const badgeHtml = orderNumber != null ? ` ${orderNumber}` : '' if (place.image_url) { return L.divIcon({ className: '', html: `
${badgeHtml}
`, iconSize: [size, size], iconAnchor: [size / 2, size / 2], tooltipAnchor: [size / 2 + 6, 0], }) } return L.divIcon({ className: '', html: `
${icon} ${badgeHtml}
`, iconSize: [size, size], iconAnchor: [size / 2, size / 2], tooltipAnchor: [size / 2 + 6, 0], }) } function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts }) { const map = useMap() const prev = useRef(null) useEffect(() => { if (selectedPlaceId && selectedPlaceId !== prev.current) { // Fit all day places into view (so you see context), but ensure selected is visible const toFit = dayPlaces.length > 0 ? dayPlaces : places.filter(p => p.id === selectedPlaceId) const withCoords = toFit.filter(p => p.lat && p.lng) if (withCoords.length > 0) { try { const bounds = L.latLngBounds(withCoords.map(p => [p.lat, p.lng])) if (bounds.isValid()) { map.fitBounds(bounds, { ...paddingOpts, maxZoom: 16, animate: true }) } } catch {} } } prev.current = selectedPlaceId }, [selectedPlaceId, places, dayPlaces, paddingOpts, map]) return null } function MapController({ center, zoom }) { 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) function BoundsController({ places, fitKey, paddingOpts }) { 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 }) } catch {} }, [fitKey, places, paddingOpts, map]) return null } function MapClickHandler({ onClick }) { const map = useMap() useEffect(() => { if (!onClick) return map.on('click', onClick) return () => map.off('click', onClick) }, [map, onClick]) return null } // Module-level photo cache shared with PlaceAvatar const mapPhotoCache = new Map() export function MapView({ places = [], dayPlaces = [], route = null, selectedPlaceId = null, onMarkerClick, onMapClick, 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, }) { // Dynamic padding: account for sidebars + bottom inspector const paddingOpts = useMemo(() => { const isMobile = typeof window !== 'undefined' && window.innerWidth < 768 if (isMobile) return { padding: [40, 20] } const top = 60 const bottom = hasInspector ? 320 : 60 const left = leftWidth + 40 const right = rightWidth + 40 return { paddingTopLeft: [left, top], paddingBottomRight: [right, bottom] } }, [leftWidth, rightWidth, hasInspector]) const [photoUrls, setPhotoUrls] = useState({}) // Fetch Google photos for places that have google_place_id but no image_url useEffect(() => { places.forEach(place => { if (place.image_url || !place.google_place_id) return if (mapPhotoCache.has(place.google_place_id)) { const cached = mapPhotoCache.get(place.google_place_id) if (cached) setPhotoUrls(prev => ({ ...prev, [place.google_place_id]: cached })) return } mapsApi.placePhoto(place.google_place_id) .then(data => { if (data.photoUrl) { mapPhotoCache.set(place.google_place_id, data.photoUrl) setPhotoUrls(prev => ({ ...prev, [place.google_place_id]: data.photoUrl })) } }) .catch(() => { mapPhotoCache.set(place.google_place_id, null) }) }) }, [places]) return ( 0 ? dayPlaces : places} fitKey={fitKey} paddingOpts={paddingOpts} /> { 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), }) }} > {places.map((place) => { const isSelected = place.id === selectedPlaceId const resolvedPhotoUrl = place.image_url || (place.google_place_id && photoUrls[place.google_place_id]) || null const orderNumber = dayOrderMap[place.id] ?? null const icon = createPlaceIcon({ ...place, image_url: resolvedPhotoUrl }, orderNumber, isSelected) return ( onMarkerClick && onMarkerClick(place.id), }} zIndexOffset={isSelected ? 1000 : 0} >
{place.name}
{place.category_name && (() => { const CatIcon = getCategoryIcon(place.category_icon) return (
{place.category_name}
) })()} {place.address && (
{place.address}
)}
) })}
{route && route.length > 1 && ( )}
) }