feat(maps): draw every travel day's route at once, with the trip total

Extract the day route builder out of useRouteCalculation into a pure module and
aggregate it over the whole trip: one map with every day's route, each day in its
own colour, the mode per stage and the trip's total distance. Straight lines first,
roads as they answer; a refused leg keeps its line and adds no distance.

Give the floating map controls one shared drop shadow while adding the toggle, so
the new pill and the base-layer switcher stand off the tiles the way the locate
button always has. Document the control in wiki/Map-Features.md.

Addresses #1736
This commit is contained in:
Konstantinos Thermos
2026-09-10 15:57:48 +03:00
parent 8acfaea3c4
commit bf1900c3a0
43 changed files with 1095 additions and 192 deletions
+2 -1
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef } from 'react'
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
import { Navigation, LocateFixed, Locate } from 'lucide-react'
import type { TrackingMode, GeoWatchErrorCode } from '../../hooks/useGeolocation'
import { useTranslation } from '../../i18n'
@@ -77,7 +78,7 @@ export default function LocationButton({ mode, error, errorCode, onClick, bottom
cursor: 'pointer',
background: isActive ? '#3b82f6' : 'var(--bg-card, white)',
color: isActive ? 'white' : (error ? '#ef4444' : 'var(--text-muted, #6b7280)'),
boxShadow: '0 2px 10px rgba(0,0,0,0.25)',
boxShadow: MAP_CONTROL_SHADOW,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@@ -1,5 +1,6 @@
import { Map as MapIcon, Satellite } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
export type BaseLayer = 'default' | 'satellite'
@@ -18,7 +19,7 @@ export function MapLayerSwitcher({ active, onToggle }: { active: BaseLayer; onTo
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: 'var(--sidebar-shadow, 0 4px 16px rgba(0,0,0,0.14))',
boxShadow: MAP_CONTROL_SHADOW,
}}>
<button
type="button"
@@ -221,6 +221,17 @@ describe('MapView', () => {
expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
})
it('FE-COMP-MAPVIEW-006b: the whole-trip overview colours each line, on a white casing', () => {
render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} routeColors={['#ea580c']} />)
const [casing, core] = screen.getAllByTestId('polyline')
.map(el => JSON.parse(el.getAttribute('data-path-options') as string))
// White under the colour, the same trick the coloured GPX tracks use to stay
// readable on satellite and dark basemaps.
expect(casing.color).toBe('#ffffff')
expect(core.color).toBe('#ea580c')
})
it('FE-COMP-MAPVIEW-007: does not render polyline when route is null', () => {
render(<MapView route={null} />)
expect(screen.queryByTestId('polyline')).toBeNull()
+7 -3
View File
@@ -607,6 +607,7 @@ export const MapView = memo(function MapView({
dayPlaces = [],
route = null,
routeSegments = [],
routeColors,
selectedPlaceId = null,
hoverDisabled = false,
onMarkerClick,
@@ -1027,12 +1028,15 @@ export const MapView = memo(function MapView({
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded.
The casing carries the click when the route can be reshaped: it is the wider of
the two, so it is the one a pointer actually lands on. */}
the two, so it is the one a pointer actually lands on.
In the whole-trip overview each day is drawn in its own colour (#1736), and the
casing goes white — the same trick the coloured GPX tracks use to stay readable
on satellite and dark basemaps, where a per-colour dark casing would not. */}
{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' }}
pathOptions={{ color: routeColors?.[i] ? '#ffffff' : '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
interactive={!!onRouteClick}
eventHandlers={onRouteClick ? {
click: (e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
@@ -1046,7 +1050,7 @@ export const MapView = memo(function MapView({
<Polyline
key={`${i}-core`}
positions={seg}
pathOptions={{ color: '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
pathOptions={{ color: routeColors?.[i] ?? '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
interactive={false}
/>,
] : [])}
@@ -1540,6 +1540,19 @@ describe('MapViewGL', () => {
expect(features[0].geometry.coordinates).toEqual([[2, 48], [3, 49]])
})
it('FE-COMP-MAPVIEWGL-048b: the whole-trip overview colours each line on the feature', async () => {
const routeSource = geoSource()
glMap.getSource.mockImplementation((id: string) => (id === 'trip-route' ? routeSource : null))
render(<MapViewGL places={[]} fitKey={1} route={[[[48, 2], [49, 3]], [[50, 4], [51, 5]]]} routeColors={['#1d4ed8', null]} />)
await act(async () => {})
const { features } = lastData(routeSource)
expect(features[0].properties.color).toBe('#1d4ed8')
// No colour means the day route's own blue, which the layer paint falls back to.
expect(features[1].properties.color).toBeUndefined()
})
it('FE-COMP-MAPVIEWGL-049: unusable GPX geometry is skipped instead of breaking the layer', async () => {
const gpxSource = geoSource()
glMap.getSource.mockImplementation((id: string) => (id === 'trip-gpx' ? gpxSource : null))
+11 -6
View File
@@ -108,6 +108,8 @@ interface Props {
accessLines?: { line: [[number, number], [number, number]]; meters: number }[]
route?: [number, number][][] | null
routeSegments?: RouteSegment[]
/** Line colour per entry of `route`, same index — the whole-trip overview (#1736). */
routeColors?: (string | null)[]
selectedPlaceId?: number | null
onMarkerClick?: (id: number) => void
hoverDisabled?: boolean
@@ -553,6 +555,7 @@ export function MapViewGL({
accessLines = NO_ACCESS_LINES,
route = null,
routeSegments = NO_ROUTE_SEGMENTS,
routeColors,
selectedPlaceId = null,
hoverDisabled = false,
onMarkerClick,
@@ -1115,7 +1118,7 @@ export function MapViewGL({
id: 'trip-route-casing',
type: 'line',
source: 'trip-route',
paint: { 'line-color': '#0a5cc2', 'line-width': 8 },
paint: { 'line-color': ['case', ['has', 'color'], '#ffffff', '#0a5cc2'], 'line-width': 8 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
// An invisible band over the route, purely to be clicked. The drawn line is 8px
@@ -1132,7 +1135,7 @@ export function MapViewGL({
id: 'trip-route-line',
type: 'line',
source: 'trip-route',
paint: { 'line-color': '#0a84ff', 'line-width': 5 },
paint: { 'line-color': ['coalesce', ['get', 'color'], '#0a84ff'], 'line-width': 5 },
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
}
@@ -1822,13 +1825,15 @@ export function MapViewGL({
if (!map) return
const src = map.getSource('trip-route') as mapboxgl.GeoJSONSource | undefined
if (!src) return
const features = (route || []).filter(seg => seg && seg.length > 1).map(seg => ({
// The colour rides on the feature rather than the layer so one source can hold a
// whole trip of differently-coloured days (#1736), the way trip-gpx already does.
const features = (route || []).flatMap((seg, i) => seg && seg.length > 1 ? [{
type: 'Feature' as const,
properties: {},
properties: routeColors?.[i] ? { color: routeColors[i] } : {},
geometry: { type: 'LineString' as const, coordinates: seg.map(([lat, lng]) => [lng, lat]) },
}))
}] : [])
src.setData({ type: 'FeatureCollection', features })
}, [route, mapReady])
}, [route, routeColors, mapReady])
// Update access-spur geojson
useEffect(() => {
@@ -0,0 +1,90 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { TripRouteOverviewPill, TripRouteOverviewPanel } from './TripRouteOverview'
import type { TripRouteOverview } from './useTripRouteOverview'
const overview = (over: Partial<TripRouteOverview> = {}): TripRouteOverview => ({
days: [
{ dayId: 1, dayNumber: 1, date: '2025-06-01', title: null, color: '#1d4ed8', lines: [], segments: [], distance: 82000, duration: 3600, modes: ['driving'] },
{ dayId: 2, dayNumber: 2, date: '2025-06-02', title: 'Coast road', color: '#ea580c', lines: [], segments: [], distance: 140500, duration: 7200, modes: ['driving', 'walking'] },
],
lines: [], lineColors: [], segments: [], focusPoints: [],
totalDistance: 222500, totalDuration: 10800, loading: false,
...over,
})
describe('TripRouteOverviewPill', () => {
it('FE-MAP-TROU-001: says which way it will take you, and reports its state', () => {
const onToggle = vi.fn()
const { rerender } = render(<TripRouteOverviewPill active={false} onToggle={onToggle} />)
const button = screen.getByTestId('trip-overview-pill')
expect(button).toHaveAttribute('aria-pressed', 'false')
expect(button).toHaveAttribute('aria-label', 'map.overview.show')
fireEvent.click(button)
expect(onToggle).toHaveBeenCalledTimes(1)
rerender(<TripRouteOverviewPill active onToggle={onToggle} />)
expect(screen.getByTestId('trip-overview-pill')).toHaveAttribute('aria-label', 'map.overview.hide')
})
})
describe('TripRouteOverviewPanel', () => {
it('FE-MAP-TROU-002: leads with the trip total in the reader\'s own unit', () => {
const { rerender } = render(<TripRouteOverviewPanel overview={overview()} unit="metric" />)
expect(screen.getByText('222.5 km')).toBeInTheDocument()
rerender(<TripRouteOverviewPanel overview={overview()} unit="imperial" />)
expect(screen.getByText('138.3 mi')).toBeInTheDocument()
})
it('FE-MAP-TROU-003: names every day, its distance and how it is travelled', () => {
render(<TripRouteOverviewPanel overview={overview()} unit="metric" />)
expect(screen.getByText('dayplan.dayN')).toBeInTheDocument()
// A day with a title is named by it rather than by its number.
expect(screen.getByText('Coast road')).toBeInTheDocument()
expect(screen.getByText('82 km')).toBeInTheDocument()
expect(screen.getByText('140.5 km')).toBeInTheDocument()
})
it('FE-MAP-TROU-004: marks the total as partial while legs are still coming in', () => {
const { container, rerender } = render(<TripRouteOverviewPanel overview={overview({ loading: true })} unit="metric" />)
expect(container.textContent).toContain('…')
rerender(<TripRouteOverviewPanel overview={overview()} unit="metric" />)
expect(container.textContent).not.toContain('…')
})
it('FE-MAP-TROU-005: a day row picks that day when the shell offers it', () => {
const onSelectDay = vi.fn()
render(<TripRouteOverviewPanel overview={overview()} unit="metric" selectedDayId={2} onSelectDay={onSelectDay} />)
fireEvent.click(screen.getByText('Coast road'))
expect(onSelectDay).toHaveBeenCalledWith(2)
})
it('FE-MAP-TROU-006: read-only shells get rows, not buttons', () => {
render(<TripRouteOverviewPanel overview={overview()} unit="metric" />)
expect(screen.queryAllByRole('button')).toHaveLength(0)
})
it('FE-MAP-TROU-008: hugs its rows, and takes the cap the shell gives it', () => {
const { container, rerender } = render(<TripRouteOverviewPanel overview={overview()} unit="metric" />)
const card = () => screen.getByTestId('trip-overview-panel')
expect(card().style.width).toBe('fit-content')
expect(card().style.maxWidth).toBe('320px')
// The phone shell asks for a narrower card so the map keeps the space.
rerender(<TripRouteOverviewPanel overview={overview()} unit="metric" maxWidth={240} />)
expect(card().style.maxWidth).toBe('240px')
expect(container).not.toBeEmptyDOMElement()
})
it('FE-MAP-TROU-007: draws nothing at all for a trip with no routed day', () => {
const { container } = render(<TripRouteOverviewPanel overview={overview({ days: [], totalDistance: 0 })} unit="metric" />)
expect(container).toBeEmptyDOMElement()
})
})
@@ -0,0 +1,130 @@
import { Route } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { profileIcon } from '../Planner/DayPlanSidebarRouteConnector'
import { formatDistance } from '../../utils/units'
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
import type { TripRouteOverview as Overview } from './useTripRouteOverview'
import type { DistanceUnit } from '../../types'
/**
* Toggle for the whole-trip route overview (#1736). Same frosted shell as the other
* map controls, so it lines up with the compass and the layer switcher.
*/
export function TripRouteOverviewPill({ active, onToggle }: { active: boolean; onToggle: () => void }) {
const { t } = useTranslation()
const label = active ? t('map.overview.hide') : t('map.overview.show')
return (
<div style={{
display: 'inline-flex', alignItems: 'center', padding: 4, borderRadius: 999, pointerEvents: 'auto',
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: MAP_CONTROL_SHADOW,
}}>
<button
type="button"
onClick={onToggle}
aria-label={label}
title={label}
aria-pressed={active}
data-testid="trip-overview-pill"
className={active ? 'text-accent' : 'text-content-muted'}
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
background: 'transparent', padding: 0,
transition: 'background 0.14s, color 0.14s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
>
<Route size={17} strokeWidth={2} />
</button>
</div>
)
}
/**
* The trip's stages and its total distance, read off the overview the map is drawing.
*
* One component for both shells: the phone and the desktop place it differently but the
* rows are the same rows, and a second copy of them is a second thing to keep in step.
*/
export function TripRouteOverviewPanel({ overview, unit, selectedDayId, onSelectDay, maxWidth = 320 }: {
overview: Overview
unit: DistanceUnit
selectedDayId?: number | null
onSelectDay?: (dayId: number) => void
/** How wide the card may grow before day names start to ellipsize. */
maxWidth?: number
}) {
const { t } = useTranslation()
if (!overview.days.length) return null
return (
<div
data-testid="trip-overview-panel"
style={{
pointerEvents: 'auto', borderRadius: 14, overflow: 'hidden',
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: 'var(--sidebar-shadow, 0 4px 16px rgba(0,0,0,0.14))',
// Sized to its rows rather than to the space available: a trip of short day
// names left a band of empty card over the map, which on a phone is most of
// what there is to look at.
width: 'fit-content', maxWidth,
}}
>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12, padding: '10px 14px 8px' }}>
<span className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', textTransform: 'uppercase', letterSpacing: 0.4 }}>
{t('map.overview.total')}
</span>
{/* Same tier as the label beside it: the two read as one line rather than as a
heading with a number stuck under it. */}
<span className="text-content" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600 }}>
{formatDistance(overview.totalDistance / 1000, unit)}
{/* Still routing: the number is a partial sum, and saying so beats a total
that silently grows while you read it. */}
{overview.loading && <span className="text-content-muted" style={{ fontWeight: 400 }}>{' '}…</span>}
</span>
</div>
<div style={{ maxHeight: 220, overflowY: 'auto', borderTop: '1px solid var(--border-primary)' }}>
{overview.days.map(day => {
const label = day.title || t('dayplan.dayN', { n: day.dayNumber })
const row = (
<>
<span style={{ width: 8, height: 8, borderRadius: 999, background: day.color, flexShrink: 0 }} aria-hidden />
<span className="text-content" style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{label}
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0 }} className="text-content-muted">
{day.modes.map(mode => {
const Icon = profileIcon(mode)
return <Icon key={mode} size={12} strokeWidth={2} aria-hidden />
})}
</span>
<span className="text-content-muted" style={{ flexShrink: 0, fontVariantNumeric: 'tabular-nums' }}>
{formatDistance(day.distance / 1000, unit)}
</span>
</>
)
const style = {
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '7px 14px', textAlign: 'left' as const,
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
background: day.dayId === selectedDayId ? 'var(--bg-hover)' : 'transparent',
border: 'none',
}
return onSelectDay ? (
<button key={day.dayId} type="button" onClick={() => onSelectDay(day.dayId)} style={{ ...style, cursor: 'pointer' }}>
{row}
</button>
) : (
<div key={day.dayId} style={style}>{row}</div>
)
})}
</div>
</div>
)
}
@@ -0,0 +1,108 @@
import { describe, it, expect } from 'vitest'
import { buildDayRouteRuns } from './dayRoutePlan'
import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories'
import type { Accommodation, AssignmentsMap, Day, Reservation } from '../../types'
const at = (lat: number, lng: number, order: number, extra: Record<string, unknown> = {}) =>
buildAssignment({ day_id: 1, order_index: order, place: buildPlace({ lat, lng }), ...extra })
const inputs = (over: Partial<{
days: Day[]; assignments: AssignmentsMap; reservations: Reservation[]
accommodations: Accommodation[]; optimizeFromAccommodation: boolean | undefined
}> = {}) => ({
days: [buildDay({ id: 1, day_number: 1 })],
assignments: {},
reservations: [],
accommodations: [],
optimizeFromAccommodation: false,
...over,
})
describe('buildDayRouteRuns', () => {
it('FE-MAP-DRP-001: orders a day\'s located stops into one run', () => {
const runs = buildDayRouteRuns(1, inputs({
assignments: { '1': [at(48.86, 2.35, 1), at(45.76, 4.83, 0)] },
}))
expect(runs).toHaveLength(1)
// order_index, not the order they happen to sit in the array.
expect(runs[0].map(p => p.lat)).toEqual([45.76, 48.86])
expect(runs[0].every(p => p.isPlace)).toBe(true)
})
it('FE-MAP-DRP-002: drops a stop with no coordinates', () => {
const runs = buildDayRouteRuns(1, inputs({
assignments: {
'1': [
at(48.86, 2.35, 0),
buildAssignment({ day_id: 1, order_index: 1, place: buildPlace({ lat: null, lng: null }) }),
at(45.76, 4.83, 2),
],
},
}))
expect(runs[0]).toHaveLength(2)
})
it('FE-MAP-DRP-003: a lone stop is no drive at all', () => {
expect(buildDayRouteRuns(1, inputs({ assignments: { '1': [at(48.86, 2.35, 0)] } }))).toEqual([])
})
it('FE-MAP-DRP-004: carries the per-leg travel modes the router resolves against', () => {
const runs = buildDayRouteRuns(1, inputs({
assignments: {
'1': [
at(48.86, 2.35, 0, { leg_transport_mode: 'walking' }),
at(48.88, 2.36, 1, { incoming_leg_transport_mode: 'cycling' }),
],
},
}))
expect(runs[0][0].leg_transport_mode).toBe('walking')
expect(runs[0][1].incoming_leg_transport_mode).toBe('cycling')
})
it('FE-MAP-DRP-005: two real places stay one run however far apart they are', () => {
// Paris → Tokyo is far past MAX_DRIVE_KM, but both are real places someone
// planned, so the run stands: the reachability guard only ever splits a leg
// that touches a booking endpoint (#2133).
const runs = buildDayRouteRuns(1, inputs({
assignments: { '1': [at(48.86, 2.35, 0), at(35.68, 139.69, 1)] },
}))
expect(runs).toHaveLength(1)
})
it('FE-MAP-DRP-006: bookends the day with its accommodation when the setting is on', () => {
const accommodation = {
id: 1, trip_id: 1, place_lat: 48.80, place_lng: 2.30,
start_day_id: 1, end_day_id: 1,
} as unknown as Accommodation
const runs = buildDayRouteRuns(1, inputs({
assignments: { '1': [at(48.86, 2.35, 0), at(48.88, 2.36, 1)] },
accommodations: [accommodation],
optimizeFromAccommodation: true,
}))
const flat = runs.flat()
expect(flat.some(p => p.lat === 48.80 && !p.isPlace)).toBe(true)
})
it('FE-MAP-DRP-007: leaves the hotel out when the setting is off', () => {
const accommodation = {
id: 1, trip_id: 1, place_lat: 48.80, place_lng: 2.30,
start_day_id: 1, end_day_id: 1,
} as unknown as Accommodation
const runs = buildDayRouteRuns(1, inputs({
assignments: { '1': [at(48.86, 2.35, 0), at(48.88, 2.36, 1)] },
accommodations: [accommodation],
optimizeFromAccommodation: false,
}))
expect(runs.flat().some(p => p.lat === 48.80)).toBe(false)
})
it('FE-MAP-DRP-008: a day that is not in the trip has no route', () => {
expect(buildDayRouteRuns(99, inputs({ assignments: { '1': [at(48.86, 2.35, 0)] } }))).toEqual([])
})
})
+169
View File
@@ -0,0 +1,169 @@
import { withHotelBookends } from './RouteCalculator'
import { getTransportRouteEndpoints, getTransportForDay, getMergedItems, isCarrierTransport, hasCarrierEndpointOnDay } from '../../utils/dayMerge'
import { getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type CarrierEdge } from '../../utils/dayOrder'
import { withinDriveRange } from '../../utils/geo'
import type { Accommodation, AssignmentsMap, Day, Reservation } from '../../types'
export const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other']
/** A waypoint on a day's drive, carrying the per-leg modes `resolveLegMode` reads off it. */
export interface DayRoutePoint {
lat: number
lng: number
isPlace: boolean
leg_transport_mode?: string | null
incoming_leg_transport_mode?: string | null
}
/** Everything the plan depends on, passed in rather than read from the store, so the
* builder stays pure and one caller can run it over every day of a trip at once. */
export interface DayRouteInputs {
days: Day[]
assignments: AssignmentsMap
reservations: Reservation[]
accommodations: Accommodation[]
optimizeFromAccommodation: boolean | undefined
}
/**
* The runs of waypoints a day's route is drawn from, hotel bookends folded in.
*
* Lifted out of `useRouteCalculation` unchanged so the trip-wide overview draws the
* same line the day view does. Every rule below is load-bearing and issue-numbered —
* a second copy of it would drift the moment either surface got the next fix.
*/
export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRoutePoint[][] {
const { days: allDays, assignments, reservations: allReservations, accommodations, optimizeFromAccommodation } = input
const da = (assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
const dayOrder = (id: number | null | undefined): number | null => {
if (id == null) return null
const d = allDays.find(x => x.id === id)
return d ? (d.day_number ?? allDays.indexOf(d)) : null
}
const thisOrder = dayOrder(dayId)
// The order the day plan shows is the order the map has to draw, so take it from
// the same place the plan does rather than rebuilding it here. getTransportForDay
// brings the span filter, the hotel/assignment exclusions and the leg expansion with
// it; getMergedItems places each leg by its own saved position, falling back to its
// time. Notes carry no coordinates, so none are passed.
const dayTransports = thisOrder == null ? [] : getTransportForDay({
reservations: allReservations.filter(r => TRANSPORT_TYPES.includes(r.type)),
dayId,
dayAssignmentIds: da.map(a => a.id),
days: allDays,
})
const merged = getMergedItems({ dayAssignments: da, dayNotes: [], dayTransports, dayId })
type Entry =
| { kind: 'place'; lat: number; lng: number; pos: number; time: string | null; mode: string | null; incoming: string | null }
| { kind: 'transport'; from: { lat: number; lng: number } | null; to: { lat: number; lng: number } | null; pos: number; carrier: boolean }
const entries: Entry[] = merged.flatMap((item): Entry[] => {
if (item.type === 'place') {
const a = item.data
if (!a.place?.lat || !a.place?.lng) return []
return [{
kind: 'place', lat: a.place.lat, lng: a.place.lng, pos: item.sortKey, time: a.place?.place_time ?? null,
// Per-segment travel mode (#1281): mode of the leg leaving this place.
mode: (a as { leg_transport_mode?: string | null }).leg_transport_mode ?? null,
// Boundary-leg mode (#1281 follow-up): mode of the leg arriving at this place.
incoming: (a as { incoming_leg_transport_mode?: string | null }).incoming_leg_transport_mode ?? null,
}]
}
if (item.type !== 'transport') return []
const { from, to } = getTransportRouteEndpoints(item.data, dayId)
return [{ kind: 'transport', from, to, pos: item.sortKey, carrier: isCarrierTransport(item.data) }]
})
// Group located places into driving runs. A transport WITH a location anchors the
// route to its departure point then breaks the run (you don't drive the flight); its
// arrival starts the next. A transport WITHOUT a location is ignored entirely. A run
// is only a real drive when it contains at least one actual place, or two back-to-back
// transports pair into a phantom [airport → airport] road route (#1394). A booking
// endpoint also has to be REACHABLE from the stop before it, or a long-haul arrival
// sorted in among the day's local stops gets stapled to one of them (#2133).
const runs: DayRoutePoint[][] = []
let currentRun: DayRoutePoint[] = []
let runHasPlace = false
const closeRun = () => {
if (currentRun.length >= 2 && runHasPlace) runs.push(currentRun)
currentRun = []
runHasPlace = false
}
for (const entry of entries) {
if (entry.kind === 'place') {
const prev = currentRun[currentRun.length - 1]
// The open run may be nothing but a far-away arrival endpoint — break rather
// than draw the ocean.
if (prev && !prev.isPlace && !withinDriveRange(prev, entry)) closeRun()
currentRun.push({ lat: entry.lat, lng: entry.lng, isPlace: true, leg_transport_mode: entry.mode, incoming_leg_transport_mode: entry.incoming })
runHasPlace = true
} else if (entry.from || entry.to) {
const prev = currentRun[currentRun.length - 1]
if (entry.from && (!prev || withinDriveRange(prev, entry.from))) currentRun.push({ ...entry.from, isPlace: false })
closeRun()
if (entry.to) currentRun.push({ ...entry.to, isPlace: false })
}
}
closeRun()
// Bookend the route with the day's accommodation, so the drawn line matches the
// sidebar's hotel legs.
const day = allDays.find(d => d.id === dayId)
const bookends = day && optimizeFromAccommodation !== false
? getDayBookendHotels(day, allDays, accommodations)
: null
const flatPts: DayRoutePoint[] = []
for (const e of entries) {
if (e.kind === 'place') flatPts.push({ lat: e.lat, lng: e.lng, isPlace: true, leg_transport_mode: e.mode, incoming_leg_transport_mode: e.incoming })
else { if (e.from) flatPts.push({ ...e.from, isPlace: false }); if (e.to) flatPts.push({ ...e.to, isPlace: false }) }
}
// A hotel bookend point is not a place-assignment, so isPlace: false — resolveLegMode
// falls through to the day default for hotel-adjacent legs unless the place endpoint
// carries its own override.
const hotelPt = (a?: Accommodation): DayRoutePoint | null =>
a && a.place_lat != null && a.place_lng != null ? { lat: a.place_lat, lng: a.place_lng, isPlace: false } : null
// Only draw a hotel bookend when the leg is a real drive: a place before check-in
// (#1465), a later "home" stop on the checkout day (#1465), or a transport endpoint on
// an arrival/departure day (#1321, #2133) all draw no bookend.
const contributes = (e: Entry) => e.kind === 'place' || !!e.from || !!e.to
const firstStop = entries.find(contributes)
const lastStop = [...entries].reverse().find(contributes)
const edgeInfo = (e: Entry | undefined, side: 'first' | 'last') => {
if (!e) return undefined
if (e.kind === 'place') return { isPlace: true, time: e.time, lat: e.lat, lng: e.lng }
const role: CarrierEdge = side === 'first'
? (e.from ? 'departure' : 'arrival')
: (e.to ? 'arrival' : 'departure')
return { isPlace: false, time: null, carrierEdge: e.carrier ? role : null }
}
// A carrier with a located endpoint today means the day changes geography by
// air/rail/sea — the check-in hotel is then the day's destination and the
// check-out hotel its origin, which the no-time bookend default respects (#2157).
const dayHasCarrier = dayTransports.some(r => hasCarrierEndpointOnDay(r, dayId))
const firstWay = flatPts[0]
const lastWay = flatPts[flatPts.length - 1]
const morningHotel = hotelPt(bookends?.morning)
const eveningHotel = hotelPt(bookends?.evening)
const drawMorning = !!bookends && !!day && shouldDrawMorningLeg(bookends, day, edgeInfo(firstStop, 'first'), dayHasCarrier)
&& (!morningHotel || !firstWay || firstWay.isPlace || withinDriveRange(morningHotel, firstWay))
const drawEvening = !!bookends && !!day && shouldDrawEveningLeg(bookends, day, edgeInfo(lastStop, 'last'), dayHasCarrier)
&& (!eveningHotel || !lastWay || lastWay.isPlace || withinDriveRange(eveningHotel, lastWay))
const runsWithHotel: DayRoutePoint[][] = withHotelBookends(
runs,
firstWay,
lastWay,
drawMorning ? morningHotel : null,
drawEvening ? eveningHotel : null,
)
// Transfer day with no activities: you check out of one accommodation and into
// another, so there are no waypoints for withHotelBookends to attach a leg to.
if (runsWithHotel.length === 0 && drawMorning && drawEvening) {
const m = hotelPt(bookends?.morning)
const e = hotelPt(bookends?.evening)
if (m && e && (m.lat !== e.lat || m.lng !== e.lng)) runsWithHotel.push([m, e])
}
return runsWithHotel
}
@@ -0,0 +1,10 @@
/**
* The drop shadow every floating map control carries.
*
* Tighter and darker than `--sidebar-shadow`, which is built for a panel resting on
* the app background: over map tiles its 0.10 alpha spread over 32px all but
* disappears, so a round control read as painted onto the map rather than floating
* above it. This is the locate button's shadow, which was always the odd one out for
* exactly that reason — the others were the ones that needed to match it.
*/
export const MAP_CONTROL_SHADOW = '0 2px 10px rgba(0,0,0,0.25)'
@@ -0,0 +1,137 @@
import { renderHook, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { TRACK_COLORS } from '@trek/shared'
import { useTripRouteOverview } from './useTripRouteOverview'
import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories'
import type { AssignmentsMap, RouteSegment } from '../../types'
vi.mock('./RouteCalculator', async (importActual) => {
const actual = await importActual<typeof import('./RouteCalculator')>()
return { ...actual, calculateRouteWithLegs: vi.fn() }
})
const { calculateRouteWithLegs } = await import('./RouteCalculator')
const leg = (distance: number, duration: number): RouteSegment => ({
mid: [0, 0], from: [0, 0], to: [0, 0],
distance, duration,
distanceText: `${Math.round(distance / 1000)} km`,
durationText: '1 h', walkingText: '5 h', drivingText: '1 h',
})
const at = (lat: number, lng: number, order: number, dayId: number, extra: Record<string, unknown> = {}) =>
buildAssignment({ day_id: dayId, order_index: order, place: buildPlace({ lat, lng }), ...extra })
const DAYS = [buildDay({ id: 1, day_number: 1 }), buildDay({ id: 2, day_number: 2 })]
const ASSIGNMENTS: AssignmentsMap = {
'1': [at(48.86, 2.35, 0, 1), at(48.88, 2.36, 1, 1)],
'2': [at(45.76, 4.83, 0, 2), at(45.78, 4.85, 1, 2)],
}
const render = (enabled = true, assignments: AssignmentsMap = ASSIGNMENTS, days = DAYS) =>
renderHook(
({ assignments: a }: { assignments: AssignmentsMap }) =>
useTripRouteOverview(7, days, a, [], [], 'driving', enabled),
{ initialProps: { assignments } },
)
beforeEach(() => {
vi.mocked(calculateRouteWithLegs).mockReset()
vi.mocked(calculateRouteWithLegs).mockResolvedValue({
coordinates: [[48.86, 2.35], [48.87, 2.355], [48.88, 2.36]],
distance: 12000, duration: 900,
legs: [leg(12000, 900)],
})
})
describe('useTripRouteOverview', () => {
it('FE-MAP-TRO-001: does nothing at all until it is switched on', () => {
const { result } = render(false)
expect(result.current.days).toEqual([])
expect(result.current.totalDistance).toBe(0)
expect(result.current.loading).toBe(false)
expect(calculateRouteWithLegs).not.toHaveBeenCalled()
})
it('FE-MAP-TRO-002: sums every day into the trip total', async () => {
const { result } = render()
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.days).toHaveLength(2)
expect(result.current.days.map(d => d.distance)).toEqual([12000, 12000])
expect(result.current.totalDistance).toBe(24000)
expect(result.current.totalDuration).toBe(1800)
})
it('FE-MAP-TRO-003: gives each day its own colour from the map palette', async () => {
const { result } = render()
await waitFor(() => expect(result.current.loading).toBe(false))
const colors = result.current.days.map(d => d.color)
expect(colors[0]).toBe(TRACK_COLORS[1])
expect(colors[1]).toBe(TRACK_COLORS[2])
// Every drawn line is labelled with the colour of the day it belongs to.
expect(result.current.lineColors).toHaveLength(result.current.lines.length)
expect(new Set(result.current.lineColors).size).toBe(2)
})
it('FE-MAP-TRO-004: reports the modes each day is travelled in', async () => {
const { result } = render(true, {
'1': [at(48.86, 2.35, 0, 1, { leg_transport_mode: 'walking' }), at(48.88, 2.36, 1, 1)],
'2': ASSIGNMENTS['2'],
})
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.days[0].modes).toEqual(['walking'])
expect(result.current.days[1].modes).toEqual(['driving'])
expect(result.current.days[0].segments[0].mode).toBe('walking')
})
it('FE-MAP-TRO-005: draws straight lines first and keeps them when routing refuses', async () => {
vi.mocked(calculateRouteWithLegs).mockRejectedValue(new Error('429'))
const { result } = render()
await waitFor(() => expect(result.current.loading).toBe(false))
// The shape of the trip is still on the map...
expect(result.current.lines).toHaveLength(2)
expect(result.current.lines[0]).toEqual([[48.86, 2.35], [48.88, 2.36]])
// ...but a leg that never answered contributes no distance.
expect(result.current.totalDistance).toBe(0)
})
it('FE-MAP-TRO-006: a day with nothing to drive is left out', async () => {
const { result } = render(true, { '1': ASSIGNMENTS['1'], '2': [at(45.76, 4.83, 0, 2)] })
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.days.map(d => d.dayId)).toEqual([1])
})
it('FE-MAP-TRO-007: frames the whole trip, not one day of it', async () => {
const { result } = render()
await waitFor(() => expect(result.current.loading).toBe(false))
expect(result.current.focusPoints.length).toBe(result.current.lines.flat().length)
expect(result.current.focusPoints.length).toBeGreaterThan(2)
})
it('FE-MAP-TRO-008: renaming a place does not re-route the trip', async () => {
const { result, rerender } = render()
await waitFor(() => expect(result.current.loading).toBe(false))
const calls = vi.mocked(calculateRouteWithLegs).mock.calls.length
expect(calls).toBeGreaterThan(0)
// A new object identity with the same geometry: the plan memo re-runs, the
// routing round must not.
rerender({
assignments: {
...ASSIGNMENTS,
'1': ASSIGNMENTS['1'].map(a => ({ ...a, place: { ...a.place, name: 'Renamed' } })),
},
})
await waitFor(() => expect(result.current.loading).toBe(false))
expect(vi.mocked(calculateRouteWithLegs).mock.calls.length).toBe(calls)
})
})
@@ -0,0 +1,225 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { calculateRouteWithLegs, type RouteProfileKey } from './RouteCalculator'
import { buildDayRouteRuns, type DayRoutePoint } from './dayRoutePlan'
import { resolveLegMode } from '../Planner/legMode'
import { useSettingsStore } from '../../store/settingsStore'
import { TRACK_COLORS } from '@trek/shared'
import type { Accommodation, AssignmentsMap, Day, Reservation, RouteSegment } from '../../types'
/** One travel day of the overview: the roads it covers, in its own colour. */
export interface TripOverviewDay {
dayId: number
dayNumber: number
date: string | null
title: string | null
color: string
/** One polyline per run of the day, `[lat, lng]`. */
lines: [number, number][][]
segments: RouteSegment[]
/** Metres and seconds, summed over the day's legs. */
distance: number
duration: number
/** The modes this day is actually travelled in, first use first. */
modes: string[]
}
export interface TripRouteOverview {
days: TripOverviewDay[]
/** Every day's polylines flattened in trip order — what the map draws. */
lines: [number, number][][]
/** The colour of each entry of `lines`, same index. */
lineColors: string[]
segments: RouteSegment[]
/** Every drawn coordinate, so the map can frame the whole trip at once. */
focusPoints: [number, number][]
totalDistance: number
totalDuration: number
/** True until every leg has answered — the totals are a partial sum until then. */
loading: boolean
}
const EMPTY: TripRouteOverview = {
days: [], lines: [], lineColors: [], segments: [], focusPoints: [],
totalDistance: 0, totalDuration: 0, loading: false,
}
/** Neighbouring legs of one run that resolve to the same mode travel as one request,
* exactly as the single-day route builds them. */
interface Chunk { points: DayRoutePoint[]; mode: string }
function chunkRun(run: DayRoutePoint[], dayDefaultMode: string): Chunk[] {
const chunks: Chunk[] = []
let i = 0
while (i < run.length - 1) {
const mode = resolveLegMode(run[i], run[i + 1], dayDefaultMode)
let end = i + 1
while (end < run.length - 1 && resolveLegMode(run[end], run[end + 1], dayDefaultMode) === mode) end++
chunks.push({ points: run.slice(i, end + 1), mode })
i = end
}
return chunks
}
const straight = (points: DayRoutePoint[]): [number, number][] => points.map(p => [p.lat, p.lng])
/**
* Every travel day's route at once, each day in its own colour, with the trip's total
* distance (#1736).
*
* The geometry is the day view's own — `buildDayRouteRuns` is the same builder
* `useRouteCalculation` draws the selected day from, so a day looks identical whether
* you are looking at it alone or at the whole trip. Only the aggregation is new.
*
* Legs go out a few at a time rather than all at once: a fortnight is dozens of legs and
* the routing host is usually a shared OSRM. RouteCalculator's cache is shared with the
* day route and the sidebar connectors, so the day already on screen costs nothing.
*/
export function useTripRouteOverview(
tripId: number | null,
days: Day[],
assignments: AssignmentsMap,
reservations: Reservation[],
accommodations: Accommodation[],
profile: RouteProfileKey,
enabled: boolean,
): TripRouteOverview {
const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation)
// Leg text is formatted at compute time, so a km↔mi switch has to re-run (#1300).
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const [result, setResult] = useState<TripRouteOverview>(EMPTY)
const abortRef = useRef<AbortController | null>(null)
const plan = useMemo(() => {
if (!enabled) return []
return [...days]
.sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
.map(day => ({
day,
runs: buildDayRouteRuns(day.id, { days, assignments, reservations, accommodations, optimizeFromAccommodation }),
}))
.filter(entry => entry.runs.length > 0)
}, [enabled, days, assignments, reservations, accommodations, optimizeFromAccommodation])
// Only geometry and mode decide whether legs have to be fetched again: renaming a
// place or editing its notes must not fire a routing round.
const planKey = useMemo(
() => plan.map(({ day, runs }) => `${day.id}@${day.default_transport_mode ?? ''}:${runs
.map(run => run.map(p => `${p.lat.toFixed(5)},${p.lng.toFixed(5)},${p.leg_transport_mode ?? ''},${p.incoming_leg_transport_mode ?? ''}`).join('|'))
.join('/')}`).join(';'),
[plan],
)
useEffect(() => {
abortRef.current?.abort()
if (!plan.length) { setResult(EMPTY); return }
const controller = new AbortController()
abortRef.current = controller
const colorFor = (day: Day) => TRACK_COLORS[Math.abs(day.day_number ?? 0) % TRACK_COLORS.length]
// Chunk every day up front, so the routed answers can be filed by position and
// reassembled in order however they come back.
const chunked = plan.map(({ day, runs }) => {
const dayDefaultMode = day.default_transport_mode || profile
return { day, runs: runs.map(run => chunkRun(run, dayDefaultMode)) }
})
const assemble = (
routed: (({ coordinates: [number, number][]; legs: RouteSegment[] }) | null)[][][],
): TripRouteOverview => {
const overviewDays: TripOverviewDay[] = chunked.map(({ day, runs }, d) => {
const lines: [number, number][][] = []
const segments: RouteSegment[] = []
runs.forEach((chunks, r) => {
const polyline: [number, number][] = []
chunks.forEach((chunk, c) => {
const answer = routed[d][r][c]
const coords = answer && answer.coordinates.length >= 2 ? answer.coordinates : straight(chunk.points)
for (const point of coords) {
// Drop the point shared with the previous chunk so concatenated legs
// don't leave a duplicate at each junction.
const last = polyline[polyline.length - 1]
if (last && last[0] === point[0] && last[1] === point[1]) continue
polyline.push(point)
}
if (answer) for (const leg of answer.legs) segments.push({ ...leg, mode: chunk.mode })
})
if (polyline.length >= 2) lines.push(polyline)
})
const modes: string[] = []
for (const chunk of runs.flat()) if (!modes.includes(chunk.mode)) modes.push(chunk.mode)
return {
dayId: day.id,
dayNumber: day.day_number ?? 0,
date: day.date ?? null,
title: day.title ?? null,
color: colorFor(day),
lines,
segments,
distance: segments.reduce((sum, s) => sum + s.distance, 0),
duration: segments.reduce((sum, s) => sum + s.duration, 0),
modes,
}
})
const lines = overviewDays.flatMap(d => d.lines)
return {
days: overviewDays,
lines,
lineColors: overviewDays.flatMap(d => d.lines.map(() => d.color)),
segments: overviewDays.flatMap(d => d.segments),
focusPoints: lines.flat(),
totalDistance: overviewDays.reduce((sum, d) => sum + d.distance, 0),
totalDuration: overviewDays.reduce((sum, d) => sum + d.duration, 0),
loading: false,
}
}
const routed = chunked.map(({ runs }) => runs.map(chunks => chunks.map(() => null as
({ coordinates: [number, number][]; legs: RouteSegment[] }) | null)))
// Straight lines first so the shape of the trip is on screen immediately, then the
// real roads replace them — the same two-step the single-day route draws with.
setResult({ ...assemble(routed), loading: true })
;(async () => {
const tasks: (() => Promise<void>)[] = []
chunked.forEach(({ day, runs }, d) => {
runs.forEach((chunks, r) => {
chunks.forEach((chunk, c) => {
tasks.push(async () => {
try {
const answer = await calculateRouteWithLegs(
chunk.points.map(p => ({ lat: p.lat, lng: p.lng })),
{ signal: controller.signal, profile: chunk.mode, tripId, dayId: day.id },
)
routed[d][r][c] = { coordinates: answer.coordinates, legs: answer.legs }
} catch {
// Routing refused this leg (usually a rate limit) — it keeps its
// straight line and contributes no distance, exactly as a day route's
// failed leg does.
}
})
})
})
})
// Small pool on purpose, the same one the sidebar connectors use: a fortnight is
// dozens of legs and the routing host is often a shared OSRM.
let next = 0
const worker = async () => {
while (next < tasks.length && !controller.signal.aborted) await tasks[next++]()
}
await Promise.all(Array.from({ length: Math.min(6, tasks.length) }, worker))
if (!controller.signal.aborted) setResult(assemble(routed))
})()
return () => controller.abort()
// planKey is derived from the same inputs as plan, so keying on the string is
// equivalent while staying stable across unrelated renders.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [planKey, profile, tripId, distanceUnit])
return result
}