mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
2348 lines
109 KiB
TypeScript
2348 lines
109 KiB
TypeScript
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<number, number[] | null> = {}
|
|
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<number, number[] | null>
|
|
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<number, RoadtripVia[]>
|
|
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 = `<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 — 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 = `
|
|
<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="${escapeHtml(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
|
|
}
|
|
|
|
// 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<string, string> = {
|
|
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 = `<span style="display:block;width:16px;height:16px;border-radius:50%;background:${color};border:2px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,0.4);box-sizing:border-box;"></span>`
|
|
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 = `<div style="position:relative;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;overflow:hidden;">${svg}${brandLogoMarkup(brandWikidata)}</div>`
|
|
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<Record<string, string>>(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<MapHoverInfo | null>(null)
|
|
const [hoverPos, setHoverPos] = useState<{ x: number; y: number } | null>(null)
|
|
const hoverIdRef = useRef<number | null>(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<HTMLDivElement>(null)
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const mapRef = useRef<any | null>(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<Map<number, PlacePin>>(new Map())
|
|
// Own layer for the hand-positioned place pins (MapLibre path, see makePlacePin).
|
|
const pinLayerRef = useRef<HTMLDivElement | null>(null)
|
|
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<PlacePin[]>([])
|
|
/** 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<PluginMapMarker[]>([])
|
|
const [pluginLayers, setPluginLayers] = useState<PluginMapLayer[]>([])
|
|
const pluginMarkersRef = useRef<PlacePin[]>([])
|
|
const routeViaMarkersRef = useRef<PlacePin[]>([])
|
|
/** The road-trip via handles (#1797) — hand-positioned like the rest, so listed here. */
|
|
const viaPinsRef = useRef<PlacePin[]>([])
|
|
/** The drive-time pills on the offered routes; same treatment. */
|
|
const altLabelsRef = useRef<PlacePin[]>([])
|
|
// 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<any | null>(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 <button> does not inherit the page's font, so without this the label reads in
|
|
// the browser's UI face while every other piece of TREK chrome reads in Poppins.
|
|
'font-family:var(--font-system)',
|
|
'padding:6px 11px;font-size:13px;font-weight:600;line-height:1.25',
|
|
'box-shadow:0 2px 10px rgba(0,0,0,.35)',
|
|
'cursor:pointer;pointer-events:auto',
|
|
// The pin is positioned by writing `transform` every frame. Any inherited
|
|
// transition on it makes the label chase the map instead of sticking to the road,
|
|
// and `transform` is exactly what a button picks up from the app's own styles.
|
|
'transition:none !important;animation:none !important',
|
|
'will-change:transform;backface-visibility:hidden',
|
|
active ? 'outline:2px solid #fff;outline-offset:1px' : '',
|
|
].filter(Boolean).join(';')
|
|
const time = document.createElement('span')
|
|
time.textContent = alt.label
|
|
el.appendChild(time)
|
|
if (alt.note) {
|
|
const note = document.createElement('span')
|
|
note.textContent = alt.note
|
|
note.style.cssText = 'font-size:11.5px;font-weight:500;opacity:.85;line-height:1.2'
|
|
el.appendChild(note)
|
|
}
|
|
// Same reason as the via handles: without this the map pans as the label is pressed.
|
|
el.addEventListener('mousedown', e => e.stopPropagation())
|
|
el.addEventListener('touchstart', e => e.stopPropagation(), { passive: true })
|
|
el.addEventListener('click', e => { e.stopPropagation(); onChooseAlternativeRef.current?.(alt.index) })
|
|
el.addEventListener('mouseenter', () => onHighlightAlternativeRef.current?.(alt.index))
|
|
el.addEventListener('mouseleave', () => onHighlightAlternativeRef.current?.(null))
|
|
altLabelsRef.current.push(attachPin(map, gl, pinLayerRef.current, el, alt.at.lng, alt.at.lat))
|
|
}
|
|
return () => {
|
|
altLabelsRef.current.forEach(p => p.remove())
|
|
altLabelsRef.current = []
|
|
}
|
|
}, [alternativeRoutes, activeAlternative, mapReady, glProvider])
|
|
|
|
/** Clicking one of the offered routes takes it, the same as clicking its chip. */
|
|
useEffect(() => {
|
|
const map = mapRef.current
|
|
if (!map || !mapReady || !onChooseAlternative) return
|
|
const onClick = (e: { features?: { properties?: { index?: number } }[] }) => {
|
|
const index = e.features?.[0]?.properties?.index
|
|
if (typeof index === 'number') onChooseAlternative(index)
|
|
}
|
|
const enter = () => { map.getCanvas().style.cursor = 'pointer' }
|
|
const leave = () => { map.getCanvas().style.cursor = '' }
|
|
if (!map.getLayer?.('route-alt-hit')) return
|
|
map.on('click', 'route-alt-hit', onClick)
|
|
map.on('mouseenter', 'route-alt-hit', enter)
|
|
map.on('mouseleave', 'route-alt-hit', leave)
|
|
return () => {
|
|
map.off('click', 'route-alt-hit', onClick)
|
|
map.off('mouseenter', 'route-alt-hit', enter)
|
|
map.off('mouseleave', 'route-alt-hit', leave)
|
|
}
|
|
}, [mapReady, onChooseAlternative])
|
|
|
|
/**
|
|
* A click on the route line, which in a GL renderer is a layer rather than an element.
|
|
* `map.on(type, layerId, …)` is the one way to hit it, and it fires before the map's own
|
|
* click, so the handler can keep the add-place menu from opening underneath the via.
|
|
*/
|
|
useEffect(() => {
|
|
const map = mapRef.current
|
|
if (!map || !mapReady || !onRouteClick) return
|
|
const onClick = (e: { lngLat: { lat: number; lng: number }; point?: { x: number; y: number }; preventDefault?: () => void }) => {
|
|
e.preventDefault?.()
|
|
// Layer handlers are independent: MapLibre evaluates each registration
|
|
// against the same click, so a point that hits both the offered-route band
|
|
// and the current-route band fires both. Every alternative starts and ends
|
|
// at the same two stops, and when the leg already carries vias the current
|
|
// road is itself one of the offers, so the overlap is the whole leg. One
|
|
// click then dropped a via AND chose a different road, two writes and two
|
|
// trip reloads for a gesture the user made once. The picker is a modal
|
|
// choice about that leg; dropping a via mid-choice is not offered anywhere.
|
|
if (e.point && map.queryRenderedFeatures?.(e.point, { layers: ['route-alt-hit'] })?.length) return
|
|
onRouteClick(e.lngLat.lat, e.lngLat.lng)
|
|
}
|
|
const enter = () => { map.getCanvas().style.cursor = 'copy' }
|
|
const leave = () => { map.getCanvas().style.cursor = '' }
|
|
for (const layer of ['trip-route-hit']) {
|
|
if (!map.getLayer?.(layer)) continue
|
|
map.on('click', layer, onClick)
|
|
map.on('mouseenter', layer, enter)
|
|
map.on('mouseleave', layer, leave)
|
|
}
|
|
return () => {
|
|
for (const layer of ['trip-route-hit']) {
|
|
map.off('click', layer, onClick)
|
|
map.off('mouseenter', layer, enter)
|
|
map.off('mouseleave', layer, leave)
|
|
}
|
|
}
|
|
}, [mapReady, onRouteClick])
|
|
|
|
/**
|
|
* The map takes the drop, not the drawn route: in a GL renderer the line lives in the
|
|
* canvas and has no element to aim at. The coordinate under the pointer says just as
|
|
* much once it is projected onto the routed geometry, and it works the same in all
|
|
* three renderers.
|
|
*/
|
|
useEffect(() => {
|
|
const map = mapRef.current
|
|
const container = map?.getContainer?.()
|
|
if (!container || !mapReady || !onPoiDropOnRoute) return
|
|
const onDragOver = (e: DragEvent) => {
|
|
if (!draggedPoiId(e)) return
|
|
e.preventDefault()
|
|
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
|
|
}
|
|
const onDrop = (e: DragEvent) => {
|
|
const osmId = draggedPoiId(e)
|
|
if (!osmId) return
|
|
e.preventDefault()
|
|
const rect = container.getBoundingClientRect()
|
|
const at = map.unproject([e.clientX - rect.left, e.clientY - rect.top])
|
|
onPoiDropOnRoute(osmId, at.lat, at.lng)
|
|
}
|
|
container.addEventListener('dragover', onDragOver)
|
|
container.addEventListener('drop', onDrop)
|
|
return () => {
|
|
container.removeEventListener('dragover', onDragOver)
|
|
container.removeEventListener('drop', onDrop)
|
|
}
|
|
}, [mapReady, onPoiDropOnRoute])
|
|
const onViewportChangeRef = useRef(onViewportChange)
|
|
onViewportChangeRef.current = onViewportChange
|
|
const onMapReadyRef = useRef(onMapReady)
|
|
onMapReadyRef.current = onMapReady
|
|
const { position: userPosition, mode: trackingMode, error: trackingError, errorCode: trackingErrorCode, 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
|
|
const hoverDisabledRef = useRef(hoverDisabled)
|
|
hoverDisabledRef.current = hoverDisabled
|
|
// Read inside the map's own click handler, which is registered once at setup.
|
|
const onRouteClickRef = useRef(onRouteClick)
|
|
onRouteClickRef.current = onRouteClick
|
|
const onChooseAlternativeRef = useRef(onChooseAlternative)
|
|
onChooseAlternativeRef.current = onChooseAlternative
|
|
const onHighlightAlternativeRef = useRef(onHighlightAlternative)
|
|
onHighlightAlternativeRef.current = onHighlightAlternative
|
|
// Same gate as the Leaflet renderer: HTML5 drag is a pointer feature, and the
|
|
// day plan the marker would be dropped on is not on screen on a phone anyway.
|
|
const markersDraggableRef = useRef(typeof window !== 'undefined' && navigator.maxTouchPoints === 0)
|
|
const routeCoords = useMemo<[number, number][]>(() => (route || []).flat().filter(isValidCoordinate), [route])
|
|
const routeFitKey = useMemo(
|
|
() => routeCoords.map(([lat, lng]) => `${lat.toFixed(6)},${lng.toFixed(6)}`).join('|'),
|
|
[routeCoords],
|
|
)
|
|
// Set when the map was built already framed on its places, so the fit below knows there is
|
|
// nothing left to do on mount.
|
|
const framedOnMountRef = useRef(false)
|
|
|
|
// Build/rebuild the map on provider/style/token/3d change
|
|
useEffect(() => {
|
|
if (!containerRef.current || (!isMapLibre && !mapboxToken)) return
|
|
if (!isMapLibre) gl.accessToken = mapboxToken
|
|
|
|
// Open framed on the places rather than on the caller's default: a trip in Japan should
|
|
// show Japan straight away, not the world view followed by a flight across the planet.
|
|
// Reading them here is what makes this "on load" — the map is built once, and the trip's
|
|
// places are already loaded by then (TripPlannerPage holds a splash until they are).
|
|
const framed = computeMapViewport(dayPlaces.length > 0 ? dayPlaces : places, {
|
|
tileSize: TILE_SIZE_GL,
|
|
padding: paddingOpts,
|
|
})
|
|
framedOnMountRef.current = framed !== null
|
|
const initial = framed ?? { center, zoom }
|
|
|
|
const mapOptions: Record<string, unknown> = {
|
|
container: containerRef.current,
|
|
style: glStyle,
|
|
center: [initial.center[1], initial.center[0]],
|
|
zoom: initial.zoom,
|
|
pitch: enableMapbox3d ? 45 : 0,
|
|
attributionControl: true,
|
|
antialias: mapboxQuality,
|
|
}
|
|
if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator'
|
|
// MapLibre 5's mouse-rotate inverts its sign at a mid-screen line it gets by
|
|
// re-projecting the map center — a line that drifts with the bearing, so a
|
|
// right-button drag near mid-screen ping-pongs instead of rotating (#1545).
|
|
// aroundCenter: false restores the plain dx-based rotate mapbox-gl uses.
|
|
if (isMapLibre) mapOptions.aroundCenter = false
|
|
|
|
const map = new gl.Map(mapOptions as any)
|
|
mapRef.current = map
|
|
popupRef.current = new gl.Popup({
|
|
closeButton: false,
|
|
closeOnClick: false,
|
|
// The tail is off (index.css), and it used to hold ten of these pixels
|
|
// itself — without them the card sat almost on top of the marker.
|
|
offset: 26,
|
|
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 (enableMapbox3d) {
|
|
// 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(glStyle) && wantsTerrain(glStyle)) addTerrainAndSky(map)
|
|
if (supportsCustom3d(glStyle)) {
|
|
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 (glStyle === MAPBOX_DEFAULT_STYLE) {
|
|
try { map.setTerrain(null) } catch { /* noop */ }
|
|
}
|
|
// The ways of driving one leg, offered while the picker is open (#1797). Added
|
|
// BEFORE the route source so its layers sit underneath: the current route stays the
|
|
// brightest thing on the map, and the options read as what they are — suggestions.
|
|
if (!map.getSource('route-alternatives')) {
|
|
map.addSource('route-alternatives', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
|
// A wide invisible band per option, so a pointer can pick one without hitting
|
|
// the 3px line exactly.
|
|
map.addLayer({
|
|
id: 'route-alt-hit',
|
|
type: 'line',
|
|
source: 'route-alternatives',
|
|
paint: { 'line-color': '#000000', 'line-opacity': 0, 'line-width': 22 },
|
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
|
})
|
|
// A white casing under the option lines, the same trick the main route uses.
|
|
// Plain grey disappeared on a grey basemap — Positron is mostly greys, and a
|
|
// grey dashed line on it is invisible. The casing gives every option an edge, so
|
|
// it reads on a pale map, a dark one and a satellite tile alike.
|
|
map.addLayer({
|
|
id: 'route-alt-casing',
|
|
type: 'line',
|
|
source: 'route-alternatives',
|
|
paint: { 'line-color': ALT_CASING, 'line-width': 8, 'line-opacity': 0.9 },
|
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
|
})
|
|
map.addLayer({
|
|
id: 'route-alt-line',
|
|
type: 'line',
|
|
source: 'route-alternatives',
|
|
// Dark and dashed by default so it stays subordinate to the blue route it is an
|
|
// alternative to; the one being considered takes its own colour and thickens.
|
|
// Every option in blue, after Apple Maps: they are all real roads, so the
|
|
// difference between them is emphasis rather than category. The one being
|
|
// considered thickens; nothing turns grey, because grey vanishes on Positron.
|
|
paint: {
|
|
'line-color': ['coalesce', ['get', 'color'], '#7eb8f0'],
|
|
'line-width': ['case', ['boolean', ['get', 'active'], false], 6, 4],
|
|
'line-opacity': ['case', ['boolean', ['get', 'active'], false], 1, 0.9],
|
|
},
|
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
|
})
|
|
}
|
|
|
|
// 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',
|
|
// Per feature where the caller gave one, else the blue the route has always
|
|
// been. `coalesce` rather than a second layer: one source, one stroke.
|
|
paint: { 'line-color': ['coalesce', ['get', 'casing'], '#0a5cc2'], 'line-width': 8 },
|
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
|
})
|
|
// An invisible band over the route, purely to be clicked. The drawn line is 8px
|
|
// and a pointer is not that accurate, so aiming at the road was most of why
|
|
// putting a via there felt like it did not work.
|
|
map.addLayer({
|
|
id: 'trip-route-hit',
|
|
type: 'line',
|
|
source: 'trip-route',
|
|
paint: { 'line-color': '#000000', 'line-opacity': 0, 'line-width': 26 },
|
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
|
})
|
|
map.addLayer({
|
|
id: 'trip-route-line',
|
|
type: 'line',
|
|
source: 'trip-route',
|
|
paint: { 'line-color': ['coalesce', ['get', 'color'], '#0a84ff'], 'line-width': 5 },
|
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
|
})
|
|
}
|
|
// The last bit to a place the road does not reach (#1797). Added after the route
|
|
// so it draws over it: it is the one piece of the line that is not driving, and it
|
|
// has to be readable exactly where it meets the road it leaves. Same blue, because
|
|
// it is the end of that route rather than a second one; dashed short and thin,
|
|
// which is how a map says "on foot from here" without a legend.
|
|
if (!map.getSource('trip-access')) {
|
|
map.addSource('trip-access', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
|
map.addLayer({
|
|
id: 'trip-access-line',
|
|
type: 'line',
|
|
source: 'trip-access',
|
|
paint: { 'line-color': '#0a84ff', 'line-width': 3, 'line-opacity': 0.85, 'line-dasharray': [1, 2.5] },
|
|
layout: { 'line-cap': 'round' },
|
|
})
|
|
}
|
|
// gpx geometries source (place.route_geometry)
|
|
if (!map.getSource('trip-gpx')) {
|
|
map.addSource('trip-gpx', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
|
// Casing under the tracks that carry a picked colour (#776) — keeps them
|
|
// legible on satellite and dark styles. Untouched tracks are filtered
|
|
// out, so they look exactly as they did before.
|
|
map.addLayer({
|
|
id: 'trip-gpx-casing',
|
|
type: 'line',
|
|
source: 'trip-gpx',
|
|
filter: ['==', ['get', 'cased'], true],
|
|
paint: { 'line-color': '#ffffff', 'line-width': 6.5, 'line-opacity': 0.7 },
|
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
|
})
|
|
map.addLayer({
|
|
id: 'trip-gpx-line',
|
|
type: 'line',
|
|
source: 'trip-gpx',
|
|
paint: {
|
|
'line-color': ['coalesce', ['get', 'color'], '#3b82f6'],
|
|
'line-width': 3.5,
|
|
'line-opacity': ['case', ['==', ['get', 'cased'], true], 0.9, 0.75],
|
|
},
|
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
|
})
|
|
// Invisible fat line that catches the click — 3.5px is not a target,
|
|
// and the start markers cluster below zoom 11, so without this a track
|
|
// is unreachable at the very zoom where you compare walks side by side.
|
|
map.addLayer({
|
|
id: GPX_HIT_LAYER_ID,
|
|
type: 'line',
|
|
source: 'trip-gpx',
|
|
paint: { 'line-color': '#000', 'line-width': 14, 'line-opacity': 0 },
|
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
|
})
|
|
const selectTrack = (e: any) => { // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
// A click on a cluster bubble sitting over a track belongs to the
|
|
// cluster (zoom-to-expand), not to the line underneath it.
|
|
if (
|
|
typeof map.getLayer === 'function'
|
|
&& map.getLayer(PLACE_CLUSTER_CIRCLE_LAYER_ID)
|
|
&& typeof map.queryRenderedFeatures === 'function'
|
|
&& map.queryRenderedFeatures(e.point, { layers: [PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID] }).length > 0
|
|
) return
|
|
const target = e.originalEvent?.target as HTMLElement | undefined
|
|
if (target?.closest?.('.mapboxgl-marker, .maplibregl-marker')) return
|
|
const placeId = e.features?.[0]?.properties?.place_id
|
|
if (typeof placeId === 'number') onClickRefs.current.marker?.(placeId)
|
|
}
|
|
const setTrackCursor = () => {
|
|
const canvas = typeof map.getCanvas === 'function' ? map.getCanvas() : null
|
|
if (canvas) canvas.style.cursor = 'pointer'
|
|
}
|
|
const clearTrackCursor = () => {
|
|
const canvas = typeof map.getCanvas === 'function' ? map.getCanvas() : null
|
|
if (canvas) canvas.style.cursor = ''
|
|
}
|
|
map.on('click', GPX_HIT_LAYER_ID, selectTrack)
|
|
map.on('mouseenter', GPX_HIT_LAYER_ID, setTrackCursor)
|
|
map.on('mouseleave', GPX_HIT_LAYER_ID, clearTrackCursor)
|
|
}
|
|
if (!map.getSource(PLACE_CLUSTER_SOURCE_ID)) {
|
|
addPlaceClusterLayers(map)
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const zoomToCluster = (e: any) => {
|
|
const features = typeof map.queryRenderedFeatures === 'function'
|
|
? map.queryRenderedFeatures(e.point, { layers: [PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID] })
|
|
: []
|
|
const feature = features?.[0]
|
|
const clusterId = feature?.properties?.cluster_id
|
|
const coordinates = feature?.geometry?.coordinates
|
|
if (clusterId == null || !Array.isArray(coordinates)) return
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const source = map.getSource(PLACE_CLUSTER_SOURCE_ID) as any
|
|
const easeToZoom = (nextZoom: number) => {
|
|
try { map.easeTo({ center: coordinates, zoom: nextZoom, duration: 350 }) } catch { /* noop */ }
|
|
}
|
|
try {
|
|
const maybeZoom = source?.getClusterExpansionZoom?.(clusterId, (err: Error | null, nextZoom: number) => {
|
|
if (!err && typeof nextZoom === 'number') easeToZoom(nextZoom)
|
|
})
|
|
if (typeof maybeZoom === 'number') easeToZoom(maybeZoom)
|
|
else if (maybeZoom && typeof maybeZoom.then === 'function') maybeZoom.then(easeToZoom).catch(() => {})
|
|
} catch { /* noop */ }
|
|
}
|
|
const setClusterCursor = () => {
|
|
const canvas = typeof map.getCanvas === 'function' ? map.getCanvas() : null
|
|
if (canvas) canvas.style.cursor = 'pointer'
|
|
}
|
|
const clearClusterCursor = () => {
|
|
const canvas = typeof map.getCanvas === 'function' ? map.getCanvas() : null
|
|
if (canvas) canvas.style.cursor = ''
|
|
}
|
|
map.on('click', PLACE_CLUSTER_CIRCLE_LAYER_ID, zoomToCluster)
|
|
map.on('click', PLACE_CLUSTER_COUNT_LAYER_ID, zoomToCluster)
|
|
map.on('mouseenter', PLACE_CLUSTER_CIRCLE_LAYER_ID, setClusterCursor)
|
|
map.on('mouseleave', PLACE_CLUSTER_CIRCLE_LAYER_ID, clearClusterCursor)
|
|
}
|
|
// Plugin layer overlays (mapLayerProvider hook). Inserted BENEATH the day
|
|
// route so core geometry always wins — the GL twin of the Leaflet pane 399.
|
|
// Dash can't be data-driven, hence one filtered line layer per dash style.
|
|
if (!map.getSource(PLUGIN_LAYER_SOURCE_ID)) {
|
|
map.addSource(PLUGIN_LAYER_SOURCE_ID, { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
|
map.addLayer({
|
|
id: PLUGIN_FILL_LAYER_ID,
|
|
type: 'fill',
|
|
source: PLUGIN_LAYER_SOURCE_ID,
|
|
paint: { 'fill-color': ['get', 'color'], 'fill-opacity': ['get', 'fillOpacity'] },
|
|
}, 'trip-route-casing')
|
|
const dashArrays: Record<string, number[] | undefined> = { solid: undefined, dash: [2, 2], dot: [0, 2] }
|
|
for (const dash of ['solid', 'dash', 'dot'] as const) {
|
|
map.addLayer({
|
|
id: PLUGIN_LINE_LAYER_IDS[dash],
|
|
type: 'line',
|
|
source: PLUGIN_LAYER_SOURCE_ID,
|
|
filter: ['==', ['get', 'dash'], dash],
|
|
paint: {
|
|
'line-color': ['get', 'color'],
|
|
'line-width': ['get', 'width'],
|
|
'line-opacity': ['get', 'opacity'],
|
|
...(dashArrays[dash] ? { 'line-dasharray': dashArrays[dash] } : {}),
|
|
},
|
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
|
}, 'trip-route-casing')
|
|
}
|
|
// A labelled feature answers a click with a plain-text popup (setText —
|
|
// never HTML). Unlabelled features stay inert, like the Leaflet twin.
|
|
const showLabel = (e: any) => { // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
const label = e.features?.[0]?.properties?.label
|
|
if (typeof label === 'string' && label && popupRef.current) {
|
|
popupRef.current.setLngLat(e.lngLat).setText(label).addTo(map)
|
|
}
|
|
}
|
|
for (const id of [PLUGIN_FILL_LAYER_ID, ...Object.values(PLUGIN_LINE_LAYER_IDS)]) {
|
|
map.on('click', id, showLabel)
|
|
}
|
|
}
|
|
// 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)
|
|
})
|
|
|
|
// Layer for the hand-positioned place pins, inside the canvas container so it
|
|
// shares the canvas's stacking context — the same place the library puts its own
|
|
// markers. `render` fires exactly when the map has drawn, so the pins land on the
|
|
// frame the user is looking at instead of on the last pointer sample.
|
|
if (isMapLibre) {
|
|
const layer = document.createElement('div')
|
|
layer.style.cssText = 'position:absolute;inset:0;overflow:hidden;pointer-events:none;'
|
|
map.getCanvasContainer().appendChild(layer)
|
|
pinLayerRef.current = layer
|
|
map.on('render', repositionPins)
|
|
}
|
|
|
|
// Set by the long-press handler below: the touchend tap that follows a
|
|
// long-press must not count as a normal map click (#1398).
|
|
let suppressNextClick = false
|
|
map.on('click', (e) => {
|
|
// The tap that ends a long-press would otherwise land here and clear
|
|
// the selection right after the Add-Place form opened (#1398).
|
|
if (suppressNextClick) { suppressNextClick = false; return }
|
|
const t = e.originalEvent.target as HTMLElement
|
|
if (t.closest('.mapboxgl-marker, .maplibregl-marker')) return // markers handle their own click
|
|
// A click that lands on a cluster bubble is the cluster's to handle
|
|
// (zoom-to-expand), not an "add place here" map click.
|
|
if (
|
|
typeof map.getLayer === 'function'
|
|
&& map.getLayer(PLACE_CLUSTER_CIRCLE_LAYER_ID)
|
|
&& typeof map.queryRenderedFeatures === 'function'
|
|
&& map.queryRenderedFeatures(e.point, { layers: [PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID] }).length > 0
|
|
) return
|
|
// Same for a click that landed on a track — it selects the track, it does
|
|
// not drop a new place on top of the line.
|
|
if (
|
|
typeof map.getLayer === 'function'
|
|
&& map.getLayer(GPX_HIT_LAYER_ID)
|
|
&& typeof map.queryRenderedFeatures === 'function'
|
|
&& map.queryRenderedFeatures(e.point, { layers: [GPX_HIT_LAYER_ID] }).length > 0
|
|
) return
|
|
// And for the drive itself while it can be reshaped: that click just put a via
|
|
// there, and it must not also drop a place on top of it (#1797).
|
|
if (
|
|
onRouteClickRef.current
|
|
&& typeof map.getLayer === 'function'
|
|
&& map.getLayer('trip-route-hit')
|
|
&& typeof map.queryRenderedFeatures === 'function'
|
|
&& map.queryRenderedFeatures(e.point, { layers: ['trip-route-hit'] }).length > 0
|
|
) return
|
|
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)
|
|
// Clear the hover card (and the anchored POI popup) as soon as the camera
|
|
// starts moving, and keep hover suppressed until it stops: the marker
|
|
// slides away under a stationary cursor, so mouseleave never fires (#1404).
|
|
const onCamStart = () => {
|
|
camMovingRef.current = true
|
|
hoverIdRef.current = null
|
|
setHoverPlace(null)
|
|
setHoverPos(null)
|
|
popupRef.current?.remove()
|
|
}
|
|
const onCamEnd = () => { camMovingRef.current = false }
|
|
map.on('movestart', onCamStart)
|
|
map.on('moveend', onCamEnd)
|
|
// "Add place here" on the GL map (#1398). Three routes into one handler:
|
|
// middle-click (the original binding), a plain right-click via the map's
|
|
// own contextmenu event — both GL libs suppress that event while the
|
|
// right-button rotate/pitch drag is active, so it can't fight the gesture,
|
|
// and it also covers Mac ctrl-click / two-finger tap — and a touch
|
|
// long-press, which neither GL lib synthesizes into contextmenu (Leaflet
|
|
// does, which is why the OSM map already worked on mobile).
|
|
const canvas = map.getCanvasContainer()
|
|
let lastContextFire = 0
|
|
const fireContext = (lngLat: { lat: number; lng: number }, originalEvent: MouseEvent | TouchEvent): boolean => {
|
|
// Android fires a native contextmenu for a long-press on top of our own
|
|
// timer — dedupe so the form doesn't open twice.
|
|
if (Date.now() - lastContextFire < 700) return false
|
|
lastContextFire = Date.now()
|
|
onClickRefs.current.context?.({ latlng: { lat: lngLat.lat, lng: lngLat.lng }, originalEvent })
|
|
return true
|
|
}
|
|
// MapLibre swallows the map contextmenu at the end of a right-button
|
|
// rotate/pitch drag, but mapbox-gl does NOT — and on Windows the DOM
|
|
// contextmenu arrives after mouseup, so every rotate would end by opening
|
|
// the Add-Place form. Track the right-button press position and drop a
|
|
// contextmenu whose pointer travelled like a drag rather than a click.
|
|
let rightDownAt: { x: number; y: number } | null = null
|
|
const onAuxDown = (ev: MouseEvent) => {
|
|
if (ev.button === 2) {
|
|
rightDownAt = { x: ev.clientX, y: ev.clientY }
|
|
return
|
|
}
|
|
if (ev.button !== 1) return
|
|
ev.preventDefault()
|
|
const rect = canvas.getBoundingClientRect()
|
|
const lngLat = map.unproject([ev.clientX - rect.left, ev.clientY - rect.top])
|
|
fireContext({ lat: lngLat.lat, lng: lngLat.lng }, 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)
|
|
map.on('contextmenu', (e: { lngLat: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
|
|
const down = rightDownAt
|
|
rightDownAt = null
|
|
if (down && Math.hypot(e.originalEvent.clientX - down.x, e.originalEvent.clientY - down.y) > 5) return
|
|
fireContext(e.lngLat, e.originalEvent)
|
|
})
|
|
// Touch long-press: 600 ms hold (Leaflet's tapHold feel) with a 10 px
|
|
// move tolerance so slow pans and pinches don't open the form.
|
|
let lpTimer: number | null = null
|
|
let lpStart: { x: number; y: number } | null = null
|
|
const cancelLongPress = () => {
|
|
if (lpTimer !== null) window.clearTimeout(lpTimer)
|
|
lpTimer = null
|
|
lpStart = null
|
|
}
|
|
const onTouchStart = (ev: TouchEvent) => {
|
|
// A fresh gesture clears a stale suppression flag: not every long-press
|
|
// is followed by a click (finger drag after the hold, Android's native
|
|
// contextmenu path), and the flag must never swallow a later real tap.
|
|
suppressNextClick = false
|
|
if (ev.touches.length !== 1) { cancelLongPress(); return }
|
|
if ((ev.target as HTMLElement).closest('.mapboxgl-marker, .maplibregl-marker')) return
|
|
const t = ev.touches[0]
|
|
const start = { x: t.clientX, y: t.clientY }
|
|
lpStart = start
|
|
lpTimer = window.setTimeout(() => {
|
|
lpTimer = null
|
|
const rect = canvas.getBoundingClientRect()
|
|
const lngLat = map.unproject([start.x - rect.left, start.y - rect.top])
|
|
lpStart = null
|
|
// Only suppress the tap when OUR fire opened the form — if the native
|
|
// contextmenu beat us to it (dedupe), no click needs swallowing.
|
|
if (fireContext({ lat: lngLat.lat, lng: lngLat.lng }, ev)) suppressNextClick = true
|
|
}, 600)
|
|
}
|
|
const onTouchMove = (ev: TouchEvent) => {
|
|
const t = ev.touches[0]
|
|
if (lpStart && (!t || Math.hypot(t.clientX - lpStart.x, t.clientY - lpStart.y) > 10)) cancelLongPress()
|
|
}
|
|
canvas.addEventListener('touchstart', onTouchStart, { passive: true })
|
|
canvas.addEventListener('touchmove', onTouchMove, { passive: true })
|
|
canvas.addEventListener('touchend', cancelLongPress)
|
|
canvas.addEventListener('touchcancel', cancelLongPress)
|
|
|
|
// 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 = typeof map.queryTerrainElevation === 'function'
|
|
? map.queryTerrainElevation([ll.lng, ll.lat])
|
|
: null
|
|
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 — PinPosition spells the runtime shape out.
|
|
marker.setLngLat([ll.lng, ll.lat, alt])
|
|
}
|
|
})
|
|
}
|
|
// Terrain altitude sync only matters with mapbox 3D/terrain on; skip the per-frame
|
|
// listener entirely for MapLibre and flat mapbox styles.
|
|
if (enableMapbox3d) map.on('render', syncMarkerAltitudes)
|
|
|
|
return () => {
|
|
canvas.removeEventListener('mousedown', onAuxDown)
|
|
canvas.removeEventListener('auxclick', onAuxClick)
|
|
canvas.removeEventListener('touchstart', onTouchStart)
|
|
canvas.removeEventListener('touchmove', onTouchMove)
|
|
canvas.removeEventListener('touchend', cancelLongPress)
|
|
canvas.removeEventListener('touchcancel', cancelLongPress)
|
|
cancelLongPress()
|
|
map.off('render', repositionPins)
|
|
markersRef.current.forEach(m => m.remove())
|
|
markersRef.current.clear()
|
|
pinLayerRef.current?.remove()
|
|
pinLayerRef.current = null
|
|
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
|
|
// Drop the debug handle too, or a style switch keeps every torn-down map
|
|
// (canvas and sources included) alive for the rest of the page's life. The
|
|
// identity check leaves a freshly built map alone if this cleanup runs late.
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
if ((window as any).__trek_map === map) delete (window as any).__trek_map
|
|
setMapReady(false)
|
|
}
|
|
}, [glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality]) // rebuild on provider/style changes only
|
|
|
|
// Pin the basemap label language to the UI language so labels don't fall back to the
|
|
// browser/OS locale and stack multiple scripts per place (e.g. "India/भारत/India", #1299).
|
|
// Mapbox Standard exposes this via a basemap config property; classic and MapLibre styles
|
|
// are left as-is. Runs on load (mapReady) and whenever the UI language changes.
|
|
useEffect(() => {
|
|
const map = mapRef.current
|
|
if (!map || !mapReady || isMapLibre || !isStandardFamily(glStyle)) return
|
|
try { map.setConfigProperty('basemap', 'language', basemapLanguage(mapLang)) } catch { /* style/SDK may not support the basemap language property */ }
|
|
}, [mapLang, mapReady, isMapLibre, glStyle])
|
|
|
|
// 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) {
|
|
// A custom uploaded image is shown directly — never auto-fetch a provider
|
|
// photo for it (that request would 404 for OSM-only places and, worse, the
|
|
// fetched thumb would shadow the user's own image). (#1136)
|
|
if (isCustomPlaceImage(place.image_url)) continue
|
|
const cacheKey = photoCacheKey(place)
|
|
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. The clustered GeoJSON source decides
|
|
// which points are currently unclustered, and we render the existing rich HTML
|
|
// marker DOM only for those visible leaves — clustered points show up as the GL
|
|
// cluster bubble + count instead.
|
|
useEffect(() => {
|
|
const map = mapRef.current
|
|
if (!map || !mapReady) 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 validPlaces = places.filter(hasValidCoords)
|
|
|
|
const reconcileMarkers = (visiblePlaces: PlaceWithCoords[]) => {
|
|
const ids = new Set(visiblePlaces.map(p => p.id))
|
|
|
|
markersRef.current.forEach((marker, id) => {
|
|
if (!ids.has(id)) {
|
|
marker.remove()
|
|
markersRef.current.delete(id)
|
|
// Removing a marker under the cursor (e.g. it just got clustered) never
|
|
// fires mouseleave, so drop its tooltip here to avoid orphaning it.
|
|
if (hoverIdRef.current === id) { hoverIdRef.current = null; setHoverPlace(null); setHoverPos(null) }
|
|
}
|
|
})
|
|
|
|
visiblePlaces.forEach(place => {
|
|
const orderNumbers = dayOrderMap[place.id] ?? null
|
|
const pck = photoCacheKey(place)
|
|
// A custom image wins over the auto-fetched thumb; otherwise fall back to it.
|
|
const photoUrl = isCustomPlaceImage(place.image_url) ? place.image_url! : ((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)
|
|
// Drag onto a day in the plan (#891). Markers are rebuilt from scratch
|
|
// on every reconcile, so the listeners go with the element and need no
|
|
// teardown of their own.
|
|
if (markersDraggableRef.current) makeMarkerDraggable(el, place.id)
|
|
el.addEventListener('click', (ev) => {
|
|
ev.stopPropagation()
|
|
// Clear the card right away — the flyTo that follows moves the marker
|
|
// out from under the cursor and mouseleave never fires (#1404).
|
|
hoverIdRef.current = null
|
|
setHoverPlace(null)
|
|
setHoverPos(null)
|
|
onClickRefs.current.marker?.(place.id)
|
|
})
|
|
el.addEventListener('mouseenter', (ev) => {
|
|
if (hoverDisabledRef.current || camMovingRef.current) return
|
|
hoverIdRef.current = place.id
|
|
setHoverPlace(place as Place & { category_color?: string; category_icon?: string; category_name?: string })
|
|
setHoverPos({ x: (ev as MouseEvent).clientX, y: (ev as MouseEvent).clientY })
|
|
})
|
|
el.addEventListener('mousemove', (ev) => {
|
|
if (hoverDisabledRef.current || camMovingRef.current) return
|
|
setHoverPos({ x: (ev as MouseEvent).clientX, y: (ev as MouseEvent).clientY })
|
|
})
|
|
el.addEventListener('mouseleave', () => {
|
|
if (hoverDisabledRef.current) return
|
|
hoverIdRef.current = null
|
|
setHoverPlace(null)
|
|
setHoverPos(null)
|
|
})
|
|
// 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.
|
|
markersRef.current.set(place.id, attachPin(map, gl, pinLayerRef.current, el, place.lng, place.lat))
|
|
})
|
|
}
|
|
|
|
const source = map.getSource(PLACE_CLUSTER_SOURCE_ID) as mapboxgl.GeoJSONSource | undefined
|
|
if (!source || typeof map.querySourceFeatures !== 'function') {
|
|
// No cluster source (e.g. style without it / test env): fall back to the
|
|
// original behaviour and draw a marker for every place.
|
|
reconcileMarkers(validPlaces)
|
|
return
|
|
}
|
|
|
|
source.setData(buildPlaceClusterData(places) as any)
|
|
const placesById = new Map<number, PlaceWithCoords>(validPlaces.map(place => [place.id, place]))
|
|
let raf: number | null = null
|
|
const runReconcile = () => {
|
|
raf = null
|
|
const features = map.querySourceFeatures(PLACE_CLUSTER_SOURCE_ID, { filter: ['!', ['has', 'point_count']] }) || []
|
|
const seen = new Set<number>()
|
|
const visiblePlaces: PlaceWithCoords[] = []
|
|
for (const feature of features) {
|
|
const rawId = feature?.properties?.placeId
|
|
const id = typeof rawId === 'string' ? Number(rawId) : rawId
|
|
if (typeof id !== 'number' || Number.isNaN(id) || seen.has(id)) continue
|
|
const place = placesById.get(id)
|
|
if (!place) continue
|
|
seen.add(id)
|
|
visiblePlaces.push(place)
|
|
}
|
|
reconcileMarkers(visiblePlaces)
|
|
}
|
|
const scheduleReconcile = () => {
|
|
if (raf !== null) return
|
|
raf = requestAnimationFrame(runReconcile)
|
|
}
|
|
|
|
// Cluster membership only settles once the source has (re)indexed and the
|
|
// viewport stops moving, so reconcile on the next frame and on every
|
|
// idle/move/zoom.
|
|
scheduleReconcile()
|
|
map.once('idle', scheduleReconcile)
|
|
map.on('moveend', scheduleReconcile)
|
|
map.on('zoomend', scheduleReconcile)
|
|
|
|
return () => {
|
|
if (raf !== null) cancelAnimationFrame(raf)
|
|
map.off('moveend', scheduleReconcile)
|
|
map.off('zoomend', scheduleReconcile)
|
|
map.off('idle', scheduleReconcile)
|
|
}
|
|
}, [places, selectedPlaceId, dayOrderMap, photoUrls, mapReady, glProvider, clusterLoosely])
|
|
|
|
// Search results stay separate from the planned itinerary.
|
|
useEffect(() => {
|
|
const map = mapRef.current
|
|
if (!map || !mapReady) return
|
|
const redraw = () => {
|
|
popupRef.current?.remove()
|
|
poiMarkersRef.current.forEach(m => m.remove())
|
|
poiMarkersRef.current = []
|
|
poiCleanupRef.current.forEach(off => off())
|
|
poiCleanupRef.current = []
|
|
const groups = clusterLoosely ? clusterPois(pois, poi => map.project([poi.lng, poi.lat]), map.getZoom()) : pois.map(poi => ({ pois: [poi], lat: poi.lat, lng: poi.lng }))
|
|
for (const group of groups) {
|
|
if (group.pois.length > 1) {
|
|
const el = document.createElement('button')
|
|
el.type = 'button'
|
|
el.style.cssText = 'width:38px;height:38px;border:0;padding:0;background:transparent;'
|
|
el.setAttribute('aria-label', t('roadtrip.poi.found', { count: group.pois.length }))
|
|
el.innerHTML = poiClusterMarkup(group.pois.length)
|
|
el.addEventListener('click', event => {
|
|
event.stopPropagation()
|
|
if (map.getZoom() >= POI_CLUSTER_DETAIL_ZOOM) {
|
|
popupRef.current?.setLngLat([group.lng, group.lat]).setDOMContent(poiClusterList(group.pois, poi => {
|
|
popupRef.current?.remove(); onPoiClickRef.current?.(poi)
|
|
})).addTo(map)
|
|
} else {
|
|
const bounds = new gl.LngLatBounds()
|
|
group.pois.forEach(poi => bounds.extend([poi.lng, poi.lat]))
|
|
map.fitBounds(bounds, { padding: 70, maxZoom: POI_CLUSTER_DETAIL_ZOOM })
|
|
}
|
|
})
|
|
poiMarkersRef.current.push(attachPin(map, gl, pinLayerRef.current, el, group.lng, group.lat))
|
|
continue
|
|
}
|
|
const poi = group.pois[0]
|
|
const el = createPoiMarkerElement(poi.category, poi.brand_wikidata)
|
|
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) })
|
|
// Dragging a hit onto the drive puts it where it is passed, rather than where the
|
|
// corridor happened to project it. Only wired when someone is listening for it.
|
|
if (onPoiDropRef.current) poiCleanupRef.current.push(makePoiDraggable(el, poi.osm_id))
|
|
poiMarkersRef.current.push(attachPin(map, gl, pinLayerRef.current, el, poi.lng, poi.lat))
|
|
}
|
|
}
|
|
redraw()
|
|
if (clusterLoosely) map.on('moveend', redraw)
|
|
return () => {
|
|
map.off('moveend', redraw)
|
|
poiCleanupRef.current.forEach(off => off())
|
|
poiCleanupRef.current = []
|
|
poiMarkersRef.current.forEach(pin => pin.remove())
|
|
poiMarkersRef.current = []
|
|
popupRef.current?.remove()
|
|
}
|
|
}, [pois, mapReady, glProvider, clusterLoosely, t])
|
|
|
|
// Fetch plugin map contributions (markers + layers) per trip. Fail-safe: an
|
|
// error or missing tripId just means no plugin overlays, the core map is fine.
|
|
useEffect(() => {
|
|
if (tripId == null) { setPluginMarkers([]); setPluginLayers([]); return }
|
|
let alive = true
|
|
pluginsApi.mapMarkers(tripId)
|
|
.then(r => { if (alive) setPluginMarkers(r.markers || []) })
|
|
.catch(() => { if (alive) setPluginMarkers([]) })
|
|
pluginsApi.mapLayers(tripId)
|
|
.then(r => { if (alive) setPluginLayers(r.layers || []) })
|
|
.catch(() => { if (alive) setPluginLayers([]) })
|
|
return () => { alive = false }
|
|
}, [tripId])
|
|
|
|
// Update plugin layer geojson
|
|
useEffect(() => {
|
|
const map = mapRef.current
|
|
if (!map || !mapReady) return
|
|
const src = map.getSource(PLUGIN_LAYER_SOURCE_ID) as mapboxgl.GeoJSONSource | undefined
|
|
if (!src) return
|
|
src.setData(buildPluginLayerData(pluginLayers))
|
|
}, [pluginLayers, mapReady, glProvider])
|
|
|
|
// Reconcile the via-point markers of a plugin route (charging stops) — small
|
|
// tone-ringed dots, popup with label + planned stop time on tap.
|
|
useEffect(() => {
|
|
const map = mapRef.current
|
|
if (!map || !mapReady) return
|
|
routeViaMarkersRef.current.forEach(m => m.remove())
|
|
routeViaMarkersRef.current = []
|
|
const pauses: HTMLElement[] = []
|
|
const unbind: (() => void)[] = []
|
|
for (const v of routeVias) {
|
|
const el = document.createElement('div')
|
|
el.style.cssText = 'width:13px;height:13px;cursor:pointer;'
|
|
const color = PLUGIN_TONE_COLORS[v.tone] ?? PLUGIN_TONE_COLORS.default
|
|
el.innerHTML = `<span style="display:block;width:13px;height:13px;border-radius:50%;background:#fff;border:3.5px solid ${color};box-shadow:0 1px 4px rgba(0,0,0,0.35);box-sizing:border-box"></span>`
|
|
if (v.nightPause) {
|
|
el.style.cssText = 'width:0;height:0;overflow:visible;cursor:pointer;'
|
|
el.innerHTML = nightPauseMarker(v)
|
|
el.setAttribute('aria-label', v.label ?? '')
|
|
pauses.push(el)
|
|
}
|
|
if (v.hoverCard) {
|
|
const show = (ev: MouseEvent) => {
|
|
if (hoverDisabledRef.current || camMovingRef.current) return
|
|
hoverIdRef.current = null
|
|
setHoverPlace({ name: v.label, routeVia: v })
|
|
setHoverPos({ x: ev.clientX, y: ev.clientY })
|
|
}
|
|
el.addEventListener('mouseenter', show)
|
|
el.addEventListener('mousemove', show)
|
|
el.addEventListener('mouseleave', () => { setHoverPlace(null); setHoverPos(null) })
|
|
}
|
|
if (v.label || v.dwellSeconds != null) {
|
|
const text = [v.label, v.dwellSeconds != null ? formatViaDwellGl(v.dwellSeconds) : null].filter(Boolean).join(' · ')
|
|
el.addEventListener('click', (ev) => {
|
|
ev.stopPropagation()
|
|
popupRef.current?.setLngLat([v.lng, v.lat]).setText(text).addTo(map)
|
|
})
|
|
}
|
|
const pin = attachPin(map, gl, pinLayerRef.current, el, v.lng, v.lat)
|
|
routeViaMarkersRef.current.push(pin)
|
|
if (v.nightPause && dayBoundaryControls) unbind.push(bindDayBoundaryDrag(el, v, dayBoundaryControls, {
|
|
project: (lat, lng) => {
|
|
const point = map.project([lng, lat])
|
|
const rect = map.getContainer().getBoundingClientRect()
|
|
return { x: point.x + rect.left, y: point.y + rect.top }
|
|
},
|
|
setPosition: (lat, lng) => { pin.setLngLat([lng, lat]) },
|
|
lock: () => {
|
|
const enabled = map.dragPan.isEnabled()
|
|
map.dragPan.disable()
|
|
return () => { if (enabled) map.dragPan.enable() }
|
|
},
|
|
}))
|
|
}
|
|
const updateZoom = () => {
|
|
const visible = map.getZoom() >= NIGHT_PAUSE_MIN_ZOOM
|
|
for (const el of pauses) el.style.display = visible ? '' : 'none'
|
|
}
|
|
updateZoom()
|
|
map.on('zoomend', updateZoom)
|
|
return () => { map.off('zoomend', updateZoom); unbind.forEach(dispose => dispose()) }
|
|
}, [routeVias, mapReady, glProvider, dayBoundaryControls])
|
|
|
|
// Reconcile plugin markers (imperative, same lifecycle as the POI markers).
|
|
useEffect(() => {
|
|
const map = mapRef.current
|
|
if (!map || !mapReady) return
|
|
pluginMarkersRef.current.forEach(m => m.remove())
|
|
pluginMarkersRef.current = []
|
|
for (const mk of pluginMarkers) {
|
|
const el = createPluginMarkerElement(mk.tone)
|
|
if (mk.label || mk.popupText || mk.url) {
|
|
el.addEventListener('click', (ev) => {
|
|
ev.stopPropagation()
|
|
popupRef.current?.setLngLat([mk.lng, mk.lat]).setDOMContent(buildPluginMarkerPopup(mk)).addTo(map)
|
|
})
|
|
}
|
|
const m = attachPin(map, gl, pinLayerRef.current, el, mk.lng, mk.lat)
|
|
pluginMarkersRef.current.push(m)
|
|
}
|
|
}, [pluginMarkers, mapReady, glProvider])
|
|
|
|
// 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 || [])
|
|
.map((seg, i) => ({ seg, colors: routeColors?.[i] }))
|
|
.filter(({ seg }) => seg && seg.length > 1)
|
|
.map(({ seg, colors }) => ({
|
|
type: 'Feature' as const,
|
|
// Null rather than absent: `coalesce` in the paint expression falls through on
|
|
// null, and an absent property would make every line the default colour.
|
|
properties: { color: colors?.line ?? null, casing: colors?.casing ?? null },
|
|
geometry: { type: 'LineString' as const, coordinates: seg.map(([lat, lng]) => [lng, lat]) },
|
|
}))
|
|
src.setData({ type: 'FeatureCollection', features })
|
|
}, [route, routeColors, mapReady])
|
|
|
|
// Update access-spur geojson
|
|
useEffect(() => {
|
|
const map = mapRef.current
|
|
if (!map) return
|
|
const src = map.getSource('trip-access') as mapboxgl.GeoJSONSource | undefined
|
|
if (!src) return
|
|
src.setData({
|
|
type: 'FeatureCollection',
|
|
features: (accessLines || []).map(spur => ({
|
|
type: 'Feature' as const,
|
|
properties: { meters: Math.round(spur.meters) },
|
|
geometry: { type: 'LineString' as const, coordinates: spur.line.map(([lat, lng]) => [lng, lat]) },
|
|
})),
|
|
})
|
|
}, [accessLines, 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: resolveTrackColor(place),
|
|
cased: hasManualTrackColor(place),
|
|
place_id: place.id,
|
|
},
|
|
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(() => (
|
|
visibleRouteReservations(reservations, { visibleConnectionIds, showTransitRoutes, selectedDayId, days })
|
|
), [reservations, visibleConnectionIds, showTransitRoutes, selectedDayId, days])
|
|
// Real road geometry for car/bus/taxi/bicycle bookings (straight line until it loads/if it fails).
|
|
const transportRoutes = useTransportRoutes(visibleReservations)
|
|
|
|
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),
|
|
}, gl.Marker as any)
|
|
}
|
|
reservationOverlayRef.current.update(visibleReservations, {
|
|
showConnections: true,
|
|
showStats: showReservationStats,
|
|
showEndpointLabels,
|
|
onEndpointClick: (id) => onReservationClickRef.current?.(id),
|
|
}, transportRoutes)
|
|
}, [visibleReservations, transportRoutes, showReservationStats, showEndpointLabels, mapReady, glProvider])
|
|
|
|
// 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<number | null>(-1)
|
|
const pendingRouteFitRef = useRef<{ fitKey: number | null; routeKey: string } | null>(null)
|
|
const fitRanRef = useRef(false)
|
|
useEffect(() => {
|
|
const fitKeyChanged = fitKey !== prevFitKey.current
|
|
const routeArrivedForPendingFit =
|
|
!fitKeyChanged
|
|
&& pendingRouteFitRef.current?.fitKey === fitKey
|
|
&& !!routeFitKey
|
|
&& routeFitKey !== pendingRouteFitRef.current.routeKey
|
|
if (!fitKeyChanged && !routeArrivedForPendingFit) return
|
|
const map = mapRef.current
|
|
if (!map) return
|
|
|
|
// The map was built framed on these very places, so fitting now would only re-do that —
|
|
// and its maxZoom would overrule the gentler zoom a single place opens at. Adopt the
|
|
// current fitKey and stand down; every later fit (picking a day) still runs.
|
|
if (!fitRanRef.current && framedOnMountRef.current) {
|
|
fitRanRef.current = true
|
|
prevFitKey.current = fitKey
|
|
pendingRouteFitRef.current = null
|
|
return
|
|
}
|
|
fitRanRef.current = true
|
|
if (fitKeyChanged) {
|
|
prevFitKey.current = fitKey
|
|
// Only wait for better geometry when a route is already on screen: the day's
|
|
// route lands as straight lines in the same batch as the fit, then upgrades to
|
|
// the real road geometry a moment later. With no route drawn, none is coming for
|
|
// this fit — arming the slot anyway would let a route toggled on much later
|
|
// (after the user has panned somewhere else) yank the camera back.
|
|
pendingRouteFitRef.current = routeFitKey ? { fitKey, routeKey: routeFitKey } : null
|
|
}
|
|
const target = dayPlaces.length > 0 ? dayPlaces : places
|
|
const markerPoints = target.filter(hasValidCoords).map(p => [p.lat, p.lng] as [number, number])
|
|
const fitPoints = routeCoords.length > 0 ? [...routeCoords, ...markerPoints] : markerPoints
|
|
if (fitPoints.length === 0) return
|
|
const bounds = new gl.LngLatBounds()
|
|
fitPoints.forEach(([lat, lng]) => bounds.extend([lng, lat]))
|
|
let fitted = false
|
|
const run = () => {
|
|
try {
|
|
map.fitBounds(bounds, {
|
|
padding: paddingOpts,
|
|
maxZoom: 15,
|
|
pitch: enableMapbox3d ? 45 : 0,
|
|
duration: 400,
|
|
})
|
|
fitted = true
|
|
} catch { /* noop */ }
|
|
}
|
|
run()
|
|
if (!fitted && typeof map.once === 'function') map.once('load', run)
|
|
if (routeArrivedForPendingFit) pendingRouteFitRef.current = null
|
|
}, [fitKey, routeFitKey]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// Frame whatever was handed over. Nothing happens when it empties, so closing the
|
|
// picker leaves the map where the user left it rather than snapping back.
|
|
useEffect(() => {
|
|
const map = mapRef.current
|
|
if (!map || !focusPoints?.length) return
|
|
const bounds = new gl.LngLatBounds()
|
|
focusPoints.forEach(([lat, lng]) => bounds.extend([lng, lat]))
|
|
// A day fit still waiting on its route must not overwrite this a moment later.
|
|
pendingRouteFitRef.current = null
|
|
try {
|
|
map.fitBounds(bounds, {
|
|
padding: paddingOpts,
|
|
maxZoom: 15,
|
|
pitch: enableMapbox3d ? 45 : 0,
|
|
duration: 400,
|
|
})
|
|
} catch { /* noop */ }
|
|
}, [focusPoints]) // 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: enableMapbox3d ? 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, enableMapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// External center/zoom prop changes — jump without animation
|
|
const jumpedToRef = useRef<[number, number] | null>(null)
|
|
useEffect(() => {
|
|
const map = mapRef.current
|
|
if (!map) return
|
|
// Not on mount: the map was just built with its own camera, framed on the places, and
|
|
// jumping to the prop centre here would throw that away and land on the world view.
|
|
// This effect is for *changes* to the prop, which only arrive later.
|
|
const previous = jumpedToRef.current
|
|
jumpedToRef.current = [center[0], center[1]]
|
|
if (!previous || (previous[0] === center[0] && previous[1] === center[1])) 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, gl.Marker as any)
|
|
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, glProvider])
|
|
|
|
// Satellite, the same setting the Leaflet map reads, so switching it on one renderer
|
|
// and reloading into the other keeps the choice. `styledata` is subscribed because a
|
|
// basemap change rebuilds the style from scratch and takes the imagery with it.
|
|
useEffect(() => {
|
|
const map = mapRef.current
|
|
if (!map || !mapReady) return
|
|
const apply = () => applySatellite(map, isSatellite)
|
|
apply()
|
|
map.on('styledata', apply)
|
|
return () => { map.off('styledata', apply) }
|
|
}, [isSatellite, mapReady, glProvider])
|
|
|
|
if (!isMapLibre && !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
|
|
// When the day-detail panel is open it slides up over the map (bottom: navh+20,
|
|
// height var(--day-panel-h)) and covers the button's band, so lift the button
|
|
// above it; otherwise keep the plain bottom-nav offset. #1348
|
|
const buttonBottom = hasDayDetail
|
|
? 'calc(var(--bottom-nav-h, 84px) + 20px + var(--day-panel-h, 0px) + 12px)'
|
|
: 'calc(var(--bottom-nav-h, 84px) + 12px)'
|
|
|
|
const HoverIcon = (hoverPlace?.category_icon && CATEGORY_ICON_MAP[hoverPlace.category_icon]) || CATEGORY_ICON_MAP['MapPin']
|
|
|
|
return (
|
|
<div className="w-full h-full relative">
|
|
<div ref={containerRef} className="w-full h-full" />
|
|
{isMobile && (
|
|
<LocationButton
|
|
mode={trackingMode}
|
|
error={trackingError}
|
|
errorCode={trackingErrorCode}
|
|
onClick={cycleTrackingMode}
|
|
bottomOffset={buttonBottom as unknown as number}
|
|
/>
|
|
)}
|
|
{/* Same pill, same corner, same offsets as the Leaflet map: the switch should not
|
|
move when an instance changes renderer. The day panel is a centred card on the
|
|
desktop map and never reaches the pill, but on a phone it is full width and
|
|
does, which is why the lift is only there. */}
|
|
<div style={{
|
|
position: 'absolute', left: leftWidth + 20, zIndex: 1000, pointerEvents: 'none',
|
|
top: hasInspector ? 80 : undefined,
|
|
bottom: hasInspector ? undefined : isMobile && hasDayDetail
|
|
? 'calc(var(--bottom-nav-h, 0px) + 20px + var(--day-panel-h, 0px) + 12px)'
|
|
: 'calc(var(--bottom-nav-h, 0px) + 12px)',
|
|
}}>
|
|
<MapLayerSwitcher active={baseLayer as BaseLayer} onToggle={toggleBaseLayer} />
|
|
</div>
|
|
{/* Hover tooltip — cursor-following name/category/address card, identical to
|
|
the Leaflet map's overlay (no anchored popup, no photo). */}
|
|
{!hoverDisabled && hoverPlace?.routeVia?.nightPause && hoverPos && !isMobile && routeVias.includes(hoverPlace.routeVia) && (
|
|
<NightPauseTooltip label={hoverPlace.name ?? ''} x={hoverPos.x} y={hoverPos.y} />
|
|
)}
|
|
{!hoverDisabled && hoverPlace && !hoverPlace.routeVia?.nightPause && hoverPos && !isMobile && (!hoverPlace.routeVia || routeVias.includes(hoverPlace.routeVia)) && (
|
|
<div data-testid="tooltip" style={{
|
|
position: 'fixed',
|
|
left: hoverPos.x + 14,
|
|
top: hoverPos.y - 10,
|
|
zIndex: 9999,
|
|
pointerEvents: 'none',
|
|
background: 'white',
|
|
borderRadius: 8,
|
|
boxShadow: '0 2px 10px rgba(0,0,0,0.15)',
|
|
padding: '6px 10px',
|
|
fontFamily: 'var(--font-system)',
|
|
maxWidth: 220,
|
|
whiteSpace: 'nowrap',
|
|
}}>
|
|
<div style={{ fontWeight: 600, fontSize: 12, color: '#111827', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
{hoverPlace.name}
|
|
</div>
|
|
{hoverPlace.category_name && (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
|
|
<HoverIcon size={10} style={{ color: hoverPlace.category_color || '#6b7280', flexShrink: 0 }} />
|
|
<span style={{ fontSize: 11, color: '#6b7280' }}>{hoverPlace.category_name}</span>
|
|
</div>
|
|
)}
|
|
{hoverPlace.address && (
|
|
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
{hoverPlace.address}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|