import { useEffect, useRef, useMemo, useState, createElement, useCallback } from 'react' import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag' import { useStableVias } from './viaMarkerState' import { usePlannerWaypointMarkersGL } from './usePlannerWaypointMarkersGL' import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors' 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 { applyBaseLayer } from './glRasterLayers' import { reportGlViewport } from './glViewport' import { isTourLayerHit, useTourMapLayersGL } from './mapRasterOverlay' import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type BaseLayer, type TourBaseLayer } from './MapLayerSwitcher' import { MapLockPill } from './MapLockPill' import { useMarkerThumbs } from './useMarkerThumbs' import { placeMarkerLook, type PlaceMarkerFlags } from './markerLook' import { markerPhotoHtml, placePhotoFull, placePhotoUrl } 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 { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from './glProviders' import LocationButton from './LocationButton' import { useIsPhone } from '../../mobile/useIsPhone' import { useGeolocation } from '../../hooks/useGeolocation' import type { Day, Place, Reservation, RouteVia } from '../../types' import type { MapHoverInfo } from './mapHover' import type { MapViewProps, RouteSegment } from './mapViewContract' import { nightPauseMarker, NIGHT_PAUSE_MIN_ZOOM } from './nightPauseMarker' import { clusterPois, poiClusterMarkup, poiClusterList, POI_CLUSTER_DETAIL_ZOOM } from './poiClusters' import { groupCoincidentPlaces } from './coincidentPlaces' import { useHazardLayerGL } from './useHazardLayerGL' import type { FerrataGlMap } from '../Ferratas/useFerrataLayerGL' import { formatViaDwell } from './routeViaDwell' import { useDawarichTrailGL } from './useDawarichTrailGL' import { bindDayBoundaryDrag } from './dayBoundaryDrag' import NightPauseTooltip from './NightPauseTooltip' import PlaceHoverCard from './PlaceHoverCard' import { ratingBadgeHtml } from './ratingBadge' import type { Poi } from './poiCategories' import { poiPinParts } from './poiMarker' import { addGpxTrackLayers, GPX_HIT_LAYER_ID, hasGpxTrackLayers, selectGpxTrack, setGpxTracks } from './gpxTrackLayers' import { buildPoiPopupHtml } from './placePopup' import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client' import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults' import { computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport' import { selectedPlaceTarget } from './selectedPlaceTarget' 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' 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] }, })), } } // 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[] = [] /** The GL renderer: the shared map contract plus the engine it is bound to. */ interface Props extends MapViewProps { 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 } /** * How eagerly place markers merge into a cluster. * * The overview band the Leaflet map clusters in as well (`CLUSTER_UNTIL_ZOOM` there), * kept as plain numbers rather than shared constants so the GL bundle does not have to * carry Leaflet for them. Above the cutoff supercluster hands every point back on its * own, and the stops that share a coordinate are folded together at the pin level * instead — see the reconcile below. */ const CLUSTER_RADIUS = 20 const CLUSTER_MAX_ZOOM = 8 /** 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, }, }) } function createMarkerElement(place: Place & PlaceMarkerFlags & { 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 look = placeMarkerLook(place, selected) const { size, borderWidth } = look // See MapView: allow-listed rather than escaped, because this is a CSS context. const borderColor = selected ? '#111827' : safeHexColor(place.category_color, 'white') 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 // Same corner, same rule as the Leaflet map: numbers when the place is planned into a // day, the rating when it is not. let badgeHtml = look.showRating ? ratingBadgeHtml((place as { rating_avg?: number | null }).rating_avg) : '' 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 = look.showPhoto && photoUrl && (photoUrl.startsWith('data:') || photoUrl.startsWith('/api/maps/place-photo/') || photoUrl.startsWith('/uploads/')) if (hasPhoto) { wrap.innerHTML = `
${markerPhotoHtml(photoUrl)}
${badgeHtml} ` } else { wrap.innerHTML = `
${categoryIconSvg(place.category_icon, look.iconSize)}
${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 } } /** * 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(poi: Pick, brandWikidata?: string | null): HTMLDivElement { // The same parts the Leaflet pin is built from: a plugin POI's own colour and icon, // both checked before they get anywhere near innerHTML. const { color, svg } = poiPinParts(poi) 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 '' } /** The look of a planner control point, drawn by usePlannerWaypointMarkersGL. */ function plannerWaypointPinCss(size: number, selected: boolean): string { return `display:flex;width:${size}px;height:${size}px;align-items:center;justify-content:center;border-radius:9999px;background:#0a84ff;color:white;border:${selected ? 3 : 2}px solid white;box-shadow:0 2px 7px rgba(0,0,0,.4);font:700 11px system-ui` } export function MapViewGL({ places = NO_PLACES, dayPlaces = NO_PLACES, tripId, routeVias = NO_ROUTE_VIAS, dayBoundaryControls, accessLines = NO_ACCESS_LINES, route = null, routeColors = null, routeWalking = null, followSelection = true, onToggleFollow, routeSegments = NO_ROUTE_SEGMENTS, selectedPlaceId = null, selectedPlace = null, hoverDisabled = false, onMarkerClick, onMapClick, onMapContextMenu = null, center = DEFAULT_MAP_CENTER, zoom = DEFAULT_MAP_ZOOM, fitKey = 0, focusPoints, focusKey, fitPadding, clusterLoosely = false, hazards, dawarichTrack = null, dawarichSelectedDate = null, dawarichHiddenDates = null, 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, scopeConnectionsToDay = false, showReservationStats = false, onReservationClick, pois = NO_POIS, onPoiClick, onPoiDropOnRoute, onRouteClick, alternativeRoutes, activeAlternative, onChooseAlternative, onHighlightAlternative, plannerWaypoints = [], selectedPlannerWaypointId = null, onPlannerWaypointClick, onPlannerWaypointMove, routeProfileFocus = null, viewBaseLayer, onViewBaseLayerChange, onToggleTerrain3d, ferrataLayer, placeTool, tourLayers, 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 globalBaseLayer = useSettingsStore(s => s.settings.map_base_layer) || 'default' const baseLayer = viewBaseLayer ?? globalBaseLayer const hasViewBaseLayer = viewBaseLayer !== undefined && typeof onViewBaseLayerChange === 'function' 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 [mapReady, setMapReady] = useState(false) const [readyMap, setReadyMap] = useState(null) // the ready map, read without touching mapRef // 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) useTourMapLayersGL(mapReady ? readyMap : null, ferrataLayer, tourLayers) // Beneath the planned route's casing, the GL twin of the Leaflet pane order. useDawarichTrailGL(mapRef.current, mapReady, dawarichTrack, dawarichSelectedDate, 'trip-route-casing', dawarichHiddenDates) // 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([]) const plannerWaypointPinsRef = useRef([]) const profileFocusPinRef = useRef(null) /** 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()) plannerWaypointPinsRef.current.forEach(pin => pin.reposition()) profileFocusPinRef.current?.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)[]>([]) const attachPlannerWaypointPin = useCallback( (el: HTMLElement, lng: number, lat: number) => attachPin(mapRef.current, gl, pinLayerRef.current, el, lng, lat), [gl], ) usePlannerWaypointMarkersGL({ mapRef, mapReady, pinsRef: plannerWaypointPinsRef, attachPin: attachPlannerWaypointPin, pinCss: plannerWaypointPinCss, plannerWaypoints, selectedPlannerWaypointId, onPlannerWaypointClick, onPlannerWaypointMove, }) useEffect(() => () => { profileFocusPinRef.current?.remove() profileFocusPinRef.current = null }, [gl, mapReady]) useEffect(() => { const map = mapRef.current if (!map || !mapReady) return if (!routeProfileFocus) { profileFocusPinRef.current?.remove() profileFocusPinRef.current = null return } let pin = profileFocusPinRef.current if (!pin) { const element = document.createElement('div') element.setAttribute('aria-hidden', 'true') element.dataset.tourProfileFocus = 'true' element.style.cssText = 'width:18px;height:18px;border:3px solid var(--bg-card);border-radius:50%;background:var(--text-muted);box-shadow:0 0 0 1px var(--text-secondary);pointer-events:none;box-sizing:border-box;' pin = attachPin(map, gl, pinLayerRef.current, element, routeProfileFocus.lng, routeProfileFocus.lat) profileFocusPinRef.current = pin } else { pin.setLngLat([routeProfileFocus.lng, routeProfileFocus.lat]) } pin.el.style.pointerEvents = 'none' }, [gl, mapReady, routeProfileFocus]) /** * 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 // `el` is the one handle both pin paths expose; the library marker's own accessor // does not exist on the hand-positioned pin, so reading that left every handle on. for (const pin of viaPinsRef.current) pin.el.style.display = on ? 'block' : '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' // Marks the handle for the map's own click handlers: the drive's hit band runs // right under it, and a click that lands here is the handle's, not the road's. el.dataset.viaHandle = '' 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() // Stopped here as well: both GL libraries listen on the canvas container the // handle sits in, and the map's own contextmenu opens the add-place form at // the very spot the via has just been removed from. e.stopPropagation() 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) // Stopping pointerdown does not stop the click that follows it, and the handle sits // on the route's hit band: the map's click landed on the road and dropped a second // via exactly under the one that was clicked. el.addEventListener('click', 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) el.removeEventListener('click', 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