import { useEffect, useRef, useState, useMemo, useCallback, createElement } from 'react' import DOM from 'react-dom' import { renderToStaticMarkup } from 'react-dom/server' import { MapContainer, TileLayer, Marker, Tooltip, 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' 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, '>') } function createPlaceIcon(place, orderNumbers, 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 || '๐Ÿ“' // Number badges (bottom-right), supports multiple numbers for duplicate places let badgeHtml = '' if (orderNumbers && orderNumbers.length > 0) { const label = orderNumbers.join(' ยท ') badgeHtml = `${label}` } 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: `
${categoryIconSvg(place.category_icon, isSelected ? 18 : 15)} ${badgeHtml}
`, iconSize: [size, size], iconAnchor: [size / 2, size / 2], tooltipAnchor: [size / 2 + 6, 0], }) } 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 { places: Place[] fitKey: number paddingOpts: Record } function BoundsController({ places, fitKey, paddingOpts }: 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 }) } catch {} }, [fitKey, places, paddingOpts, map]) return null } interface MapClickHandlerProps { onClick: ((e: L.LeafletMouseEvent) => void) | 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 const mapPhotoCache = new Map() const mapPhotoInFlight = new Set() // Live location tracker โ€” blue dot with pulse animation (like Apple/Google Maps) function LocationTracker() { const map = useMap() const [position, setPosition] = useState<[number, number] | null>(null) const [accuracy, setAccuracy] = useState(0) const [tracking, setTracking] = useState(false) const watchId = useRef(null) const startTracking = useCallback(() => { if (!('geolocation' in navigator)) return setTracking(true) watchId.current = navigator.geolocation.watchPosition( (pos) => { const latlng: [number, number] = [pos.coords.latitude, pos.coords.longitude] setPosition(latlng) setAccuracy(pos.coords.accuracy) }, () => setTracking(false), { enableHighAccuracy: true, maximumAge: 5000 } ) }, []) const stopTracking = useCallback(() => { if (watchId.current !== null) navigator.geolocation.clearWatch(watchId.current) watchId.current = null setTracking(false) setPosition(null) }, []) const toggleTracking = useCallback(() => { if (tracking) { stopTracking() } else { startTracking() } }, [tracking, startTracking, stopTracking]) // Center map on position when first acquired const centered = useRef(false) useEffect(() => { if (position && !centered.current) { map.setView(position, 15) centered.current = true } }, [position, map]) // Cleanup on unmount useEffect(() => () => { if (watchId.current !== null) navigator.geolocation.clearWatch(watchId.current) }, []) return ( <> {/* Location button */}
{/* Blue dot + accuracy circle */} {position && ( <> {accuracy < 500 && ( )} )} {/* Pulse animation CSS */} {position && ( )} ) } export 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, }) { // 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 photos for places with concurrency limit to avoid blocking map rendering useEffect(() => { const queue = places.filter(place => { if (place.image_url) return false const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}` if (!cacheKey) return false if (mapPhotoCache.has(cacheKey)) { const cached = mapPhotoCache.get(cacheKey) if (cached) setPhotoUrls(prev => prev[cacheKey] === cached ? prev : ({ ...prev, [cacheKey]: cached })) return false } if (mapPhotoInFlight.has(cacheKey)) return false const photoId = place.google_place_id || place.osm_id if (!photoId && !(place.lat && place.lng)) return false return true }) let active = 0 const MAX_CONCURRENT = 3 let idx = 0 const fetchNext = () => { while (active < MAX_CONCURRENT && idx < queue.length) { const place = queue[idx++] const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}` const photoId = place.google_place_id || place.osm_id mapPhotoInFlight.add(cacheKey) active++ mapsApi.placePhoto(photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name) .then(data => { if (data.photoUrl) { mapPhotoCache.set(cacheKey, data.photoUrl) setPhotoUrls(prev => ({ ...prev, [cacheKey]: data.photoUrl })) } else { mapPhotoCache.set(cacheKey, null) } }) .catch(() => { mapPhotoCache.set(cacheKey, null) }) .finally(() => { mapPhotoInFlight.delete(cacheKey); active--; fetchNext() }) } } fetchNext() }, [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 cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}` const resolvedPhotoUrl = place.image_url || (cacheKey && photoUrls[cacheKey]) || null const orderNumbers = dayOrderMap[place.id] ?? null const icon = createPlaceIcon({ ...place, image_url: resolvedPhotoUrl }, orderNumbers, 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 && ( <> {routeSegments.map((seg, i) => ( ))} )} {/* GPX imported route geometries */} {places.map((place) => { if (!place.route_geometry) return null try { const coords = JSON.parse(place.route_geometry) as [number, number][] if (!coords || coords.length < 2) return null return ( ) } catch { return null } })}
) }