feat(tours): add non-motorized day tour planning

This commit is contained in:
elmocito
2026-10-04 12:59:55 +02:00
parent c93cf2bce9
commit 9e475f447a
202 changed files with 16129 additions and 383 deletions
+111 -29
View File
@@ -9,7 +9,8 @@ import { renderIconMarkup } from '../../utils/iconMarkup'
import type mapboxgl from 'mapbox-gl'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n/TranslationContext'
import { MapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type BaseLayer } from './MapLayerSwitcher'
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type BaseLayer, type TourBaseLayer } from './MapLayerSwitcher'
import { OPENTOPOMAP_TILE_URL, OPENTOPOMAP_TILE_ATTRIBUTION, OPENTOPOMAP_TILE_MAXZOOM } from '../../constants/mapDefaults'
import { MapLockPill } from './MapLockPill'
import { useAuthStore } from '../../store/authStore'
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
@@ -41,6 +42,7 @@ import { ratingBadgeHtml } from './ratingBadge'
import type { Poi } from './poiCategories'
import { poiPinParts } from './poiMarker'
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
import { parseRenderableRouteGeometry, type RouteProfileFocus } from '../../utils/routeGeometry'
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'
@@ -199,6 +201,13 @@ interface Props {
onChooseAlternative?: (index: number) => void
/** Reports which option the pointer is over, so the list and the map agree. */
onHighlightAlternative?: (index: number | null) => void
/** Generic numbered control points for list-first route editors. */
plannerWaypoints?: Array<{ id: string; lat: number; lng: number }>
selectedPlannerWaypointId?: string | null
onPlannerWaypointClick?: (id: string) => void
routeProfileFocus?: RouteProfileFocus | null
viewBaseLayer?: TourBaseLayer
onViewBaseLayerChange?: (layer: TourBaseLayer) => void
/**
* An explicit stretch of map to frame, independent of the day being shown.
*
@@ -330,23 +339,27 @@ function addPlaceClusterLayers(map: any): void {
* yet. The catch covers that case, and `styledata` brings the pass back once it is in.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function applySatellite(map: any, on: boolean): void {
function applySatellite(map: any, on: boolean, topo = false): void {
const layerId = topo ? 'trip-topo-raster' : SATELLITE_LAYER_ID
const sourceId = topo ? 'trip-topo' : SATELLITE_SOURCE_ID
try {
if (map.getLayer(SATELLITE_LAYER_ID)) {
map.setLayoutProperty(SATELLITE_LAYER_ID, 'visibility', on ? 'visible' : 'none')
if (map.getLayer(layerId)) {
map.setLayoutProperty(layerId, 'visibility', on ? 'visible' : 'none')
return
}
if (!on) return // Nothing to build while it is switched off.
// The layer is what is missing, not necessarily the source: a pass that got the
// source in and then failed on the layer would otherwise leave a source that stops
// every later pass from ever building the layer.
if (!map.getSource(SATELLITE_SOURCE_ID)) {
map.addSource(SATELLITE_SOURCE_ID, {
if (!map.getSource(sourceId)) {
map.addSource(sourceId, {
type: 'raster',
tiles: [SATELLITE_TILE_URL],
tiles: topo
? ['a', 'b', 'c'].map(subdomain => OPENTOPOMAP_TILE_URL.replace('{s}', subdomain))
: [SATELLITE_TILE_URL],
tileSize: 256,
maxzoom: SATELLITE_TILE_MAXZOOM,
attribution: SATELLITE_TILE_ATTRIBUTION,
maxzoom: topo ? OPENTOPOMAP_TILE_MAXZOOM : SATELLITE_TILE_MAXZOOM,
attribution: topo ? OPENTOPOMAP_TILE_ATTRIBUTION : SATELLITE_TILE_ATTRIBUTION,
})
}
// Under the first thing TREK draws, over everything the basemap style draws.
@@ -355,9 +368,9 @@ function applySatellite(map: any, on: boolean): void {
const firstOwn = layers.find((l: { id: string }) =>
OWN_LAYER_PREFIXES.some(prefix => l.id.startsWith(prefix)))
map.addLayer({
id: SATELLITE_LAYER_ID,
id: layerId,
type: 'raster',
source: SATELLITE_SOURCE_ID,
source: sourceId,
paint: { 'raster-opacity': 1 },
}, firstOwn?.id)
} catch { /* a style that refuses the layer keeps the plain basemap */ }
@@ -727,6 +740,12 @@ export function MapViewGL({
activeAlternative,
onChooseAlternative,
onHighlightAlternative,
plannerWaypoints = [],
selectedPlannerWaypointId = null,
onPlannerWaypointClick,
routeProfileFocus = null,
viewBaseLayer,
onViewBaseLayerChange,
roadtripVias,
onMoveVia,
onRemoveVia,
@@ -740,7 +759,9 @@ export function MapViewGL({
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 globalBaseLayer = useSettingsStore(s => s.settings.map_base_layer) || 'default'
const baseLayer = viewBaseLayer ?? globalBaseLayer
const hasViewBaseLayer = viewBaseLayer !== undefined && typeof onViewBaseLayerChange === 'function'
const updateSetting = useSettingsStore(s => s.updateSetting)
const isSatellite = baseLayer === 'satellite'
const toggleBaseLayer = useCallback(() => {
@@ -809,6 +830,8 @@ export function MapViewGL({
const routeViaMarkersRef = useRef<PlacePin[]>([])
/** The road-trip via handles (#1797) — hand-positioned like the rest, so listed here. */
const viaPinsRef = useRef<PlacePin[]>([])
const plannerWaypointPinsRef = useRef<PlacePin[]>([])
const profileFocusPinRef = useRef<PlacePin | null>(null)
/** 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
@@ -819,6 +842,8 @@ export function MapViewGL({
pluginMarkersRef.current.forEach(pin => pin.reposition())
routeViaMarkersRef.current.forEach(pin => pin.reposition())
viaPinsRef.current.forEach(pin => pin.reposition())
plannerWaypointPinsRef.current.forEach(pin => pin.reposition())
profileFocusPinRef.current?.reposition()
altLabelsRef.current.forEach(pin => pin.reposition())
}, [])
// Single reusable hover popup for POI markers. Planned places use the
@@ -837,6 +862,61 @@ export function MapViewGL({
* drag is therefore hand-rolled — pointer events on the element, unproject on move.
*/
const viaCleanupRef = useRef<(() => void)[]>([])
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady) return
plannerWaypointPinsRef.current.forEach(pin => pin.remove())
plannerWaypointPinsRef.current = []
for (const [index, point] of plannerWaypoints.entries()) {
const selected = point.id === selectedPlannerWaypointId
const size = selected ? 30 : 26
const el = document.createElement('button')
el.type = 'button'
el.textContent = String(index + 1)
el.setAttribute('aria-label', `Waypoint ${index + 1}`)
el.style.cssText = `display:flex;width:${size}px;height:${size}px;align-items:center;justify-content:center;border-radius:9999px;background:#0a84ff;color:white;border:${selected ? 3 : 2}px solid white;box-shadow:0 2px 7px rgba(0,0,0,.4);font:700 11px system-ui;cursor:pointer;padding:0`
const swallow = (event: Event) => event.stopPropagation()
const select = (event: Event) => { event.stopPropagation(); onPlannerWaypointClick?.(point.id) }
el.addEventListener('pointerdown', swallow)
el.addEventListener('touchstart', swallow, { passive: true })
el.addEventListener('click', select)
plannerWaypointPinsRef.current.push(attachPin(map, gl, pinLayerRef.current, el, point.lng, point.lat))
}
return () => {
plannerWaypointPinsRef.current.forEach(pin => pin.remove())
plannerWaypointPinsRef.current = []
}
}, [gl, mapReady, onPlannerWaypointClick, plannerWaypoints, selectedPlannerWaypointId])
useEffect(() => () => {
profileFocusPinRef.current?.remove()
profileFocusPinRef.current = null
}, [gl, mapReady])
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady) return
if (!routeProfileFocus) {
profileFocusPinRef.current?.remove()
profileFocusPinRef.current = null
return
}
let pin = profileFocusPinRef.current
if (!pin) {
const element = document.createElement('div')
element.setAttribute('aria-hidden', 'true')
element.dataset.tourProfileFocus = 'true'
element.style.cssText = 'width:18px;height:18px;border:3px solid var(--bg-card);border-radius:50%;background:var(--text-muted);box-shadow:0 0 0 1px var(--text-secondary);pointer-events:none;box-sizing:border-box;'
pin = attachPin(map, gl, pinLayerRef.current, element, routeProfileFocus.lng, routeProfileFocus.lat)
profileFocusPinRef.current = pin
} else {
pin.setLngLat([routeProfileFocus.lng, routeProfileFocus.lat])
}
pin.el.style.pointerEvents = 'none'
}, [gl, mapReady, routeProfileFocus])
/**
* The list only changes when a via does, and the callbacks are read through a ref.
*
@@ -2188,20 +2268,17 @@ export function MapViewGL({
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 [] }
const coords = parseRenderableRouteGeometry(place.route_geometry)
if (!coords) 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]) },
}]
})
src.setData({ type: 'FeatureCollection', features })
}, [places, mapReady])
@@ -2405,11 +2482,14 @@ export function MapViewGL({
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady) return
const apply = () => applySatellite(map, isSatellite)
const apply = () => {
applySatellite(map, isSatellite)
applySatellite(map, baseLayer === 'topo', true)
}
apply()
map.on('styledata', apply)
return () => { map.off('styledata', apply) }
}, [isSatellite, mapReady, glProvider])
}, [isSatellite, baseLayer, mapReady, glProvider])
if (!isMapLibre && !mapboxToken) {
return (
@@ -2455,7 +2535,9 @@ export function MapViewGL({
: 'calc(var(--bottom-nav-h, 0px) + 12px)',
}}>
{onToggleFollow && <MapLockPill locked={!followSelection} onToggle={onToggleFollow} />}
<MapLayerSwitcher active={baseLayer as BaseLayer} onToggle={toggleBaseLayer} />
{hasViewBaseLayer
? <TourMapLayerSwitcher active={baseLayer as TourBaseLayer} onChange={onViewBaseLayerChange} />
: <MapLayerSwitcher active={globalBaseLayer 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). */}