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
+93 -19
View File
@@ -59,14 +59,15 @@ import type { Poi } from './poiCategories'
import { poiDetailRowKey, poiDetailRows, poiPinCacheKey, poiPinParts } from './poiMarker'
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
import DawarichTrailLayer from './DawarichTrailLayer'
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_MAXZOOM, AMAP_SATELLITE, attributionForTile } from '../../constants/mapDefaults'
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, OPENTOPOMAP_TILE_URL, OPENTOPOMAP_TILE_ATTRIBUTION, OPENTOPOMAP_TILE_MAXZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_MAXZOOM, AMAP_SATELLITE, attributionForTile } from '../../constants/mapDefaults'
import { crsForBasemap } from './gcj02Crs'
import { isGcj02Basemap, resolveBasemap } from '../../utils/tileUrl'
import VectorBasemap from './VectorBasemap'
import { useSettingsStore } from '../../store/settingsStore'
import { MapLayerSwitcher, MAP_LAYER_SWITCHER_INSET } from './MapLayerSwitcher'
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type TourBaseLayer } from './MapLayerSwitcher'
import { MapLockPill } from './MapLockPill'
import { computeMapViewport, TILE_SIZE_RASTER, type ViewportPadding } from '../../utils/mapViewport'
import { parseRenderableRouteGeometry } from '../../utils/routeGeometry'
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
@@ -356,6 +357,20 @@ function ViewportController({ onViewportChange }: { onViewportChange?: (b: { sou
return null
}
function MapCameraSnapshot({ onChange }: { onChange: (center: [number, number], zoom: number) => void }) {
const map = useMap()
useEffect(() => {
const save = () => {
const center = map.getCenter()
onChange([center.lat, center.lng], map.getZoom())
}
map.whenReady(save)
map.on('moveend zoomend', save)
return () => { map.off('moveend zoomend', save) }
}, [map, onChange])
return null
}
interface SelectionControllerProps {
/** False while the traveller locked the map (#2010): a pick changes nothing on it. */
follow: boolean
@@ -734,6 +749,16 @@ const MemoMarker = memo(function MemoMarker({
)
})
function plannerWaypointIcon(index: number, selected: boolean): L.DivIcon {
const size = selected ? 30 : 26
return L.divIcon({
className: '',
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
html: `<span style="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">${index + 1}</span>`,
})
}
export const MapView = memo(function MapView({
places = [],
dayPlaces = [],
@@ -799,7 +824,19 @@ export const MapView = memo(function MapView({
activeAlternative,
onChooseAlternative,
onHighlightAlternative,
plannerWaypoints = [],
selectedPlannerWaypointId = null,
onPlannerWaypointClick,
routeProfileFocus = null,
viewBaseLayer,
onViewBaseLayerChange,
}: any) {
const globalBaseLayer = useSettingsStore(s => s.settings.map_base_layer) || 'default'
const updateSetting = useSettingsStore(s => s.updateSetting)
const baseLayer = (viewBaseLayer ?? globalBaseLayer) as TourBaseLayer
const hasViewBaseLayer = viewBaseLayer !== undefined && typeof onViewBaseLayerChange === 'function'
const isSatellite = baseLayer === 'satellite'
const isTopo = baseLayer === 'topo'
// The caller hands over whatever the user configured; what kind of basemap
// that is decides which layer draws it. A saved raster template still wins,
// the default is a vector style.
@@ -811,7 +848,7 @@ export const MapView = memo(function MapView({
* decided before the map is constructed, because Leaflet cannot change a map's
* CRS afterwards — hence the whole map, not just the tile layer.
*/
const isGcjBasemap = basemap.kind === 'raster' && isGcj02Basemap(basemap.url)
const isGcjBasemap = !isTopo && basemap.kind === 'raster' && isGcj02Basemap(basemap.url)
const gcjCrs = useMemo(() => crsForBasemap(isGcjBasemap), [isGcjBasemap])
const poiMarkers = useMemo(() => (pois as Poi[]).map((poi: Poi) => (
<Marker
@@ -870,6 +907,10 @@ export const MapView = memo(function MapView({
})
return { center: framed?.center ?? center, zoom: framed?.zoom ?? zoom, framed: framed !== null }
})
const cameraSnapshotRef = useRef<{ center: [number, number]; zoom: number } | null>(null)
const saveCameraSnapshot = useCallback((cameraCenter: [number, number], cameraZoom: number) => {
cameraSnapshotRef.current = { center: cameraCenter, zoom: cameraZoom }
}, [])
// Hover state for the single tooltip overlay (replaces per-marker <Tooltip>)
const [hoveredPlace, setHoveredPlace] = useState<MapHoverInfo | null>(null)
@@ -1045,12 +1086,10 @@ export const MapView = memo(function MapView({
// of points), so it hangs off `places` alone — a selection change must not
// re-parse every track.
const gpxTracks = useMemo(() => 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 [{ place, coords, cased: hasManualTrackColor(place), color: resolveTrackColor(place) }]
} catch { return [] }
const geometry = parseRenderableRouteGeometry(place.route_geometry)
if (!geometry) return []
const coords: [number, number][] = geometry.map(([lat, lng]) => [lat, lng])
return [{ place, coords, cased: hasManualTrackColor(place), color: resolveTrackColor(place) }]
}), [places])
// Keeps the click handler out of the polyline memo below: `handleMarkerClick`
@@ -1119,9 +1158,6 @@ export const MapView = memo(function MapView({
? 'calc(var(--bottom-nav-h, 84px) + 20px + var(--day-panel-h, 0px) + 12px)'
: 'calc(var(--bottom-nav-h, 84px) + 12px)'
const baseLayer = useSettingsStore(s => s.settings.map_base_layer) || 'default'
const updateSetting = useSettingsStore(s => s.updateSetting)
const isSatellite = baseLayer === 'satellite'
const toggleBaseLayer = useCallback(() => {
// Store flips state synchronously (instant, works offline); a failed save is logged there.
updateSetting('map_base_layer', isSatellite ? 'default' : 'satellite').catch(() => {})
@@ -1149,8 +1185,8 @@ export const MapView = memo(function MapView({
// the cluster group away on every template or API-key edit.
key={isGcjBasemap ? 'gcj02' : 'wgs84'}
id="trek-map"
center={initialView.center}
zoom={initialView.zoom}
center={cameraSnapshotRef.current?.center ?? initialView.center}
zoom={cameraSnapshotRef.current?.zoom ?? initialView.zoom}
zoomControl={false}
// On the map itself, not left to the base layer. Leaflet reads its zoom
// ceiling from the map options or, failing that, from a GridLayer that
@@ -1184,6 +1220,18 @@ export const MapView = memo(function MapView({
updateWhenIdle={true}
referrerPolicy="strict-origin-when-cross-origin"
/>
) : isTopo ? (
<TileLayer
key="topo"
url={OPENTOPOMAP_TILE_URL}
attribution={OPENTOPOMAP_TILE_ATTRIBUTION}
maxNativeZoom={OPENTOPOMAP_TILE_MAXZOOM}
maxZoom={MAP_MAX_ZOOM}
keepBuffer={8}
updateWhenZooming={false}
updateWhenIdle={true}
referrerPolicy="strict-origin-when-cross-origin"
/>
) : basemap.kind === 'vector' ? (
<VectorBasemap style={basemap.style} />
) : (
@@ -1200,6 +1248,7 @@ export const MapView = memo(function MapView({
)}
<MapController center={center} zoom={zoom} />
{hasViewBaseLayer && <MapCameraSnapshot onChange={saveCameraSnapshot} />}
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} focusPoints={focusPoints} fitPadding={fitPadding} follow={followSelection} />
<SelectionController follow={followSelection} places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} selectedPlace={selectedPlace} paddingOpts={paddingOpts} />
<MapClickHandler onClick={onMapClick} />
@@ -1311,10 +1360,37 @@ export const MapView = memo(function MapView({
<RoadtripViaMarkers viasByDay={roadtripVias} onMoveVia={onMoveVia} onRemoveVia={onRemoveVia} />
) : null}
{(plannerWaypoints as Array<{ id: string; lat: number; lng: number }>).map((point, index) => (
<Marker
key={`planner-waypoint-${point.id}`}
position={[point.lat, point.lng]}
icon={plannerWaypointIcon(index, point.id === selectedPlannerWaypointId)}
zIndexOffset={point.id === selectedPlannerWaypointId ? 1200 : 1100}
eventHandlers={{
click: (event: { originalEvent: MouseEvent }) => {
event.originalEvent.stopPropagation()
onPlannerWaypointClick?.(point.id)
},
}}
>
<Tooltip direction="top" offset={[0, -12]}>{index + 1}</Tooltip>
</Marker>
))}
{/* GPX imported route geometries */}
<TrackCasingPane onReady={setHasCasingPane} />
{gpxPolylines}
{routeProfileFocus && (
<CircleMarker
center={[routeProfileFocus.lat, routeProfileFocus.lng]}
radius={8}
pathOptions={{ className: 'tour-profile-focus-marker', color: 'var(--bg-card)', weight: 3, fillColor: 'var(--text-muted)', fillOpacity: 1 }}
interactive={false}
bubblingMouseEvents={false}
/>
)}
<ReservationOverlay
reservations={visibleReservations}
showConnections
@@ -1367,13 +1443,11 @@ export const MapView = memo(function MapView({
{/* 20px off the sidebar, not 12: the pill is round and frosted, so at the
smaller gap its shadow ran into the sidebar edge and the two read as one
surface. */}
{/* Bottom left, whatever else is on screen. Opening a place used to send it to the
top of the map, which read as the control moving house rather than as room being
made: the inspector is a centred card at most 800 wide, so the corner it would
have been clearing is one the card never reaches. */}
<div style={{ position: 'absolute', left: leftWidth + MAP_LAYER_SWITCHER_INSET, bottom: switcherBottom, zIndex: 1000, pointerEvents: 'none', display: 'flex', flexDirection: 'column', gap: 8 }}>
{onToggleFollow && <MapLockPill locked={!followSelection} onToggle={onToggleFollow} />}
<MapLayerSwitcher active={baseLayer} onToggle={toggleBaseLayer} />
{hasViewBaseLayer
? <TourMapLayerSwitcher active={baseLayer} onChange={onViewBaseLayerChange} />
: <MapLayerSwitcher active={globalBaseLayer} onToggle={toggleBaseLayer} />}
</div>
</div>