mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
1378c95078
* feat(maps): add an OSM POI search endpoint (category within a viewport) New /api/maps/pois queries OpenStreetMap via Overpass for places of a category (restaurants, cafes, hotels, sights, …) inside a bounding box. OSM-only by design — it never calls Google, even when a Google key is configured. * feat(map): explore nearby places on the trip map (OSM category pill) A floating, icon-only pill over the planner map lets you toggle a POI category and see those OpenStreetMap places in the current view; clicking a marker opens the add-place form pre-filled (name, address, website, phone). Single-select with a 'search this area' action after the map moves. Renders on both the Leaflet and Mapbox maps, and can be turned off in settings (discussion #841). * fix(planner): anchor timed places when optimising and route transports by location - The day optimiser no longer reshuffles places that have a set time — they stay anchored to their time, like locked places. - The route now uses a transport's departure/arrival location as a waypoint when it has one (e.g. a flight's airport), instead of breaking the route at every booking; transports without a location are ignored for routing but still show their leg's distance/duration under the booking. * feat(admin): instance-wide Mapbox defaults in default user settings Admins can set a shared Mapbox token (plus style, 3D and quality) as instance defaults, so the whole instance can use Mapbox without each user pasting their own key. Users without their own value inherit it via the existing admin-defaults merge; the shared token is stored encrypted (discussion #920).
685 lines
25 KiB
TypeScript
685 lines
25 KiB
TypeScript
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 type { Reservation } from '../../types'
|
|
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
|
|
|
|
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, '<').replace(/>/g, '>')
|
|
}
|
|
|
|
const iconCache = new Map<string, L.DivIcon>()
|
|
|
|
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 = `<span style="
|
|
position:absolute;bottom:-4px;right:-4px;
|
|
min-width:18px;height:${orderNumbers.length > 1 ? 16 : 18}px;border-radius:${orderNumbers.length > 1 ? 8 : 9}px;
|
|
padding:0 ${orderNumbers.length > 1 ? 4 : 3}px;
|
|
background:rgba(255,255,255,0.94);
|
|
border:1.5px solid rgba(0,0,0,0.15);
|
|
box-shadow:0 1px 4px rgba(0,0,0,0.18);
|
|
display:flex;align-items:center;justify-content:center;
|
|
font-size:${orderNumbers.length > 1 ? 7.5 : 9}px;font-weight:800;color:#111827;
|
|
font-family:var(--font-system);line-height:1;
|
|
box-sizing:border-box;white-space:nowrap;
|
|
">${label}</span>`
|
|
}
|
|
|
|
// 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: `<div style="
|
|
width:${size}px;height:${size}px;
|
|
cursor:pointer;position:relative;
|
|
">
|
|
<div style="
|
|
width:${size}px;height:${size}px;border-radius:50%;
|
|
border:${borderWidth}px solid ${borderColor};
|
|
box-shadow:${shadow};
|
|
overflow:hidden;background:${bgColor};
|
|
">
|
|
<img src="${place.image_url}" width="${size}" height="${size}" style="display:block;border-radius:50%;object-fit:cover;" />
|
|
</div>
|
|
${badgeHtml}
|
|
</div>`,
|
|
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: `<div style="
|
|
width:${size}px;height:${size}px;border-radius:50%;
|
|
border:${borderWidth}px solid ${borderColor};
|
|
box-shadow:${shadow};
|
|
background:${bgColor};
|
|
display:flex;align-items:center;justify-content:center;
|
|
cursor:pointer;position:relative;
|
|
will-change:transform;contain:layout style;
|
|
">
|
|
${categoryIconSvg(place.category_icon, isSelected ? 18 : 15)}
|
|
${badgeHtml}
|
|
</div>`,
|
|
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<string, L.DivIcon>()
|
|
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: `<div style="width:26px;height:26px;border-radius:50%;background:${color};border:2px solid white;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;cursor:pointer;">${svg}</div>`,
|
|
iconSize: [26, 26],
|
|
iconAnchor: [13, 13],
|
|
tooltipAnchor: [0, -14],
|
|
})
|
|
poiIconCache.set(category, icon)
|
|
return icon
|
|
}
|
|
|
|
// 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)
|
|
interface BoundsControllerProps {
|
|
hasDayDetail?: boolean
|
|
places: Place[]
|
|
fitKey: number
|
|
paddingOpts: L.FitBoundsOptions
|
|
}
|
|
|
|
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]) // 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: `<div style="
|
|
width:60px;height:60px;
|
|
transform:rotate(${position.heading}deg);transition:transform 120ms ease-out;
|
|
background:conic-gradient(from -30deg, rgba(59,130,246,0) 0deg, rgba(59,130,246,0.35) 15deg, rgba(59,130,246,0) 60deg, rgba(59,130,246,0) 360deg);
|
|
border-radius:50%;
|
|
-webkit-mask:radial-gradient(circle, transparent 12px, black 13px);
|
|
mask:radial-gradient(circle, transparent 12px, black 13px);
|
|
pointer-events:none;
|
|
"></div>`,
|
|
})
|
|
|
|
return (
|
|
<>
|
|
{position.accuracy < 500 && (
|
|
<Circle
|
|
center={[position.lat, position.lng]}
|
|
radius={position.accuracy}
|
|
pathOptions={{ color: '#3b82f6', fillColor: '#3b82f6', fillOpacity: 0.12, weight: 1, opacity: 0.35 }}
|
|
interactive={false}
|
|
/>
|
|
)}
|
|
{headingIcon && (
|
|
<Marker
|
|
position={[position.lat, position.lng]}
|
|
icon={headingIcon}
|
|
interactive={false}
|
|
zIndexOffset={900}
|
|
/>
|
|
)}
|
|
<CircleMarker
|
|
center={[position.lat, position.lng]}
|
|
radius={8}
|
|
pathOptions={{ color: 'white', fillColor: '#3b82f6', fillOpacity: 1, weight: 3 }}
|
|
interactive={false}
|
|
/>
|
|
</>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<Marker
|
|
position={[place.lat, place.lng]}
|
|
icon={icon}
|
|
eventHandlers={{
|
|
click: () => 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,
|
|
pois = [] as Poi[],
|
|
onPoiClick,
|
|
onViewportChange,
|
|
}: any) {
|
|
const poiMarkers = useMemo(() => (pois as Poi[]).map((poi: Poi) => (
|
|
<Marker
|
|
key={`poi-${poi.osm_id}`}
|
|
position={[poi.lat, poi.lng]}
|
|
icon={createPoiIcon(poi.category)}
|
|
zIndexOffset={500}
|
|
eventHandlers={{ click: () => onPoiClick?.(poi) }}
|
|
>
|
|
<Tooltip direction="top" offset={[0, -10]} opacity={1} className="map-tooltip">{poi.name}</Tooltip>
|
|
</Marker>
|
|
)), [pois, onPoiClick])
|
|
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((): L.FitBoundsOptions => {
|
|
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 <Tooltip>)
|
|
const [hoveredPlace, setHoveredPlace] = useState<any>(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<Record<string, string>>(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<Record<string, string>>({})
|
|
const thumbRafRef = useRef<number | null>(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: `<div class="marker-cluster-custom" style="width:${size}px;height:${size}px;"><span>${count}</span></div>`,
|
|
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 (
|
|
<MemoMarker
|
|
key={place.id}
|
|
place={place}
|
|
isSelected={isSelected}
|
|
orderNumbers={orderNumbers}
|
|
photoUrl={photoUrl}
|
|
onClickPlace={handleMarkerClick}
|
|
onHover={handleMarkerHover}
|
|
onHoverOut={handleMarkerHoverOut}
|
|
/>
|
|
)
|
|
}), [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 [(
|
|
<Polyline
|
|
key={`gpx-${place.id}`}
|
|
positions={coords}
|
|
color={place.category_color || '#3b82f6'}
|
|
weight={3.5}
|
|
opacity={0.75}
|
|
/>
|
|
)]
|
|
} 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 (
|
|
<>
|
|
<div className="w-full h-full relative">
|
|
<MapContainer
|
|
id="trek-map"
|
|
center={center}
|
|
zoom={zoom}
|
|
zoomControl={false}
|
|
className="w-full h-full bg-[#e5e7eb]"
|
|
>
|
|
<TileLayer
|
|
url={tileUrl}
|
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
|
maxZoom={19}
|
|
keepBuffer={8}
|
|
updateWhenZooming={false}
|
|
updateWhenIdle={true}
|
|
referrerPolicy="strict-origin-when-cross-origin"
|
|
/>
|
|
|
|
<MapController center={center} zoom={zoom} />
|
|
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} fitKey={fitKey} paddingOpts={paddingOpts} hasDayDetail={hasDayDetail} />
|
|
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
|
|
<MapClickHandler onClick={onMapClick} />
|
|
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
|
|
<ViewportController onViewportChange={onViewportChange} />
|
|
<LeafletLocationLayer position={userPosition} mode={trackingMode} />
|
|
|
|
<MarkerClusterGroup
|
|
chunkedLoading
|
|
chunkInterval={30}
|
|
chunkDelay={0}
|
|
maxClusterRadius={30}
|
|
disableClusteringAtZoom={11}
|
|
spiderfyOnMaxZoom
|
|
showCoverageOnHover={false}
|
|
zoomToBoundsOnClick
|
|
animate={false}
|
|
iconCreateFunction={clusterIconCreateFunction}
|
|
>
|
|
{markers}
|
|
</MarkerClusterGroup>
|
|
|
|
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded. */}
|
|
{route && route.length > 0 && route.flatMap((seg, i) => seg.length > 1 ? [
|
|
<Polyline
|
|
key={`${i}-casing`}
|
|
positions={seg}
|
|
pathOptions={{ color: '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
|
/>,
|
|
<Polyline
|
|
key={`${i}-core`}
|
|
positions={seg}
|
|
pathOptions={{ color: '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
|
/>,
|
|
] : [])}
|
|
|
|
{/* GPX imported route geometries */}
|
|
{gpxPolylines}
|
|
|
|
<ReservationOverlay
|
|
reservations={visibleReservations}
|
|
showConnections
|
|
showStats={showReservationStats}
|
|
onEndpointClick={onReservationClick}
|
|
/>
|
|
|
|
{poiMarkers}
|
|
</MapContainer>
|
|
{isMobile && <LocationButton
|
|
mode={trackingMode}
|
|
error={trackingError}
|
|
onClick={cycleTrackingMode}
|
|
bottomOffset={locationButtonBottom as unknown as number}
|
|
/>}
|
|
</div>
|
|
|
|
{TooltipOverlay && (
|
|
<div data-testid="tooltip" style={{
|
|
position: 'fixed',
|
|
left: tooltipPos.x + 14,
|
|
top: tooltipPos.y - 10,
|
|
zIndex: 9999,
|
|
pointerEvents: 'none',
|
|
background: 'white',
|
|
borderRadius: 8,
|
|
boxShadow: '0 2px 10px rgba(0,0,0,0.15)',
|
|
padding: '6px 10px',
|
|
fontFamily: "var(--font-system)",
|
|
maxWidth: 220,
|
|
whiteSpace: 'nowrap',
|
|
}}>
|
|
<div style={{ fontWeight: 600, fontSize: 12, color: '#111827', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
{hoveredPlace.name}
|
|
</div>
|
|
{hoveredPlace.category_name && CatIcon && (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
|
|
<CatIcon size={10} style={{ color: hoveredPlace.category_color || '#6b7280', flexShrink: 0 }} />
|
|
<span style={{ fontSize: 11, color: '#6b7280' }}>{hoveredPlace.category_name}</span>
|
|
</div>
|
|
)}
|
|
{hoveredPlace.address && (
|
|
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
{hoveredPlace.address}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
})
|