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: `
`,
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 }
})}
)
}