mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
e224befde7
* feat(planner): reorder days in a modal instead of a dropdown The day-reorder control opened a small anchored dropdown; move it into the shared Modal (portal, dimmed backdrop, Esc/backdrop close) so it matches the Add activity dialog. Drag handles, up/down arrows and the day badges are unchanged. * feat(map): explore reliability, Mapbox popups + compass, region-biased search POI explore: clamp oversized viewports, query the Overpass mirrors in parallel (first valid response wins) with a per-request timeout and a short-lived cache, and surface a retry when every mirror fails - so it returns results at any zoom instead of timing out. Mapbox renderer: add the place/POI hover popups (name, category, address, photo) the Leaflet map already had, plus a compass pill next to the explore pill that resets the view to north. /api/maps/search: accept an optional locationBias to fix foreign-region bias and expose Google's place types in the result. * feat(dashboard): list-view and mobile polish Use the Archived status label for the filter and show Open dates for trips without dates; drop the unused settings button next to the view toggle. Desktop list view renders the date as a stat-style block separated from the counts. Mobile list rows are stacked (slim cover banner + centred date), trip actions stay visible (touch has no hover), and the hero card's hover lift is disabled on touch; small spacing fix under the sidebar. * feat: small community-requested options Raise the plan-note subtitle limit to 250 characters and add more note icons. Expose is_archived and cover_image on the update_trip MCP tool. Add place coordinates to the PDF export. Allow creating a category from an existing to-do, and add a show/hide toggle on the admin password fields. * test(shared): bump day-note subtitle limit assertion to 250 * test: align specs with the new search param order and archive label Keep lang as the 3rd positional arg of the maps search controller so the existing unit test stays valid, and forward locationBias as the 4th. Add the now-used Popup to the MapViewGL mapbox mock, switch the dashboard archive-filter query to the Archived label, and expect the 4-arg search call.
714 lines
30 KiB
TypeScript
714 lines
30 KiB
TypeScript
import { useEffect, useRef, useMemo, useState, createElement } from 'react'
|
|
import { renderToStaticMarkup } from 'react-dom/server'
|
|
import mapboxgl from 'mapbox-gl'
|
|
import 'mapbox-gl/dist/mapbox-gl.css'
|
|
import { useSettingsStore } from '../../store/settingsStore'
|
|
import { useAuthStore } from '../../store/authStore'
|
|
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
|
|
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
|
|
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup'
|
|
import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox'
|
|
import { ReservationMapboxOverlay } from './reservationsMapbox'
|
|
import LocationButton from './LocationButton'
|
|
import { useGeolocation } from '../../hooks/useGeolocation'
|
|
import type { Place, Reservation } from '../../types'
|
|
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
|
|
import { buildPlacePopupHtml, buildPoiPopupHtml } from './placePopup'
|
|
|
|
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 '' }
|
|
}
|
|
|
|
interface RouteSegment {
|
|
mid: [number, number]
|
|
from: [number, number]
|
|
to: [number, number]
|
|
walkingText?: string
|
|
drivingText?: string
|
|
}
|
|
|
|
interface Props {
|
|
places: Place[]
|
|
dayPlaces?: Place[]
|
|
route?: [number, number][][] | null
|
|
routeSegments?: RouteSegment[]
|
|
selectedPlaceId?: number | null
|
|
onMarkerClick?: (id: number) => void
|
|
onMapClick?: (info: { latlng: { lat: number; lng: number } }) => void
|
|
onMapContextMenu?: ((e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => void) | null
|
|
center?: [number, number]
|
|
zoom?: number
|
|
fitKey?: number | null
|
|
dayOrderMap?: Record<number, number[] | null>
|
|
leftWidth?: number
|
|
rightWidth?: number
|
|
hasInspector?: boolean
|
|
hasDayDetail?: boolean
|
|
reservations?: Reservation[]
|
|
visibleConnectionIds?: number[]
|
|
showReservationStats?: boolean
|
|
onReservationClick?: (reservationId: number) => void
|
|
pois?: Poi[]
|
|
onPoiClick?: (poi: Poi) => void
|
|
onViewportChange?: (bbox: { south: number; west: number; north: number; east: number }) => void
|
|
onMapReady?: (map: mapboxgl.Map | null) => void
|
|
}
|
|
|
|
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
|
|
const size = selected ? 44 : 36
|
|
const borderColor = selected ? '#111827' : (place.category_color || 'white')
|
|
const borderWidth = selected ? 3 : 2.5
|
|
const shadow = selected
|
|
? '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'
|
|
|
|
// The visual circle is `size` + 2*border on each side. To make the
|
|
// mapbox `anchor: 'center'` land on the real visual middle of the marker
|
|
// (rather than just the inner content box), the wrapper has to be the
|
|
// full outer size. If we gave the wrapper only `size`, the border would
|
|
// bleed outside it and the route lines would appear slightly off.
|
|
const outer = size + borderWidth * 2
|
|
|
|
let badgeHtml = ''
|
|
if (orderNumbers && orderNumbers.length > 0) {
|
|
const label = orderNumbers.join(' · ')
|
|
badgeHtml = `<span style="
|
|
position:absolute;bottom:-2px;right:-2px;
|
|
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>`
|
|
}
|
|
|
|
const wrap = document.createElement('div')
|
|
// Do NOT set `position: relative` here — mapbox-gl ships
|
|
// `.mapboxgl-marker { position: absolute }` and relies on it. An inline
|
|
// `position: relative` here overrides the class, turns every marker into
|
|
// a static block element, and stacks them in document order inside the
|
|
// canvas container. The result looks exactly like "markers drift as the
|
|
// map zooms" because each marker's transform is then applied relative
|
|
// to its stacked slot, not to the map viewport.
|
|
wrap.style.cssText = `width:${outer}px;height:${outer}px;cursor:pointer;`
|
|
|
|
const hasPhoto = photoUrl && (photoUrl.startsWith('data:') || photoUrl.startsWith('/api/maps/place-photo/'))
|
|
if (hasPhoto) {
|
|
wrap.innerHTML = `
|
|
<div style="
|
|
position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
|
|
width:${size}px;height:${size}px;border-radius:50%;
|
|
border:${borderWidth}px solid ${borderColor};
|
|
box-shadow:${shadow};
|
|
overflow:hidden;background:${bgColor};
|
|
box-sizing:content-box;
|
|
">
|
|
<img src="${photoUrl}" width="${size}" height="${size}" style="display:block;border-radius:50%;object-fit:cover;" />
|
|
</div>
|
|
${badgeHtml}
|
|
`
|
|
} else {
|
|
wrap.innerHTML = `
|
|
<div style="
|
|
position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
|
|
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;
|
|
box-sizing:content-box;
|
|
">
|
|
${categoryIconSvg(place.category_icon, selected ? 18 : 15)}
|
|
</div>
|
|
${badgeHtml}
|
|
`
|
|
}
|
|
return wrap
|
|
}
|
|
|
|
// Small coloured pin for an OSM "explore" POI (matches the pill category colour).
|
|
function createPoiMarkerElement(category: string): HTMLDivElement {
|
|
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 el = document.createElement('div')
|
|
el.style.cssText = 'width:26px;height:26px;cursor:pointer;'
|
|
el.innerHTML = `<div style="width:26px;height:26px;border-radius:50%;background:${color};border:2px solid #fff;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;box-sizing:border-box;">${svg}</div>`
|
|
return el
|
|
}
|
|
|
|
export function MapViewGL({
|
|
places = [],
|
|
dayPlaces = [],
|
|
route = null,
|
|
routeSegments = [],
|
|
selectedPlaceId = null,
|
|
onMarkerClick,
|
|
onMapClick,
|
|
onMapContextMenu = null,
|
|
center = [48.8566, 2.3522],
|
|
zoom = 10,
|
|
fitKey = 0,
|
|
dayOrderMap = {},
|
|
leftWidth = 0,
|
|
rightWidth = 0,
|
|
hasInspector = false,
|
|
hasDayDetail = false,
|
|
reservations = [],
|
|
visibleConnectionIds = [],
|
|
showReservationStats = false,
|
|
onReservationClick,
|
|
pois = [],
|
|
onPoiClick,
|
|
onViewportChange,
|
|
onMapReady,
|
|
}: Props) {
|
|
const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
|
|
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
|
|
const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false)
|
|
const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true)
|
|
const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) !== false
|
|
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
|
|
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
|
|
const [mapReady, setMapReady] = useState(false)
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const mapRef = useRef<mapboxgl.Map | null>(null)
|
|
const markersRef = useRef<Map<number, mapboxgl.Marker>>(new Map())
|
|
const locationMarkerRef = useRef<LocationMarkerHandle | null>(null)
|
|
const reservationOverlayRef = useRef<ReservationMapboxOverlay | null>(null)
|
|
// Refs so the reservation overlay always sees the latest callback /
|
|
// options without forcing a full overlay rebuild on every prop change.
|
|
const onReservationClickRef = useRef(onReservationClick)
|
|
onReservationClickRef.current = onReservationClick
|
|
const poiMarkersRef = useRef<mapboxgl.Marker[]>([])
|
|
// Single reusable hover popup (name/category/address card) shared by planned
|
|
// places and POI markers — mirrors the Leaflet map's hover tooltip.
|
|
const popupRef = useRef<mapboxgl.Popup | null>(null)
|
|
const onPoiClickRef = useRef(onPoiClick)
|
|
onPoiClickRef.current = onPoiClick
|
|
const onViewportChangeRef = useRef(onViewportChange)
|
|
onViewportChangeRef.current = onViewportChange
|
|
const onMapReadyRef = useRef(onMapReady)
|
|
onMapReadyRef.current = onMapReady
|
|
const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation()
|
|
const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu })
|
|
onClickRefs.current.marker = onMarkerClick
|
|
onClickRefs.current.map = onMapClick
|
|
onClickRefs.current.context = onMapContextMenu
|
|
|
|
// Build/rebuild the map on style/token/3d change
|
|
useEffect(() => {
|
|
if (!containerRef.current || !mapboxToken) return
|
|
mapboxgl.accessToken = mapboxToken
|
|
|
|
const map = new mapboxgl.Map({
|
|
container: containerRef.current,
|
|
style: mapboxStyle,
|
|
center: [center[1], center[0]],
|
|
zoom,
|
|
pitch: mapbox3d ? 45 : 0,
|
|
attributionControl: true,
|
|
antialias: mapboxQuality,
|
|
projection: mapboxQuality ? 'globe' : 'mercator',
|
|
})
|
|
mapRef.current = map
|
|
popupRef.current = new mapboxgl.Popup({
|
|
closeButton: false,
|
|
closeOnClick: false,
|
|
offset: 18,
|
|
maxWidth: '240px',
|
|
className: 'trek-map-popup',
|
|
})
|
|
// Hand the map out so the trip planner can render its own compass pill next to
|
|
// the POI pill (a custom round control instead of Mapbox's default top-right one).
|
|
onMapReadyRef.current?.(map)
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
;(window as any).__trek_map = map
|
|
|
|
map.on('load', () => {
|
|
if (mapbox3d) {
|
|
// Terrain is only valuable on satellite styles — on clean vector
|
|
// styles it makes route lines drift off the HTML markers because
|
|
// the lines snap to DEM height while markers stay at sea level.
|
|
if (!isStandardFamily(mapboxStyle) && wantsTerrain(mapboxStyle)) addTerrainAndSky(map)
|
|
if (supportsCustom3d(mapboxStyle)) {
|
|
const dark = document.documentElement.classList.contains('dark')
|
|
addCustom3dBuildings(map, dark)
|
|
}
|
|
}
|
|
|
|
// Mapbox Standard ships its own DEM-based terrain that kicks in
|
|
// below zoom 13.7. HTML markers project at sea level, so when the
|
|
// terrain exaggeration ramps up at lower zooms the markers drift
|
|
// away from the 3D buildings and route lines they belong to. The
|
|
// non-satellite Standard style still looks great without terrain,
|
|
// so flatten it out to keep markers pinned. (Satellite variants
|
|
// are left alone — the DEM is what gives them their character.)
|
|
if (mapboxStyle === 'mapbox://styles/mapbox/standard') {
|
|
try { map.setTerrain(null) } catch { /* noop */ }
|
|
}
|
|
// initial route source — kept around so updates can setData() cheaply
|
|
if (!map.getSource('trip-route')) {
|
|
map.addSource('trip-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
|
// Apple-Maps style: a darker-blue casing under a bright-blue core, both
|
|
// rounded. Casing is added first so it sits beneath the core line.
|
|
map.addLayer({
|
|
id: 'trip-route-casing',
|
|
type: 'line',
|
|
source: 'trip-route',
|
|
paint: { 'line-color': '#0a5cc2', 'line-width': 8 },
|
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
|
})
|
|
map.addLayer({
|
|
id: 'trip-route-line',
|
|
type: 'line',
|
|
source: 'trip-route',
|
|
paint: { 'line-color': '#0a84ff', 'line-width': 5 },
|
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
|
})
|
|
}
|
|
// gpx geometries source (place.route_geometry)
|
|
if (!map.getSource('trip-gpx')) {
|
|
map.addSource('trip-gpx', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
|
map.addLayer({
|
|
id: 'trip-gpx-line',
|
|
type: 'line',
|
|
source: 'trip-gpx',
|
|
paint: {
|
|
'line-color': ['coalesce', ['get', 'color'], '#3b82f6'],
|
|
'line-width': 3.5,
|
|
'line-opacity': 0.75,
|
|
},
|
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
|
})
|
|
}
|
|
// Signal that sources/layers are attached so overlay effects can
|
|
// safely add their own sources. Style rebuilds reset this via the
|
|
// cleanup below.
|
|
setMapReady(true)
|
|
})
|
|
|
|
map.on('click', (e) => {
|
|
const t = e.originalEvent.target as HTMLElement
|
|
if (t.closest('.mapboxgl-marker')) return // markers handle their own click
|
|
onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } })
|
|
})
|
|
// Emit the viewport bbox (pan/zoom + once on first idle) so the POI-explore
|
|
// pill can fetch OSM places for the visible area.
|
|
const emitViewport = () => {
|
|
const b = map.getBounds()
|
|
onViewportChangeRef.current?.({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() })
|
|
}
|
|
map.on('moveend', emitViewport)
|
|
map.once('idle', emitViewport)
|
|
// In the mapbox-gl map the right mouse button is reserved for the
|
|
// built-in rotate/pitch gesture, so we bind the "add place" action
|
|
// to the middle mouse button (button === 1) instead.
|
|
const canvas = map.getCanvasContainer()
|
|
const onAuxDown = (ev: MouseEvent) => {
|
|
if (ev.button !== 1) return
|
|
ev.preventDefault()
|
|
const rect = canvas.getBoundingClientRect()
|
|
const lngLat = map.unproject([ev.clientX - rect.left, ev.clientY - rect.top])
|
|
onClickRefs.current.context?.({
|
|
latlng: { lat: lngLat.lat, lng: lngLat.lng },
|
|
originalEvent: ev,
|
|
})
|
|
}
|
|
// Also suppress the browser's native auxclick menu on middle-click.
|
|
const onAuxClick = (ev: MouseEvent) => {
|
|
if (ev.button === 1) ev.preventDefault()
|
|
}
|
|
canvas.addEventListener('mousedown', onAuxDown)
|
|
canvas.addEventListener('auxclick', onAuxClick)
|
|
|
|
// Drop follow mode if the user pans the map manually — matches the
|
|
// Apple Maps behaviour where the blue dot stays but the map no longer
|
|
// chases it until the user taps the button again.
|
|
map.on('dragstart', () => {
|
|
setTrackingMode(prev => prev === 'follow' ? 'show' : prev)
|
|
})
|
|
|
|
// Keep HTML markers glued to the terrain / 3D ground. Mapbox projects
|
|
// HTML markers at altitude=0 (sea level) by default, so as soon as the
|
|
// style has a terrain DEM (Standard, Standard Satellite, custom terrain)
|
|
// the markers drift off the places when the camera pitches or zooms —
|
|
// the buildings rise from DEM height, the marker stays at sea level,
|
|
// and the pixel offset grows as the perspective changes.
|
|
//
|
|
// Pushing `[lng, lat, elevation]` through setLngLat tells mapbox to
|
|
// project the marker onto the same ground the route line sits on.
|
|
// We re-apply this every render because DEM tiles stream in async.
|
|
let lastAltUpdate = 0
|
|
const syncMarkerAltitudes = () => {
|
|
const now = performance.now()
|
|
if (now - lastAltUpdate < 80) return // ~12Hz is plenty
|
|
lastAltUpdate = now
|
|
markersRef.current.forEach(marker => {
|
|
const ll = marker.getLngLat()
|
|
let alt = 0
|
|
try {
|
|
const e = map.queryTerrainElevation([ll.lng, ll.lat])
|
|
if (typeof e === 'number' && Number.isFinite(e)) alt = e
|
|
} catch { /* terrain not ready */ }
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const curAlt = (ll as any).alt ?? 0
|
|
if (Math.abs(curAlt - alt) > 0.25) {
|
|
// mapbox-gl accepts a third altitude element at runtime, but its typings
|
|
// only model the 2-tuple form, so cast to LngLatLike.
|
|
marker.setLngLat([ll.lng, ll.lat, alt] as unknown as mapboxgl.LngLatLike)
|
|
}
|
|
})
|
|
}
|
|
map.on('render', syncMarkerAltitudes)
|
|
|
|
return () => {
|
|
canvas.removeEventListener('mousedown', onAuxDown)
|
|
canvas.removeEventListener('auxclick', onAuxClick)
|
|
markersRef.current.forEach(m => m.remove())
|
|
markersRef.current.clear()
|
|
if (popupRef.current) { popupRef.current.remove(); popupRef.current = null }
|
|
onMapReadyRef.current?.(null)
|
|
if (reservationOverlayRef.current) {
|
|
reservationOverlayRef.current.destroy()
|
|
reservationOverlayRef.current = null
|
|
}
|
|
if (locationMarkerRef.current) {
|
|
locationMarkerRef.current.destroy()
|
|
locationMarkerRef.current = null
|
|
}
|
|
try { map.remove() } catch { /* noop */ }
|
|
mapRef.current = null
|
|
setMapReady(false)
|
|
}
|
|
}, [mapboxStyle, mapboxToken, mapbox3d]) // rebuild on style changes only
|
|
|
|
// Photo loading — mirrors the Leaflet MapView. Updates via RAF to batch
|
|
// simultaneous thumb arrivals into one re-render.
|
|
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]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// Reconcile markers with places + photos. Rebuilds the DOM node when any
|
|
// visual input changes so photos, selection state and order badges stay
|
|
// in sync.
|
|
useEffect(() => {
|
|
const map = mapRef.current
|
|
if (!map) return
|
|
// Markers are about to be rebuilt; drop any open hover popup first. A marker
|
|
// recreated under the pointer (e.g. when its photo streams in) never fires
|
|
// mouseleave, which would otherwise leave the popup orphaned on the map.
|
|
popupRef.current?.remove()
|
|
const ids = new Set(places.map(p => p.id))
|
|
|
|
markersRef.current.forEach((marker, id) => {
|
|
if (!ids.has(id)) {
|
|
marker.remove()
|
|
markersRef.current.delete(id)
|
|
}
|
|
})
|
|
|
|
places.forEach(place => {
|
|
if (!place.lat || !place.lng) return
|
|
const orderNumbers = dayOrderMap[place.id] ?? null
|
|
const pck = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
|
const photoUrl = (pck && photoUrls[pck]) || place.image_url || null
|
|
const selected = place.id === selectedPlaceId
|
|
const el = createMarkerElement(place as Place & { category_color?: string; category_icon?: string }, photoUrl, orderNumbers, selected)
|
|
el.addEventListener('click', (ev) => {
|
|
ev.stopPropagation()
|
|
onClickRefs.current.marker?.(place.id)
|
|
})
|
|
el.addEventListener('mouseenter', () => {
|
|
popupRef.current?.setLngLat([place.lng, place.lat])
|
|
.setHTML(buildPlacePopupHtml(place as Place & { category_color?: string; category_icon?: string; category_name?: string }, photoUrl))
|
|
.addTo(map)
|
|
})
|
|
el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
|
|
// Recreate marker each time rather than patching internal state —
|
|
// mapbox-gl's internal _element bookkeeping breaks under DOM swaps.
|
|
const existing = markersRef.current.get(place.id)
|
|
if (existing) existing.remove()
|
|
// Default (viewport-aligned) anchors keep the marker parallel to the
|
|
// screen so its pixel centre lines up with the route line at any
|
|
// pitch. Tried `pitchAlignment: 'map'` to snap markers onto terrain,
|
|
// but it rotates the element by the pitch angle and visually offsets
|
|
// the anchor by ~100px at 45° tilt, which caused the observed drift.
|
|
const m = new mapboxgl.Marker({ element: el, anchor: 'center' })
|
|
.setLngLat([place.lng, place.lat])
|
|
.addTo(map)
|
|
markersRef.current.set(place.id, m)
|
|
})
|
|
}, [places, selectedPlaceId, dayOrderMap, photoUrls])
|
|
|
|
// Reconcile OSM "explore" POI markers (imperative, kept separate from the
|
|
// planned-place markers so they don't cluster or get confused with them).
|
|
useEffect(() => {
|
|
const map = mapRef.current
|
|
if (!map || !mapReady) return
|
|
popupRef.current?.remove() // same orphan-popup guard as the place markers
|
|
poiMarkersRef.current.forEach(m => m.remove())
|
|
poiMarkersRef.current = []
|
|
for (const poi of (pois as Poi[])) {
|
|
const el = createPoiMarkerElement(poi.category)
|
|
el.addEventListener('mouseenter', () => {
|
|
popupRef.current?.setLngLat([poi.lng, poi.lat]).setHTML(buildPoiPopupHtml(poi)).addTo(map)
|
|
})
|
|
el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
|
|
el.addEventListener('click', (ev) => { ev.stopPropagation(); onPoiClickRef.current?.(poi) })
|
|
const m = new mapboxgl.Marker({ element: el, anchor: 'center' }).setLngLat([poi.lng, poi.lat]).addTo(map)
|
|
poiMarkersRef.current.push(m)
|
|
}
|
|
}, [pois, mapReady])
|
|
|
|
// Update route geojson
|
|
useEffect(() => {
|
|
const map = mapRef.current
|
|
if (!map) return
|
|
const src = map.getSource('trip-route') as mapboxgl.GeoJSONSource | undefined
|
|
if (!src) return
|
|
const features = (route || []).filter(seg => seg && seg.length > 1).map(seg => ({
|
|
type: 'Feature' as const,
|
|
properties: {},
|
|
geometry: { type: 'LineString' as const, coordinates: seg.map(([lat, lng]) => [lng, lat]) },
|
|
}))
|
|
src.setData({ type: 'FeatureCollection', features })
|
|
}, [route, mapReady])
|
|
|
|
// Travel times now live in the day sidebar (per-segment connectors), not on the map.
|
|
|
|
// Update GPX geometries
|
|
useEffect(() => {
|
|
const map = mapRef.current
|
|
if (!map) return
|
|
const src = map.getSource('trip-gpx') as mapboxgl.GeoJSONSource | undefined
|
|
if (!src) return
|
|
const features = 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 [{
|
|
type: 'Feature' as const,
|
|
properties: { color: (place as Place & { category_color?: string }).category_color || '#3b82f6' },
|
|
geometry: { type: 'LineString' as const, coordinates: coords.map(([lat, lng]) => [lng, lat]) },
|
|
}]
|
|
} catch { return [] }
|
|
})
|
|
src.setData({ type: 'FeatureCollection', features })
|
|
}, [places, mapReady])
|
|
|
|
// Reservation overlay — mirrors the Leaflet ReservationOverlay: great-
|
|
// circle arcs for flights/cruises, straight lines for trains/cars,
|
|
// clickable endpoint badges, rotating mid-arc stats label for flights.
|
|
// The overlay is a small imperative manager that owns its own source,
|
|
// layer, and HTML markers; it lives next to the map for the map's
|
|
// lifetime and is rebuilt when the style/token/3d effect rebuilds.
|
|
//
|
|
// `visibleConnectionIds` is driven by the per-reservation toggle in
|
|
// DayPlanSidebar — nothing is rendered until the user enables a
|
|
// booking's route, matching the Leaflet MapView's behaviour.
|
|
const visibleReservations = useMemo(() => {
|
|
if (!visibleConnectionIds || visibleConnectionIds.length === 0) return []
|
|
const set = new Set(visibleConnectionIds)
|
|
return reservations.filter(r => set.has(r.id))
|
|
}, [reservations, visibleConnectionIds])
|
|
|
|
useEffect(() => {
|
|
const map = mapRef.current
|
|
if (!map || !mapReady) return
|
|
if (!reservationOverlayRef.current) {
|
|
reservationOverlayRef.current = new ReservationMapboxOverlay(map, {
|
|
showConnections: true,
|
|
showStats: showReservationStats,
|
|
showEndpointLabels,
|
|
onEndpointClick: (id) => onReservationClickRef.current?.(id),
|
|
})
|
|
}
|
|
reservationOverlayRef.current.update(visibleReservations, {
|
|
showConnections: true,
|
|
showStats: showReservationStats,
|
|
showEndpointLabels,
|
|
onEndpointClick: (id) => onReservationClickRef.current?.(id),
|
|
})
|
|
}, [visibleReservations, showReservationStats, showEndpointLabels, mapReady])
|
|
|
|
// Fit bounds on fitKey change — matches the Leaflet BoundsController
|
|
const paddingOpts = useMemo(() => {
|
|
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
|
if (isMobile) return { top: 40, right: 20, bottom: 40, left: 20 }
|
|
const top = 60
|
|
const bottom = hasInspector ? 320 : hasDayDetail ? 280 : 60
|
|
return { top, right: rightWidth + 40, bottom, left: leftWidth + 40 }
|
|
}, [leftWidth, rightWidth, hasInspector, hasDayDetail])
|
|
|
|
const prevFitKey = useRef(-1)
|
|
useEffect(() => {
|
|
if (fitKey === prevFitKey.current) return
|
|
prevFitKey.current = fitKey
|
|
const map = mapRef.current
|
|
if (!map) return
|
|
const target = dayPlaces.length > 0 ? dayPlaces : places
|
|
const valid = target.filter(p => p.lat && p.lng)
|
|
if (valid.length === 0) return
|
|
const bounds = new mapboxgl.LngLatBounds()
|
|
valid.forEach(p => bounds.extend([p.lng, p.lat]))
|
|
const run = () => {
|
|
try {
|
|
map.fitBounds(bounds, {
|
|
padding: paddingOpts,
|
|
maxZoom: 15,
|
|
pitch: mapbox3d ? 45 : 0,
|
|
duration: 400,
|
|
})
|
|
} catch { /* noop */ }
|
|
}
|
|
if (map.loaded()) run()
|
|
else map.once('load', run)
|
|
}, [fitKey]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// flyTo selected place
|
|
useEffect(() => {
|
|
const map = mapRef.current
|
|
if (!map || !selectedPlaceId) return
|
|
const target = places.find(p => p.id === selectedPlaceId) || dayPlaces.find(p => p.id === selectedPlaceId)
|
|
if (!target?.lat || !target?.lng) return
|
|
try {
|
|
map.flyTo({
|
|
center: [target.lng, target.lat],
|
|
zoom: Math.max(map.getZoom(), 14),
|
|
pitch: mapbox3d ? 45 : 0,
|
|
duration: 400,
|
|
// Account for the side panels and the bottom inspector / day-detail panel
|
|
// so the selected pin lands in the centre of the *visible* map area rather
|
|
// than the geometric centre (where the bottom panel would cover it).
|
|
padding: paddingOpts,
|
|
})
|
|
} catch { /* noop */ }
|
|
}, [selectedPlaceId, mapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// External center/zoom prop changes — jump without animation
|
|
useEffect(() => {
|
|
const map = mapRef.current
|
|
if (!map) return
|
|
try { map.jumpTo({ center: [center[1], center[0]], zoom }) } catch { /* noop */ }
|
|
}, [center[0], center[1]]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// Blue dot rendering + follow-mode camera. Attach the marker lazily the
|
|
// first time a fix arrives so the layers sit on top of everything else
|
|
// added so far, and destroy it when tracking is turned off.
|
|
useEffect(() => {
|
|
const map = mapRef.current
|
|
if (!map) return
|
|
if (trackingMode === 'off') {
|
|
if (locationMarkerRef.current) {
|
|
locationMarkerRef.current.update(null)
|
|
}
|
|
return
|
|
}
|
|
if (!userPosition) return
|
|
const apply = () => {
|
|
if (!locationMarkerRef.current) locationMarkerRef.current = attachLocationMarker(map)
|
|
locationMarkerRef.current.update(userPosition)
|
|
if (trackingMode === 'follow') {
|
|
// easeTo is gentler than flyTo for continuous updates
|
|
try {
|
|
map.easeTo({
|
|
center: [userPosition.lng, userPosition.lat],
|
|
bearing: userPosition.heading ?? map.getBearing(),
|
|
zoom: Math.max(map.getZoom(), 16),
|
|
duration: 350,
|
|
})
|
|
} catch { /* noop */ }
|
|
}
|
|
}
|
|
if (map.loaded()) apply()
|
|
else map.once('load', apply)
|
|
}, [userPosition, trackingMode])
|
|
|
|
if (!mapboxToken) {
|
|
return (
|
|
<div className="w-full h-full flex items-center justify-center bg-zinc-100 dark:bg-zinc-800 text-center px-6">
|
|
<div className="text-sm text-zinc-500">
|
|
No Mapbox access token configured.<br />
|
|
<span className="text-xs">Settings → Map → Mapbox GL</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// 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 buttonBottom = 'calc(var(--bottom-nav-h, 84px) + 12px)'
|
|
|
|
return (
|
|
<div className="w-full h-full relative">
|
|
<div ref={containerRef} className="w-full h-full" />
|
|
{isMobile && (
|
|
<LocationButton
|
|
mode={trackingMode}
|
|
error={trackingError}
|
|
onClick={cycleTrackingMode}
|
|
bottomOffset={buttonBottom as unknown as number}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|