mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
feat(tours): add non-motorized day tour planning
This commit is contained in:
@@ -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). */}
|
||||
|
||||
Reference in New Issue
Block a user