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