import { useEffect, useRef, useMemo, useState, createElement, useCallback } from 'react' import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag' import type { RoadtripVia } from '@trek/shared' import { useStableVias } from './viaMarkerState' import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors' import type { AlternativeOverlay } from '../Roadtrip/alternativeOverlays' import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker' import { renderIconMarkup } from '../../utils/iconMarkup' import type mapboxgl from 'mapbox-gl' import { useSettingsStore } from '../../store/settingsStore' import { useTranslation } from '../../i18n/TranslationContext' import { MapLayerSwitcher, type BaseLayer } from './MapLayerSwitcher' import { useAuthStore } from '../../store/authStore' import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService' import { isCustomPlaceImage, photoCacheKey } from './placePhoto' 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 { useTransportRoutes } from '../../hooks/useTransportRoutes' import { visibleRouteReservations } from '../../utils/reservationRoutes' import { safeHexColor } from '../../utils/safeColor' import { escapeHtml } from '@trek/shared' import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from './glProviders' import LocationButton from './LocationButton' import { useGeolocation } from '../../hooks/useGeolocation' import type { Day, Place, Reservation, RouteVia } from '../../types' import type { MapHoverInfo } from './mapHover' import { nightPauseMarker, NIGHT_PAUSE_MIN_ZOOM } from './nightPauseMarker' import { clusterPois, poiClusterMarkup, poiClusterList, POI_CLUSTER_DETAIL_ZOOM } from './poiClusters' import type { RoadtripHazard } from '@trek/shared' import { useHazardLayerGL } from './useHazardLayerGL' import { bindDayBoundaryDrag, type DayBoundaryControls } from './dayBoundaryDrag' import NightPauseTooltip from './NightPauseTooltip' import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories' import { resolveTrackColor, hasManualTrackColor } from './trackColors' import { buildPoiPopupHtml } from './placePopup' import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client' import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM } from '../../constants/mapDefaults' import { computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport' function categoryIconSvg(iconName: string | null | undefined, size: number): string { const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin'] try { return renderIconMarkup(createElement(IconComponent, { size, color: 'white', strokeWidth: 2.5 })) } catch { return '' } } // Marker grouping for the GL map (#1385): MapLibre/Mapbox can't show the rich // HTML photo markers *and* cluster them natively, so we feed the place points // into a clustered GeoJSON source. The cluster bubbles render as GL circles + // a count label; the individual rich HTML markers are then only drawn for the // points the source reports as currently unclustered. Grouping is always on, // matching the Leaflet map's MarkerClusterGroup. const PLACE_CLUSTER_SOURCE_ID = 'trip-place-clusters' const PLACE_CLUSTER_CIRCLE_LAYER_ID = 'trip-place-clusters-circle' const PLACE_CLUSTER_COUNT_LAYER_ID = 'trip-place-clusters-count' const PLACE_UNCLUSTERED_LAYER_ID = 'trip-place-unclustered-hit' const GPX_HIT_LAYER_ID = 'trip-gpx-hit' /** * The satellite base layer, the GL twin of the Leaflet one. * * Leaflet swaps its whole tile layer for the imagery; a GL map cannot, because the * basemap is a style with dozens of layers in it. So the imagery goes on as a raster * layer of its own, drawn over the style and under everything TREK adds — the route, * the pins and the tracks stay on top and stay legible, which is the same order the * Leaflet map ends up with. * * Same tokenless ESRI source as Leaflet, so the two look alike and neither needs a key. */ const SATELLITE_SOURCE_ID = 'trip-satellite' const SATELLITE_LAYER_ID = 'trip-satellite-raster' /** Everything TREK draws is prefixed; the imagery is inserted before the first of them. */ const OWN_LAYER_PREFIXES = ['trip-', 'trek-'] type PlaceWithCoords = Place & { lat: number; lng: number } function hasValidCoords(place: Place): place is PlaceWithCoords { return place.lat != null && place.lng != null && Number.isFinite(place.lat) && Number.isFinite(place.lng) } function isValidCoordinate(coord: [number, number] | null | undefined): coord is [number, number] { return !!coord && Number.isFinite(coord[0]) && Number.isFinite(coord[1]) } function buildPlaceClusterData(places: Place[]) { return { type: 'FeatureCollection' as const, features: places.filter(hasValidCoords).map(place => ({ type: 'Feature' as const, properties: { placeId: place.id }, geometry: { type: 'Point' as const, coordinates: [place.lng, place.lat] }, })), } } interface RouteSegment { mid: [number, number] from: [number, number] to: [number, number] walkingText?: string drivingText?: string } // Stable identities for the omitted collection props. An inline `= []` / `= {}` // default allocates a fresh object on every render, and these props sit in the // dependency arrays of the imperative reconcile effects below — so every render, // including one caused only by the hover tooltip's state, would tear down and // rebuild every marker. That is the "marker recreated under the cursor, // mouseleave never fires" case (#1404). const NO_PLACES: Place[] = [] const NO_ROUTE_VIAS: RouteVia[] = [] /** Stable empty default: a fresh array each render would refire the spur effect. */ const NO_ACCESS_LINES: { line: [[number, number], [number, number]]; meters: number }[] = [] const NO_ROUTE_SEGMENTS: RouteSegment[] = [] const NO_DAY_ORDER: Record = {} const NO_RESERVATIONS: Reservation[] = [] const NO_CONNECTION_IDS: number[] = [] const NO_POIS: Poi[] = [] const NO_DAYS: Day[] = [] interface Props { places: Place[] dayPlaces?: Place[] // Enables the plugin map contributions (markers + layers). Absent on surfaces // without a trip (CollectionMap), which naturally excludes them — same rule as // the Leaflet MapPluginMarkers. tripId?: number | string // Charging stops / rest areas a plugin route places on the drawn day route. routeVias?: RouteVia[] dayBoundaryControls?: DayBoundaryControls /** The dashed last bit to a place the road network does not reach. */ accessLines?: { line: [[number, number], [number, number]]; meters: number }[] route?: [number, number][][] | null /** * One colour pair per entry of `route`, or absent for the blue the route has always * been. Only the road trip passes these, and only while colouring by day is on. */ routeColors?: ({ line: string; casing: string } | undefined)[] | null routeSegments?: RouteSegment[] selectedPlaceId?: number | null onMarkerClick?: (id: number) => void hoverDisabled?: boolean onMapClick?: (info: { latlng: { lat: number; lng: number } }) => void onMapContextMenu?: ((e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent | TouchEvent }) => void) | null center?: [number, number] zoom?: number fitKey?: number | null dayOrderMap?: Record leftWidth?: number rightWidth?: number hasInspector?: boolean hasDayDetail?: boolean reservations?: Reservation[] visibleConnectionIds?: number[] showTransitRoutes?: boolean days?: Day[] selectedDayId?: number | null showReservationStats?: boolean onReservationClick?: (reservationId: number) => void pois?: Poi[] onPoiClick?: (poi: Poi) => void /** * A corridor hit dropped somewhere on the map, with the coordinate it landed on. * The caller decides whether that point is near enough to the drive to mean anything. */ onPoiDropOnRoute?: (osmId: string, lat: number, lng: number) => void /** A click on the drawn route, for putting a via point there (#1797). */ onRouteClick?: (lat: number, lng: number) => void /** The ways of driving one leg, drawn while the picker is open. */ alternativeRoutes?: AlternativeOverlay[] /** Which option is being considered, so it can be lit up in its own colour. */ activeAlternative?: number | null onChooseAlternative?: (index: number) => void /** Reports which option the pointer is over, so the list and the map agree. */ onHighlightAlternative?: (index: number | null) => void /** * An explicit stretch of map to frame, independent of the day being shown. * * `fitKey` cannot express this: it carries no coordinates, and each renderer decides * for itself that it means "the selected day". Weighing the ways of driving one leg * needs that leg on screen, which is neither the day nor the trip. */ focusPoints?: [number, number][] /** * Let markers stay apart longer than usual. * * A road trip is read along a line: two stops fifty kilometres apart on the same * motorway are the shape of the day, and merging them into one dot hides it. */ clusterLoosely?: boolean hazards?: RoadtripHazard[] /** Via points to draw as draggable handles, keyed by day (#1797). */ roadtripVias?: Record onMoveVia?: (dayId: number, id: number, lat: number, lng: number) => void onRemoveVia?: (dayId: number, id: number) => void onViewportChange?: (bbox: { south: number; west: number; north: number; east: number }) => void glProvider?: GlMapProvider /** * The GL engine, injected instead of imported. Both SDKs used to be pulled in * statically here, so a single 2.8 MB chunk carried mapbox-gl and maplibre-gl * together and every map user downloaded both while only one ever ran. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any gl: any // eslint-disable-next-line @typescript-eslint/no-explicit-any onMapReady?: (map: any | null) => void } /** How eagerly place markers merge into a cluster. */ const CLUSTER_RADIUS = 30 const CLUSTER_MAX_ZOOM = 10 /** The cluster source and the three layers over it. Extracted so the rebuild on a mode * change and the initial build cannot drift apart. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function addPlaceClusterLayers(map: any): void { map.addSource(PLACE_CLUSTER_SOURCE_ID, { type: 'geojson', data: { type: 'FeatureCollection', features: [] }, cluster: true, clusterRadius: CLUSTER_RADIUS, clusterMaxZoom: CLUSTER_MAX_ZOOM, }) map.addLayer({ id: PLACE_CLUSTER_CIRCLE_LAYER_ID, type: 'circle', source: PLACE_CLUSTER_SOURCE_ID, filter: ['has', 'point_count'], paint: { 'circle-color': '#111827', 'circle-opacity': 0.97, 'circle-radius': ['step', ['get', 'point_count'], 18, 10, 21, 50, 24], 'circle-stroke-width': 2.5, 'circle-stroke-color': 'rgba(255,255,255,0.9)', }, }) map.addLayer({ id: PLACE_CLUSTER_COUNT_LAYER_ID, type: 'symbol', source: PLACE_CLUSTER_SOURCE_ID, filter: ['has', 'point_count'], layout: { 'text-field': ['get', 'point_count_abbreviated'], 'text-size': 12, 'text-allow-overlap': true, }, paint: { 'text-color': '#ffffff', 'text-halo-color': 'rgba(17,24,39,0.35)', 'text-halo-width': 1, }, }) map.addLayer({ id: PLACE_UNCLUSTERED_LAYER_ID, type: 'circle', source: PLACE_CLUSTER_SOURCE_ID, filter: ['!', ['has', 'point_count']], paint: { 'circle-radius': 24, 'circle-opacity': 0, 'circle-stroke-opacity': 0, }, }) } /** * Puts the imagery on the map, or takes it off again. * * Built on demand rather than once at load: a style change drops every source the map * had, and a hot reload does the same. Guarded on its own absence, so the usual pass is * a no-op and only the visibility flips. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function applySatellite(map: any, on: boolean): void { try { if (!map.isStyleLoaded?.()) return if (!map.getSource(SATELLITE_SOURCE_ID)) { if (!on) return // Nothing to build while it is switched off. map.addSource(SATELLITE_SOURCE_ID, { type: 'raster', tiles: [SATELLITE_TILE_URL], tileSize: 256, maxzoom: SATELLITE_TILE_MAXZOOM, attribution: SATELLITE_TILE_ATTRIBUTION, }) // Under the first thing TREK draws, over everything the basemap style draws. // Without the anchor the imagery lands on top and buries the route. const layers = map.getStyle?.()?.layers ?? [] const firstOwn = layers.find((l: { id: string }) => OWN_LAYER_PREFIXES.some(prefix => l.id.startsWith(prefix))) map.addLayer({ id: SATELLITE_LAYER_ID, type: 'raster', source: SATELLITE_SOURCE_ID, paint: { 'raster-opacity': 1 }, }, firstOwn?.id) } if (map.getLayer(SATELLITE_LAYER_ID)) { map.setLayoutProperty(SATELLITE_LAYER_ID, 'visibility', on ? 'visible' : 'none') } } catch { /* a style that refuses the layer keeps the plain basemap */ } } function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement { // A stop that interrupts the drive gets its own small disc, decided before the photo // branch: the brand logo a fuel search comes back with is exactly what this replaces. // No number badge either, for the same reason the rail gives it none. const service = serviceMarkerHtml(place.stop_type, selected) if (service) { const outer = serviceMarkerOuter(selected) const wrap = document.createElement('div') wrap.style.cssText = `width:${outer}px;height:${outer}px;display:flex;align-items:center;justify-content:center` wrap.innerHTML = service return wrap } const size = selected ? 44 : 36 // See MapView: allow-listed rather than escaped, because this is a CSS context. const borderColor = selected ? '#111827' : safeHexColor(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 = safeHexColor(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 = `${label}` } const wrap = document.createElement('div') // Do NOT set `position: relative` here — GL map libraries ship // marker classes with `position: absolute` and rely 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/') || photoUrl.startsWith('/uploads/')) if (hasPhoto) { wrap.innerHTML = `
${badgeHtml} ` } else { wrap.innerHTML = `
${categoryIconSvg(place.category_icon, selected ? 18 : 15)}
${badgeHtml} ` } return wrap } // Plugin map contributions (mapMarkerProvider / mapLayerProvider hooks) — the GL // twins of MapPluginMarkers/MapPluginLayers. Same contract: host-vetted declarative // data only, tone palette, plugin JS never touches the canvas. Layer features live // in one geojson source with data-driven paint; the dash style can't be data-driven // in GL, so the stroke is split across three filtered line layers. const PLUGIN_LAYER_SOURCE_ID = 'trek-plugin-layers' const PLUGIN_LINE_LAYER_IDS: Record<'solid' | 'dash' | 'dot', string> = { solid: 'trek-plugin-layers-line-solid', dash: 'trek-plugin-layers-line-dash', dot: 'trek-plugin-layers-line-dot', } const PLUGIN_FILL_LAYER_ID = 'trek-plugin-layers-fill' const PLUGIN_TONE_COLORS: Record = { default: '#4F46E5', success: '#10b981', warn: '#f59e0b', danger: '#ef4444', } // GL circle layers size in screen pixels, so a metric circle has to become a // polygon. Equirectangular approximation — plenty for a display-only overlay. function circleToRing(center: [number, number], radiusM: number): [number, number][] { const [lat, lng] = center const dLat = radiusM / 111_320 const cos = Math.cos((lat * Math.PI) / 180) const dLng = radiusM / (111_320 * Math.max(0.01, Math.abs(cos))) const ring: [number, number][] = [] for (let i = 0; i <= 64; i++) { const a = (i / 64) * 2 * Math.PI ring.push([lng + Math.cos(a) * dLng, lat + Math.sin(a) * dLat]) } return ring } interface PluginLayerGeoFeature { type: 'Feature' properties: { id: string; color: string; width: number; opacity: number; dash: string; fillOpacity: number; label: string } geometry: { type: 'LineString'; coordinates: number[][] } | { type: 'Polygon'; coordinates: number[][][] } } function buildPluginLayerData(layers: PluginMapLayer[]) { const features = layers.flatMap(layer => layer.features.flatMap((f, i): PluginLayerGeoFeature[] => { const color = PLUGIN_TONE_COLORS[f.tone] ?? PLUGIN_TONE_COLORS.default const properties = { id: `${layer.pluginId}:${layer.id}:${i}`, color, width: f.width, opacity: f.opacity, dash: f.dash, fillOpacity: f.fill ? Math.min(0.25, f.opacity) : 0, label: f.label || '', } if (f.type === 'polyline' && f.points) { return [{ type: 'Feature' as const, properties, geometry: { type: 'LineString' as const, coordinates: f.points.map(([lat, lng]) => [lng, lat]) }, }] } if (f.type === 'polygon' && f.points) { const ring = f.points.map(([lat, lng]) => [lng, lat]) if (ring.length && (ring[0][0] !== ring[ring.length - 1][0] || ring[0][1] !== ring[ring.length - 1][1])) ring.push(ring[0]) return [{ type: 'Feature' as const, properties, geometry: { type: 'Polygon' as const, coordinates: [ring] } }] } if (f.type === 'circle' && f.center && f.radiusM) { return [{ type: 'Feature' as const, properties, geometry: { type: 'Polygon' as const, coordinates: [circleToRing(f.center, f.radiusM)] } }] } return [] })) return { type: 'FeatureCollection' as const, features } } function formatViaDwellGl(seconds: number): string { const h = Math.floor(seconds / 3600) const m = Math.round((seconds % 3600) / 60) return h > 0 ? `${h} h ${m} min` : `${m} min` } /** * A place pin on the map, whatever is carrying it. * * Mapbox keeps the library's Marker (its terrain path needs the altitude handling that * comes with it); MapLibre gets a hand-positioned element, because a Marker repositions * itself on every `move` event — one per pointer sample — while the canvas only redraws * once per animation frame. Under a heavy style the pins then run ahead of the map and * visibly swim during a drag, which the clustered pins never do: those are drawn inside * the canvas. Positioning from the map's own `render` event puts both on one clock. */ /** What either path accepts for a position — the 3-tuple carries a terrain altitude. */ type PinPosition = [number, number] | [number, number, number] | { lng: number; lat: number } interface PlacePin { el: HTMLElement remove: () => void getLngLat: () => { lng: number; lat: number } setLngLat: (value: PinPosition) => void /** Re-reads the map and writes this pin's screen position. No-op for a library marker. */ reposition: () => void } /** Wraps the library's own marker so both paths present the same handle. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function libraryPin(marker: any): PlacePin { return { el: marker.getElement(), remove: () => marker.remove(), getLngLat: () => marker.getLngLat(), setLngLat: (value) => marker.setLngLat(value), reposition: () => {}, } } /** A pin we place ourselves, in the map's render cadence. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function makePlacePin(map: any, layer: HTMLElement, el: HTMLElement, lng: number, lat: number): PlacePin { let position = { lng, lat } el.style.position = 'absolute' el.style.top = '0' el.style.left = '0' el.style.pointerEvents = 'auto' el.style.willChange = 'transform' layer.appendChild(el) const reposition = (): void => { // The map is gone the moment the style rebuilds or the component unmounts, and a // queued render can still land after that. if (typeof map.project !== 'function') return const p = map.project([position.lng, position.lat]) el.style.transform = `translate(-50%, -50%) translate(${p.x}px, ${p.y}px)` } reposition() return { el, remove: () => el.remove(), getLngLat: () => ({ ...position }), setLngLat: (value) => { position = Array.isArray(value) ? { lng: value[0], lat: value[1] } : { lng: value.lng, lat: value.lat } reposition() }, reposition, } } /** * Puts an element on the map as a pin. * * `layer` is only ever set under MapLibre (that is where it gets created), so passing it * along is the whole provider decision: with a layer we position the element ourselves on * the map's render clock, without one the library's own Marker does it. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function attachPin(map: any, gl: any, layer: HTMLElement | null, el: HTMLElement, lng: number, lat: number): PlacePin { return layer ? makePlacePin(map, layer, el, lng, lat) : libraryPin(new gl.Marker({ element: el, anchor: 'center' }).setLngLat([lng, lat]).addTo(map)) } // Tone dot for a plugin marker — visual twin of MapPluginMarkers' divIcon. function createPluginMarkerElement(tone: PluginMapMarker['tone']): HTMLDivElement { const color = PLUGIN_TONE_COLORS[tone] ?? PLUGIN_TONE_COLORS.default const el = document.createElement('div') el.style.cssText = 'width:16px;height:16px;cursor:pointer;' el.innerHTML = `` return el } // Popup body for a plugin marker, built with textContent — the values are already // host-sanitized, but nothing plugin-supplied is ever handed to innerHTML anyway. function buildPluginMarkerPopup(mk: PluginMapMarker): HTMLDivElement { const box = document.createElement('div') box.style.cssText = 'min-width:120px;font-size:13px;' if (mk.label) { const t = document.createElement('div') t.style.cssText = `font-weight:600;${mk.popupText ? 'margin-bottom:4px;' : ''}` t.textContent = mk.label box.appendChild(t) } if (mk.popupText) { const p = document.createElement('div') p.style.color = '#4b5563' p.textContent = mk.popupText box.appendChild(p) } if (mk.url) { const a = document.createElement('a') a.href = mk.url // http/https/mailto only — enforced server-side a.target = '_blank' a.rel = 'noreferrer noopener' a.style.cssText = `display:inline-block;margin-top:6px;color:${PLUGIN_TONE_COLORS.default};` a.textContent = mk.url box.appendChild(a) } return box } // Small coloured pin for an OSM "explore" POI (matches the pill category colour). // A chain shows its logo instead of the category icon: on a corridor full of petrol // stations the brand is what the eye is looking for, and the server proxies it so the // browser never asks Wikimedia which ones are on screen. function createPoiMarkerElement(category: string, brandWikidata?: string | null): HTMLDivElement { const cat = POI_CATEGORY_BY_KEY[category] const color = cat?.color || '#6b7280' const svg = cat ? renderIconMarkup(createElement(cat.Icon, { size: 13, color: 'white', strokeWidth: 2.5 })) : '' const el = document.createElement('div') el.style.cssText = 'width:26px;height:26px;cursor:pointer;will-change:transform;' el.innerHTML = `
${svg}${brandLogoMarkup(brandWikidata)}
` return el } /** * Kept as the one place that decides a POI pin carries no brand mark: nothing. * * The logos turned a corridor full of petrol stations into a row of advertisements, were * unreadable at pin size, and a brand with no logo on file fell back to a different * picture entirely — so no two pins looked alike. A flat disc in the category's colour * with its icon is what the rail, the corridor list and the map all show now. */ export function brandLogoMarkup(_brandWikidata?: string | null): string { return '' } export function MapViewGL({ places = NO_PLACES, dayPlaces = NO_PLACES, tripId, routeVias = NO_ROUTE_VIAS, dayBoundaryControls, accessLines = NO_ACCESS_LINES, route = null, routeColors = null, routeSegments = NO_ROUTE_SEGMENTS, selectedPlaceId = null, hoverDisabled = false, onMarkerClick, onMapClick, onMapContextMenu = null, center = DEFAULT_MAP_CENTER, zoom = DEFAULT_MAP_ZOOM, fitKey = 0, focusPoints, clusterLoosely = false, hazards, dayOrderMap = NO_DAY_ORDER, leftWidth = 0, rightWidth = 0, hasInspector = false, hasDayDetail = false, reservations = NO_RESERVATIONS, visibleConnectionIds = NO_CONNECTION_IDS, showTransitRoutes = true, days = NO_DAYS, selectedDayId = null, showReservationStats = false, onReservationClick, pois = NO_POIS, onPoiClick, onPoiDropOnRoute, onRouteClick, alternativeRoutes, activeAlternative, onChooseAlternative, onHighlightAlternative, roadtripVias, onMoveVia, onRemoveVia, onViewportChange, glProvider = 'mapbox-gl', gl, onMapReady, }: Props) { const { t } = useTranslation() const rawMapboxStyle = useSettingsStore(s => s.settings.mapbox_style || MAPBOX_DEFAULT_STYLE) const rawMaplibreStyle = useSettingsStore(s => s.settings.maplibre_style || '') const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '') // The same stored choice the Leaflet map reads, so the two renderers agree. const baseLayer = useSettingsStore(s => s.settings.map_base_layer) || 'default' const updateSetting = useSettingsStore(s => s.updateSetting) const isSatellite = baseLayer === 'satellite' const toggleBaseLayer = useCallback(() => { // The store flips synchronously, so the map switches even offline; a failed save // is logged there rather than blocking the switch. updateSetting('map_base_layer', isSatellite ? 'default' : 'satellite').catch(() => {}) }, [isSatellite, updateSetting]) 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) === true const mapLang = useSettingsStore(s => s.settings.language) const isMapLibre = glProvider === 'maplibre-gl' const glStyle = styleForActiveProvider(glProvider, rawMapboxStyle, rawMaplibreStyle) const enableMapbox3d = !isMapLibre && mapbox3d const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled) const [photoUrls, setPhotoUrls] = useState>(getAllThumbs) const [mapReady, setMapReady] = useState(false) // Hover tooltip — a cursor-following name/category/address card, matching the // Leaflet map's overlay exactly (no anchored popup, no photo thumbnail). const [hoverPlace, setHoverPlace] = useState(null) const [hoverPos, setHoverPos] = useState<{ x: number; y: number } | null>(null) const hoverIdRef = useRef(null) // True while the camera is moving (flyTo after a click, pan, zoom). Marker // elements get rebuilt during the move and re-fire mouseenter under a // stationary cursor, which would re-show the card we just cleared (#1404). const camMovingRef = useRef(false) // Selecting a place rebuilds its marker element, so the browser never fires // mouseleave on the removed node and the fixed-position hover card gets // orphaned (it stays put and drifts with page scroll). Clear it on selection // change and on any scroll so it can't get stuck. useEffect(() => { hoverIdRef.current = null; setHoverPlace(null); setHoverPos(null) }, [selectedPlaceId]) useEffect(() => { if (!hoverPlace) return const clear = () => { hoverIdRef.current = null; setHoverPlace(null); setHoverPos(null) } window.addEventListener('scroll', clear, true) return () => window.removeEventListener('scroll', clear, true) }, [hoverPlace]) const containerRef = useRef(null) // eslint-disable-next-line @typescript-eslint/no-explicit-any const mapRef = useRef(null) const hazardPopupFactory = useCallback(() => new gl.Popup({ className: 'map-tooltip trek-hazard-popup', maxWidth: '320px' }), [gl]) useHazardLayerGL(mapRef.current, mapReady, hazards, hazardPopupFactory) // eslint-disable-next-line @typescript-eslint/no-explicit-any const markersRef = useRef>(new Map()) // Own layer for the hand-positioned place pins (MapLibre path, see makePlacePin). const pinLayerRef = useRef(null) const locationMarkerRef = useRef(null) const reservationOverlayRef = useRef(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([]) /** Undoes the drag wiring on each POI pin; the pins themselves are rebuilt wholesale. */ const poiCleanupRef = useRef<(() => void)[]>([]) const onPoiDropRef = useRef(onPoiDropOnRoute) onPoiDropRef.current = onPoiDropOnRoute // Plugin map contributions — data fetched per trip, elements owned imperatively // like the POI markers so they survive the React render cycle. const [pluginMarkers, setPluginMarkers] = useState([]) const [pluginLayers, setPluginLayers] = useState([]) const pluginMarkersRef = useRef([]) const routeViaMarkersRef = useRef([]) /** The road-trip via handles (#1797) — hand-positioned like the rest, so listed here. */ const viaPinsRef = useRef([]) /** The drive-time pills on the offered routes; same treatment. */ const altLabelsRef = useRef([]) // Every hand-positioned pin, whichever set it belongs to. They all have to be written // in the same frame the canvas draws, or the ones left out swim against the ones in. const repositionPins = useCallback(() => { markersRef.current.forEach(pin => pin.reposition()) poiMarkersRef.current.forEach(pin => pin.reposition()) pluginMarkersRef.current.forEach(pin => pin.reposition()) routeViaMarkersRef.current.forEach(pin => pin.reposition()) viaPinsRef.current.forEach(pin => pin.reposition()) altLabelsRef.current.forEach(pin => pin.reposition()) }, []) // Single reusable hover popup for POI markers. Planned places use the // cursor-following React tooltip below so they match the Leaflet map. // eslint-disable-next-line @typescript-eslint/no-explicit-any const popupRef = useRef(null) const onPoiClickRef = useRef(onPoiClick) onPoiClickRef.current = onPoiClick /** * The via handles. * * Built as plain elements and attached with the same `attachPin` the place pins use, * rather than the library's own marker: under MapLibre those float above the map during * a drag instead of sticking to the ground (the reason `attachPin` exists at all). The * drag is therefore hand-rolled — pointer events on the element, unproject on move. */ const viaCleanupRef = useRef<(() => void)[]>([]) /** * The list only changes when a via does, and the callbacks are read through a ref. * * Both matter for the same reason: this effect destroys every handle and builds new DOM * elements. It used to re-run on every render of the planner — the vias arrived as a * fresh object each time and `onMoveVia` as a fresh closure — so the render that landed * when a freshly placed via finished re-routing pulled the element out from under the * pointer, and the first drag went nowhere. */ const vias = useStableVias(roadtripVias) const viaHandlersRef = useRef({ onMoveVia, onRemoveVia }) viaHandlersRef.current = { onMoveVia, onRemoveVia } const viasDraggable = !!onMoveVia useEffect(() => { const map = mapRef.current if (!map || !mapReady) return viaCleanupRef.current.forEach(off => off()) viaCleanupRef.current = [] viaPinsRef.current.forEach(p => p.remove()) viaPinsRef.current = [] /** * Zoomed out, the handles go away. * * A via is a handle for a few hundred metres of road, and at a continental zoom a * whole day's worth of them collapses into a cluster of dots over one town — not * something anybody can aim at, and dragging one there moves the route by kilometres * per pixel. Below this the drive is read, not shaped. */ const VIA_MIN_ZOOM = 9 const applyViaZoom = () => { const on = map.getZoom() >= VIA_MIN_ZOOM for (const pin of viaPinsRef.current) { const node = (pin as { getElement?: () => HTMLElement | null }).getElement?.() if (node) node.style.display = on ? '' : 'none' } } for (const via of vias) { const el = document.createElement('span') el.style.cssText = 'display:block;width:12px;height:12px;border-radius:9999px;background:#0a84ff;border:2.5px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.45);cursor:grab;touch-action:none' const pin = attachPin(map, gl, pinLayerRef.current, el, via.lng, via.lat) viaPinsRef.current.push(pin) if (!viasDraggable) continue let dragging = false // The map starts panning on mousedown/touchstart anywhere on its surface, and a pin // sits on that surface. Stopping only pointerdown left both running at once: the // handle moved AND the map slid out from under it. const swallow = (e: Event) => e.stopPropagation() // Where the gesture began, so a click can be told from a drag. Without it // every pointerup committed a move to wherever inside the 17px handle the // pointer happened to be: at a low zoom that is kilometres, and the write // re-routes the whole trip. Leaflet gets this for free from `dragend`, // which is why the two renderers behaved differently for one gesture. let startedAt: { x: number; y: number } | null = null const MOVED_ENOUGH = 4 const onDown = (e: PointerEvent) => { // Primary button only. Right-clicking ran onDown, then contextmenu -> // remove AND pointerup -> move, so one gesture sent two writes; on the // platforms where contextmenu comes first, the move landed on an id that // no longer existed and reported a failure for a deletion that worked. if (e.button !== 0) return e.stopPropagation() e.preventDefault() dragging = true startedAt = { x: e.clientX, y: e.clientY } el.setPointerCapture(e.pointerId) el.style.cursor = 'grabbing' } const onMove = (e: PointerEvent) => { if (!dragging) return const rect = map.getContainer().getBoundingClientRect() const at = map.unproject([e.clientX - rect.left, e.clientY - rect.top]) pin.setLngLat([at.lng, at.lat]) } const onUp = (e: PointerEvent) => { if (!dragging) return dragging = false el.style.cursor = 'grab' const from = startedAt startedAt = null const travelled = from ? Math.hypot(e.clientX - from.x, e.clientY - from.y) : 0 if (travelled < MOVED_ENOUGH) { // A click, not a drag. Put the pin back where the via actually is: the // move handler has been following the pointer across the handle. pin.setLngLat([via.lng, via.lat]) return } const rect = map.getContainer().getBoundingClientRect() const at = map.unproject([e.clientX - rect.left, e.clientY - rect.top]) viaHandlersRef.current.onMoveVia?.(via.day_id, via.id, at.lat, at.lng) } // The browser fires this when the captured element is torn out of the DOM, // which is exactly what a delete does. Committing a position there wrote a // move for a via that was being removed. const onCancel = () => { if (!dragging) return dragging = false startedAt = null el.style.cursor = 'grab' pin.setLngLat([via.lng, via.lat]) } const onContext = (e: MouseEvent) => { e.preventDefault() viaHandlersRef.current.onRemoveVia?.(via.day_id, via.id) } el.addEventListener('pointerdown', onDown) el.addEventListener('pointermove', onMove) el.addEventListener('pointerup', onUp) el.addEventListener('pointercancel', onCancel) el.addEventListener('contextmenu', onContext) el.addEventListener('mousedown', swallow) el.addEventListener('touchstart', swallow, { passive: true }) el.addEventListener('dblclick', swallow) viaCleanupRef.current.push(() => { el.removeEventListener('pointerdown', onDown) el.removeEventListener('pointermove', onMove) el.removeEventListener('pointerup', onUp) el.removeEventListener('pointercancel', onCancel) el.removeEventListener('contextmenu', onContext) el.removeEventListener('mousedown', swallow) el.removeEventListener('touchstart', swallow) el.removeEventListener('dblclick', swallow) }) } // Applied now and on every zoom that settles, so a handle that should not be there is // gone before the first frame rather than after the first gesture. applyViaZoom() map.on('zoomend', applyViaZoom) viaCleanupRef.current.push(() => map.off('zoomend', applyViaZoom)) }, [vias, viasDraggable, mapReady, glProvider]) /** * The other people's pointers, and this person's own going the other way. * * Built as plain elements on the same pin layer the vias use, so they ride the map * during a pan instead of being re-placed a frame later — the same reason the via * handles are not library markers. * * No interpolation between frames, unlike the studio's book: a map pans and zooms under * the arrow, so a position eased towards over several frames is a position that was * never true at any of them. Ten frames a second placed exactly reads as a hand; the * same frames chasing a moving target read as a drift. */ /** * The offered routes, drawn under the current one. * * Grey and dashed rather than a set of bright lines: at any moment one of them IS the * route, drawn in blue on top, and three more solid colours next to it would read as * four equal roads. Hovering an option in the bar lights that one up in its own colour, * which is the only moment a second colour helps. */ useEffect(() => { const map = mapRef.current if (!map || !mapReady) return const source = map.getSource?.('route-alternatives') if (!source?.setData) return source.setData({ type: 'FeatureCollection', features: (alternativeRoutes ?? []).map((alt, i) => ({ type: 'Feature', properties: { index: i, active: activeAlternative === i, color: alt.color }, geometry: { type: 'LineString', coordinates: alt.coordinates.map(([lat, lng]) => [lng, lat]) }, })), }) }, [alternativeRoutes, activeAlternative, mapReady, glProvider]) /** * The drive time on each offered route, the way Apple Maps labels them. * * Hand-positioned pins rather than a symbol layer: a symbol would need the text in the * style's own font stack, which differs between the bundled MapLibre styles and Mapbox, * and the pill has a shape a text layer cannot draw. They join the render clock like * every other pin here, so they stay on their road while the map moves. */ useEffect(() => { const map = mapRef.current if (!map || !mapReady) return altLabelsRef.current.forEach(p => p.remove()) altLabelsRef.current = [] for (const alt of alternativeRoutes ?? []) { const el = document.createElement('button') el.type = 'button' const active = activeAlternative === alt.index el.style.cssText = [ // A rounded rectangle rather than a pill, and roomy enough for a second line — // the shape Apple gives these, and the reason "1 h 55 min / Fastest" reads as one // label instead of two stacked chips. 'display:flex;flex-direction:column;align-items:flex-start;white-space:nowrap', `background:${alt.labelBg}`, `color:${ALT_LABEL_TEXT}`, 'border:none;border-radius:11px', // A