mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-20 13:51:45 +00:00
feat(planner): real road routes (OSRM) with travel-time connectors (#1060)
* feat(planner): real road routes (OSRM) with travel-time connectors Replace the straight-line "as the crow flies" route with real OSRM road geometry (FOSSGIS routed-car/-foot) and an Apple-Maps style render (blue casing under a lighter core) on both the Leaflet and Mapbox GL maps. Routes are off by default and toggled per session, with a driving/walking mode switch in the day footer. Each day shows per-segment travel time/distance connectors between places, computed from the OSRM legs and split at transport bookings. Also redesigns the day header for visual consistency: vertical number+weather capsule, name with a divider before the date, subtle hotel/rental pills that stay on one line, and a hover-revealed 2x2 action square (edit / add transport / add note / collapse). Drops the Google Maps button. * test(planner): update route hook tests for calculateRouteWithLegs
This commit is contained in:
@@ -128,7 +128,8 @@ describe('MapView', () => {
|
||||
|
||||
it('FE-COMP-MAPVIEW-006: renders polyline when route has 2+ points', () => {
|
||||
render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} />)
|
||||
expect(screen.getByTestId('polyline')).toBeTruthy()
|
||||
// Apple-Maps style draws a casing + a core line per segment.
|
||||
expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-007: does not render polyline when route is null', () => {
|
||||
@@ -155,16 +156,11 @@ describe('MapView', () => {
|
||||
expect(screen.getByTestId('cluster-group')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-011: renders RouteLabel marker when routeSegments provided with route', () => {
|
||||
const route = [[[48.0, 2.0], [49.0, 3.0]]] as [number, number][][][]
|
||||
const routeSegments = [
|
||||
{ mid: [48.5, 2.5] as [number, number], from: 0, to: 1, walkingText: '10 min', drivingText: '3 min' },
|
||||
]
|
||||
render(<MapView route={route} routeSegments={routeSegments} />)
|
||||
// Route polyline is rendered
|
||||
expect(screen.getByTestId('polyline')).toBeTruthy()
|
||||
// RouteLabel renders a Marker (mocked), but it returns null when zoom < 12
|
||||
// so we just assert the polyline is there, exercising the routeSegments.map path
|
||||
it('FE-COMP-MAPVIEW-011: renders the route polyline; travel times are no longer drawn on the map', () => {
|
||||
const route = [[[48.0, 2.0], [49.0, 3.0]]] as unknown as [number, number][][]
|
||||
render(<MapView route={route} />)
|
||||
// The route is drawn; per-segment times now live in the day sidebar, not on the map.
|
||||
expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-012: invalid route_geometry JSON triggers catch and skips polyline', () => {
|
||||
|
||||
@@ -225,44 +225,7 @@ function MapContextMenuHandler({ onContextMenu }: { onContextMenu: ((e: L.Leafle
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Route travel time label ──
|
||||
interface RouteLabelProps {
|
||||
midpoint: [number, number]
|
||||
walkingText: string
|
||||
drivingText: string
|
||||
}
|
||||
|
||||
function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
|
||||
if (!midpoint) return null
|
||||
|
||||
const icon = L.divIcon({
|
||||
className: 'route-info-pill',
|
||||
html: `<div style="
|
||||
display:flex;align-items:center;gap:5px;
|
||||
background:rgba(0,0,0,0.85);backdrop-filter:blur(8px);
|
||||
color:#fff;border-radius:99px;padding:3px 9px;
|
||||
font-size:9px;font-weight:600;white-space:nowrap;
|
||||
font-family:-apple-system,BlinkMacSystemFont,system-ui,sans-serif;
|
||||
box-shadow:0 2px 12px rgba(0,0,0,0.3);
|
||||
pointer-events:none;
|
||||
position:relative;left:-50%;top:-50%;
|
||||
">
|
||||
<span style="display:flex;align-items:center;gap:2px">
|
||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="13" cy="4" r="2"/><path d="M7 21l3-7"/><path d="M10 14l5-5"/><path d="M15 9l-4 7"/><path d="M18 18l-3-7"/></svg>
|
||||
${walkingText}
|
||||
</span>
|
||||
<span style="opacity:0.3">|</span>
|
||||
<span style="display:flex;align-items:center;gap:2px">
|
||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9L18 10l-2-4H7L5 10l-2.5 1.1C1.7 11.3 1 12.1 1 13v3c0 .6.4 1 1 1h2"/><circle cx="7" cy="17" r="2"/><circle cx="17" cy="17" r="2"/></svg>
|
||||
${drivingText}
|
||||
</span>
|
||||
</div>`,
|
||||
iconSize: [0, 0],
|
||||
iconAnchor: [0, 0],
|
||||
})
|
||||
|
||||
return <Marker position={midpoint} icon={icon} interactive={false} zIndexOffset={2000} />
|
||||
}
|
||||
// Travel times are shown in the day sidebar (per-segment connectors), not on the map.
|
||||
|
||||
// Module-level photo cache shared with PlaceAvatar
|
||||
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
|
||||
@@ -586,23 +549,19 @@ export const MapView = memo(function MapView({
|
||||
{markers}
|
||||
</MarkerClusterGroup>
|
||||
|
||||
{route && route.length > 0 && (
|
||||
<>
|
||||
{route.map((seg, i) => seg.length > 1 && (
|
||||
<Polyline
|
||||
key={i}
|
||||
positions={seg}
|
||||
color="#111827"
|
||||
weight={3}
|
||||
opacity={0.9}
|
||||
dashArray="6, 5"
|
||||
/>
|
||||
))}
|
||||
{routeSegments.map((seg, i) => (
|
||||
<RouteLabel key={i} midpoint={seg.mid} from={seg.from} to={seg.to} walkingText={seg.walkingText} drivingText={seg.drivingText} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded. */}
|
||||
{route && route.length > 0 && route.flatMap((seg, i) => seg.length > 1 ? [
|
||||
<Polyline
|
||||
key={`${i}-casing`}
|
||||
positions={seg}
|
||||
pathOptions={{ color: '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||
/>,
|
||||
<Polyline
|
||||
key={`${i}-core`}
|
||||
positions={seg}
|
||||
pathOptions={{ color: '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||
/>,
|
||||
] : [])}
|
||||
|
||||
{/* GPX imported route geometries */}
|
||||
{gpxPolylines}
|
||||
|
||||
@@ -163,7 +163,6 @@ export function MapViewGL({
|
||||
const markersRef = useRef<Map<number, mapboxgl.Marker>>(new Map())
|
||||
const locationMarkerRef = useRef<LocationMarkerHandle | null>(null)
|
||||
const reservationOverlayRef = useRef<ReservationMapboxOverlay | null>(null)
|
||||
const routeLabelMarkersRef = useRef<mapboxgl.Marker[]>([])
|
||||
// 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)
|
||||
@@ -218,16 +217,20 @@ export function MapViewGL({
|
||||
// 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',
|
||||
paint: { 'line-color': '#0a5cc2', 'line-width': 8 },
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
map.addLayer({
|
||||
id: 'trip-route-line',
|
||||
type: 'line',
|
||||
source: 'trip-route',
|
||||
paint: {
|
||||
'line-color': '#111827',
|
||||
'line-width': 3,
|
||||
'line-opacity': 0.9,
|
||||
'line-dasharray': [2, 1.5],
|
||||
},
|
||||
paint: { 'line-color': '#0a84ff', 'line-width': 5 },
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
}
|
||||
@@ -444,34 +447,7 @@ export function MapViewGL({
|
||||
src.setData({ type: 'FeatureCollection', features })
|
||||
}, [route])
|
||||
|
||||
// Travel-time pills between consecutive places. The GL map accepted the
|
||||
// routeSegments prop but never drew anything, so the labels that Leaflet
|
||||
// shows were missing here (#850). Render them as HTML markers, matching the
|
||||
// Leaflet pill styling.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !mapReady) return
|
||||
routeLabelMarkersRef.current.forEach(m => m.remove())
|
||||
routeLabelMarkersRef.current = []
|
||||
for (const seg of routeSegments) {
|
||||
if (!seg.mid || (!seg.walkingText && !seg.drivingText)) continue
|
||||
const el = document.createElement('div')
|
||||
el.style.pointerEvents = 'none'
|
||||
el.innerHTML = `<div style="display:flex;align-items:center;gap:5px;background:rgba(0,0,0,0.85);backdrop-filter:blur(8px);color:#fff;border-radius:99px;padding:3px 9px;font-size:9px;font-weight:600;white-space:nowrap;font-family:-apple-system,BlinkMacSystemFont,system-ui,sans-serif;box-shadow:0 2px 12px rgba(0,0,0,0.3);">
|
||||
<span style="display:flex;align-items:center;gap:2px"><svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="13" cy="4" r="2"/><path d="M7 21l3-7"/><path d="M10 14l5-5"/><path d="M15 9l-4 7"/><path d="M18 18l-3-7"/></svg>${seg.walkingText ?? ''}</span>
|
||||
<span style="opacity:0.3">|</span>
|
||||
<span style="display:flex;align-items:center;gap:2px"><svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9L18 10l-2-4H7L5 10l-2.5 1.1C1.7 11.3 1 12.1 1 13v3c0 .6.4 1 1 1h2"/><circle cx="7" cy="17" r="2"/><circle cx="17" cy="17" r="2"/></svg>${seg.drivingText ?? ''}</span>
|
||||
</div>`
|
||||
const m = new mapboxgl.Marker({ element: el, anchor: 'center' })
|
||||
.setLngLat([seg.mid[1], seg.mid[0]])
|
||||
.addTo(map)
|
||||
routeLabelMarkersRef.current.push(m)
|
||||
}
|
||||
return () => {
|
||||
routeLabelMarkersRef.current.forEach(m => m.remove())
|
||||
routeLabelMarkersRef.current = []
|
||||
}
|
||||
}, [routeSegments, mapReady])
|
||||
// Travel times now live in the day sidebar (per-segment connectors), not on the map.
|
||||
|
||||
// Update GPX geometries
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
import type { RouteResult, RouteSegment, Waypoint } from '../../types'
|
||||
import type { RouteResult, RouteSegment, RouteWithLegs, Waypoint } from '../../types'
|
||||
|
||||
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
|
||||
|
||||
// FOSSGIS hosts OSRM with real per-profile routing (car/foot/bike) — the
|
||||
// project-osrm.org demo is car-only (it ignores the profile in the URL). Use
|
||||
// the matching profile so walking routes follow footpaths, not the road network.
|
||||
const OSRM_PROFILE_BASE: Record<'driving' | 'walking' | 'cycling', string> = {
|
||||
driving: 'https://routing.openstreetmap.de/routed-car/route/v1/driving',
|
||||
walking: 'https://routing.openstreetmap.de/routed-foot/route/v1/foot',
|
||||
cycling: 'https://routing.openstreetmap.de/routed-bike/route/v1/bike',
|
||||
}
|
||||
|
||||
// Cache route responses keyed by the exact waypoint list. Routes are stable, so
|
||||
// this avoids re-hitting the public OSRM demo server on every day switch / reorder.
|
||||
const routeCache = new Map<string, RouteWithLegs>()
|
||||
const ROUTE_CACHE_MAX = 200
|
||||
|
||||
/** Fetches a full route via OSRM and returns coordinates, distance, and duration estimates for driving/walking. */
|
||||
export async function calculateRoute(
|
||||
waypoints: Waypoint[],
|
||||
@@ -116,12 +130,72 @@ export async function calculateSegments(
|
||||
const walkingDuration = leg.distance / (5000 / 3600)
|
||||
return {
|
||||
mid, from, to,
|
||||
distance: leg.distance,
|
||||
duration: leg.duration,
|
||||
walkingText: formatDuration(walkingDuration),
|
||||
drivingText: formatDuration(leg.duration),
|
||||
distanceText: formatDistance(leg.distance),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* One OSRM call per waypoint-run that returns BOTH the real road geometry (for the
|
||||
* map) and per-leg distance/duration (for the sidebar connectors). Results are cached
|
||||
* by the exact waypoint list. Throws on OSRM failure so callers can fall back to a
|
||||
* straight line.
|
||||
*/
|
||||
export async function calculateRouteWithLegs(
|
||||
waypoints: Waypoint[],
|
||||
{ signal, profile = 'driving' }: { signal?: AbortSignal; profile?: 'driving' | 'walking' | 'cycling' } = {}
|
||||
): Promise<RouteWithLegs> {
|
||||
if (!waypoints || waypoints.length < 2) {
|
||||
return { coordinates: [], distance: 0, duration: 0, legs: [] }
|
||||
}
|
||||
|
||||
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
||||
const cacheKey = `${profile}:${coords}`
|
||||
const cached = routeCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const url = `${OSRM_PROFILE_BASE[profile]}/${coords}?overview=full&geometries=geojson&annotations=distance,duration`
|
||||
const response = await fetch(url, { signal })
|
||||
if (!response.ok) throw new Error('Route could not be calculated')
|
||||
|
||||
const data = await response.json()
|
||||
if (data.code !== 'Ok' || !data.routes?.[0]) throw new Error('No route found')
|
||||
|
||||
const route = data.routes[0]
|
||||
const coordinates: [number, number][] = route.geometry.coordinates.map(
|
||||
([lng, lat]: [number, number]) => [lat, lng]
|
||||
)
|
||||
const legs: RouteSegment[] = (route.legs || []).map(
|
||||
(leg: { distance: number; duration: number }, i: number): RouteSegment => {
|
||||
const from: [number, number] = [waypoints[i].lat, waypoints[i].lng]
|
||||
const to: [number, number] = [waypoints[i + 1].lat, waypoints[i + 1].lng]
|
||||
const mid: [number, number] = [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2]
|
||||
const walkingDuration = leg.distance / (5000 / 3600)
|
||||
return {
|
||||
mid, from, to,
|
||||
distance: leg.distance,
|
||||
duration: leg.duration,
|
||||
walkingText: formatDuration(walkingDuration),
|
||||
drivingText: formatDuration(leg.duration),
|
||||
distanceText: formatDistance(leg.distance),
|
||||
durationText: formatDuration(leg.duration),
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const result: RouteWithLegs = { coordinates, distance: route.distance, duration: route.duration, legs }
|
||||
routeCache.set(cacheKey, result)
|
||||
if (routeCache.size > ROUTE_CACHE_MAX) {
|
||||
const oldest = routeCache.keys().next().value
|
||||
if (oldest !== undefined) routeCache.delete(oldest)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function formatDistance(meters: number): string {
|
||||
if (meters < 1000) {
|
||||
return `${Math.round(meters)} m`
|
||||
|
||||
Reference in New Issue
Block a user