mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import type { RouteSegment } from '../../types'
|
||||
// Walking gets the foot icon; a plugin route profile ('plugin:…') gets the bolt —
|
||||
// its legs carry the profile-true durationText anyway, the icon just signals that
|
||||
// the time came from a plugin router (e.g. EV routing with charge time folded in).
|
||||
function profileIcon(profile: string) {
|
||||
export function profileIcon(profile: string) {
|
||||
if (profile === 'walking') return Footprints
|
||||
if (profile.startsWith('plugin:')) return Zap
|
||||
return Car
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
||||
import { useTripStore } from '../store/tripStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { calculateRouteWithLegs, withHotelBookends, type RouteProfileKey } from '../components/Map/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 { calculateRouteWithLegs, type RouteProfileKey } from '../components/Map/RouteCalculator'
|
||||
import { buildDayRouteRuns, TRANSPORT_TYPES } from '../components/Map/dayRoutePlan'
|
||||
import { resolveLegMode } from '../components/Planner/legMode'
|
||||
import type { TripStoreState } from '../store/tripStore'
|
||||
import type { RouteSegment, RouteResult, RouteVia, Accommodation } from '../types'
|
||||
|
||||
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other']
|
||||
|
||||
const NO_ACCOMMODATIONS: Accommodation[] = []
|
||||
|
||||
/**
|
||||
@@ -44,174 +40,16 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
if (!dayId || !enabled) { setRoute(null); setRouteSegments([]); setRouteVias([]); return }
|
||||
// Read directly from store (not a render-phase ref) so callers after optimistic
|
||||
// updates or non-optimistic deletes always see the latest assignments.
|
||||
const currentAssignments = useTripStore.getState().assignments || {}
|
||||
const da = (currentAssignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
const allReservations = useTripStore.getState().reservations || []
|
||||
const allDays = useTripStore.getState().days || []
|
||||
const dayOrder = (id: number | null | undefined): number | null => {
|
||||
if (id == null) return null
|
||||
const d = allDays.find(x => x.id === id)
|
||||
return d ? ((d as any).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. The old builder
|
||||
// read the BOOKING's position and never expanded metadata.legs, while a
|
||||
// multi-leg booking stores its position per leg — so a layover flight was
|
||||
// dropped from the waypoint list entirely and the airport before it was joined
|
||||
// to the airport after it in one road run across an ocean (#2071).
|
||||
//
|
||||
// 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),
|
||||
const state = useTripStore.getState()
|
||||
const allDays = state.days || []
|
||||
const runsWithHotel = buildDayRouteRuns(dayId, {
|
||||
days: allDays,
|
||||
assignments: state.assignments || {},
|
||||
reservations: state.reservations || [],
|
||||
accommodations,
|
||||
optimizeFromAccommodation,
|
||||
})
|
||||
const merged = getMergedItems({ dayAssignments: da, dayNotes: [], dayTransports, dayId })
|
||||
|
||||
// Build a unified list of places + transports sorted by effective position.
|
||||
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 (you
|
||||
// travel there), then breaks the run (you don't drive the flight/train); its
|
||||
// arrival point starts the next run.
|
||||
// - A transport WITHOUT a location is ignored entirely — the places around it
|
||||
// connect directly, as if the booking weren't there.
|
||||
// A run is only a real drive when it contains at least one actual place. Two
|
||||
// back-to-back transports (e.g. two flights on one day) would otherwise pair the
|
||||
// first's arrival point with the second's departure point into a phantom
|
||||
// [airport → airport] road route — that is the flight itself, not a drive (#1394).
|
||||
//
|
||||
// A booking endpoint also has to be REACHABLE from the stop before it. The day's
|
||||
// order is a wall clock with no timezone behind it, so a long-haul arrival whose
|
||||
// departure clock reads late sorts after the day's local stops — and its departure
|
||||
// airport, an ocean away, then gets stapled to the last of them. The router answers
|
||||
// that pair with NoRoute and the whole chunk falls back to a straight line, which is
|
||||
// the long ray the map draws (#2133). Distance is only ever consulted for a leg
|
||||
// touching a transport endpoint: two real places far apart are a drive someone
|
||||
// planned, an airport that far from the stop before it never is.
|
||||
type RunPoint = { lat: number; lng: number; isPlace: boolean; leg_transport_mode?: string | null; incoming_leg_transport_mode?: string | null }
|
||||
const runs: RunPoint[][] = []
|
||||
let currentRun: RunPoint[] = []
|
||||
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: a hotel → first-stop run and
|
||||
// a last-stop → hotel run, so the drawn line matches the sidebar's hotel legs.
|
||||
// getDayBookendHotels returns the morning/evening hotel (they differ only on a
|
||||
// transfer day) and already filters to accommodations that have coordinates.
|
||||
const day = allDays.find(d => d.id === dayId)
|
||||
const bookends = day && optimizeFromAccommodation !== false
|
||||
? getDayBookendHotels(day, allDays, accommodations)
|
||||
: null
|
||||
const flatPts: RunPoint[] = []
|
||||
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): RunPoint | 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. You start/end the day at a hotel
|
||||
// when you slept there / sleep there tonight; on the hotel's own check-in or check-out day
|
||||
// the leg holds only when the edge stop is a PLACE timed after check-in / before check-out
|
||||
// (you dropped bags first, or swung back before checking out). A place before check-in (an
|
||||
// airport you reach first, #1465), a later "home" stop on the checkout day (#1465), or a
|
||||
// transport endpoint on an arrival/departure day (#1321, S7) all draw no bookend.
|
||||
// A carrier endpoint at an edge additionally kills its own leg outright, however
|
||||
// near the hotel it sits: you flew out of that airport, so nothing drove back from
|
||||
// it tonight, and you flew into that one, so nothing drove to it this morning
|
||||
// (#2133). Which of a transport's two points sits at the edge depends on its span —
|
||||
// an overnight flight contributes only its departure on the day it leaves.
|
||||
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)
|
||||
// Same reachability test as the run builder: a hotel is not joined to a point no
|
||||
// one could have driven between.
|
||||
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: RunPoint[][] = 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.
|
||||
// Draw the hotel → hotel transfer directly. Gated on both bookends being real
|
||||
// (drawMorning/drawEvening already exclude the #1321 arrival fallback) and the two
|
||||
// hotels being distinct, so an ordinary same-hotel rest day still draws nothing.
|
||||
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])
|
||||
}
|
||||
|
||||
const straightLines = (): [number, number][][] =>
|
||||
runsWithHotel.map(r => r.map(p => [p.lat, p.lng] as [number, number]))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { MapViewAuto } from '../../../../components/Map/MapViewAuto'
|
||||
import { MapCompassPill, type CompassMap } from '../../../../components/Map/MapCompassPill'
|
||||
import { TripRouteOverviewPill, TripRouteOverviewPanel } from '../../../../components/Map/TripRouteOverview'
|
||||
import PoiCategoryPill from '../../../../components/Map/PoiCategoryPill'
|
||||
import { usePoiExplore } from '../../../../components/Map/usePoiExplore'
|
||||
import { useSettingsStore } from '../../../../store/settingsStore'
|
||||
@@ -29,6 +30,7 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
|
||||
const poi = usePoiExplore()
|
||||
const [glMap, setGlMap] = useState<CompassMap | null>(null)
|
||||
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
|
||||
const mapActive = shell.view === 'map'
|
||||
|
||||
@@ -44,14 +46,16 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
|
||||
tripId={planner.tripId}
|
||||
places={planner.mapPlaces}
|
||||
dayPlaces={planner.dayPlaces}
|
||||
route={planner.route}
|
||||
route={planner.overviewActive ? planner.tripOverview.lines : planner.route}
|
||||
routeColors={planner.overviewActive ? planner.tripOverview.lineColors : undefined}
|
||||
focusPoints={planner.overviewActive ? planner.tripOverview.focusPoints : undefined}
|
||||
routeVias={planner.routeVias}
|
||||
showTransitRoutes={planner.transitRoutesShown}
|
||||
// The route toggle belongs to one day, so the map needs that day to know
|
||||
// which automated transports may ride it (#2019).
|
||||
days={planner.days}
|
||||
selectedDayId={planner.selectedDayId}
|
||||
routeSegments={planner.routeSegments}
|
||||
routeSegments={planner.overviewActive ? planner.tripOverview.segments : planner.routeSegments}
|
||||
selectedPlaceId={planner.selectedPlaceId}
|
||||
onMarkerClick={planner.handleMarkerClick}
|
||||
// Tap on empty map = deselect, same contract as desktop.
|
||||
@@ -104,6 +108,29 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
|
||||
<MapCompassPill map={glMap} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Whole-trip overview (#1736): the stages stack above their toggle on the right,
|
||||
clear of the round-controls band below it and of the compass and Leaflet's
|
||||
base-layer switcher, which both sit in the bottom-LEFT corner. The offset is
|
||||
Tailwind rather than inline because the compass band is identified by being
|
||||
the one element with an inline --bottom-nav-h, and a second would make that
|
||||
ambiguous. */}
|
||||
{mapActive && !planner.roadtripActive && (
|
||||
<div className="pointer-events-none absolute left-3 right-3 z-[25] flex flex-col items-end gap-2 bottom-[calc(var(--bottom-nav-h,84px)+58px)]">
|
||||
{planner.overviewActive && (
|
||||
<TripRouteOverviewPanel
|
||||
overview={planner.tripOverview}
|
||||
unit={distanceUnit}
|
||||
selectedDayId={planner.selectedDayId}
|
||||
onSelectDay={planner.handleSelectDay}
|
||||
// Tighter than the desktop card: the map is the whole screen here, so a
|
||||
// long day name ellipsizes rather than eating another 80px of it.
|
||||
maxWidth={240}
|
||||
/>
|
||||
)}
|
||||
<TripRouteOverviewPill active={planner.overviewShown} onToggle={planner.toggleOverview} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useCanDo } from '../store/permissionsStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { MapViewAuto as MapView } from '../components/Map/MapViewAuto'
|
||||
import { MapCompassPill, type CompassMap } from '../components/Map/MapCompassPill'
|
||||
import { TripRouteOverviewPill, TripRouteOverviewPanel } from '../components/Map/TripRouteOverview'
|
||||
import { getCached, fetchPhoto } from '../services/photoService'
|
||||
import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
|
||||
import RoadtripModeSwitch from '../components/Roadtrip/RoadtripModeSwitch'
|
||||
@@ -252,6 +253,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
pushUndo, undo, canUndo, lastActionLabel, handleUndo,
|
||||
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
|
||||
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripCorridor,
|
||||
overviewActive, tripOverview, toggleOverview, overviewShown,
|
||||
followTrack, roadtripViaCounts,
|
||||
allowedFileTypes, tripMembers, setTripMembers, refreshMembers, loadAccommodations,
|
||||
TRANSPORT_TYPES, TRIP_TABS, activeTab, setActiveTab, handleTabChange,
|
||||
@@ -326,6 +328,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
// page level so it has tripMembers / base currency / current user available.
|
||||
const meId = useAuthStore(s => s.user?.id ?? -1)
|
||||
const displayCurrency = useSettingsStore(s => s.settings.default_currency)
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
const costsBase = (displayCurrency || trip?.currency || 'EUR').toUpperCase()
|
||||
// Transit search departs against a real date, so the whole Automated mode —
|
||||
// the day-header tram button and the modal's mode switch — is off without one.
|
||||
@@ -396,7 +399,8 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
tripId={tripId}
|
||||
places={mapPlaces}
|
||||
dayPlaces={dayPlaces}
|
||||
route={roadtripActive ? roadtripRoutes.lines : route}
|
||||
route={roadtripActive ? roadtripRoutes.lines : overviewActive ? tripOverview.lines : route}
|
||||
routeColors={overviewActive ? tripOverview.lineColors : undefined}
|
||||
routeVias={roadtripActive ? roadtripRoutes.vias : routeVias}
|
||||
accessLines={roadtripActive ? roadtripRoutes.accessLines : undefined}
|
||||
showTransitRoutes={transitRoutesShown}
|
||||
@@ -404,7 +408,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
// know which automated transports may ride it (#2019).
|
||||
days={days}
|
||||
selectedDayId={selectedDayId}
|
||||
routeSegments={roadtripActive ? roadtripRoutes.segments : routeSegments}
|
||||
routeSegments={roadtripActive ? roadtripRoutes.segments : overviewActive ? tripOverview.segments : routeSegments}
|
||||
selectedPlaceId={selectedPlaceId}
|
||||
onMarkerClick={handleMarkerClick}
|
||||
onMapClick={handleMapClick}
|
||||
@@ -439,7 +443,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
onRouteClick={roadtripActive && can('day_edit', trip) ? addRoadtripVia : undefined}
|
||||
roadtripVias={roadtripActive ? roadtripVias.byDay : undefined}
|
||||
alternativeRoutes={alternativeOverlays}
|
||||
focusPoints={alternativeFocusPoints}
|
||||
focusPoints={overviewActive ? tripOverview.focusPoints : alternativeFocusPoints}
|
||||
clusterLoosely={roadtripActive}
|
||||
activeAlternative={highlightedAlternative}
|
||||
onChooseAlternative={chooseRouteAlternative}
|
||||
@@ -466,6 +470,28 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom-RIGHT. Not the top corridor between the panels, which is already
|
||||
contested by the POI bar and the collapse tabs (#2247); and not the
|
||||
bottom-left corner, where Leaflet's base-layer switcher sits at
|
||||
z-index 1000 and would cover this. The right corner is free on both
|
||||
renderers — the locate button that lives there is phone-only. */}
|
||||
{!roadtripActive && (
|
||||
<div className="hidden md:flex" style={{
|
||||
position: 'absolute', bottom: 18, right: mapInsetRight + 14, zIndex: 26,
|
||||
pointerEvents: 'none', flexDirection: 'column', alignItems: 'flex-end', gap: 8,
|
||||
}}>
|
||||
{overviewActive && (
|
||||
<TripRouteOverviewPanel
|
||||
overview={tripOverview}
|
||||
unit={distanceUnit}
|
||||
selectedDayId={selectedDayId}
|
||||
onSelectDay={handleSelectDay}
|
||||
/>
|
||||
)}
|
||||
<TripRouteOverviewPill active={overviewShown} onToggle={toggleOverview} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(poiPillEnabled || glMap) && (
|
||||
<div className="hidden md:flex" style={{
|
||||
position: 'absolute', top: 14,
|
||||
|
||||
@@ -449,7 +449,9 @@ describe('TripPlannerPage — plan tab', () => {
|
||||
it('FE-PAGE-TPW-013: the collapse buttons toggle each side panel', () => {
|
||||
renderPage()
|
||||
|
||||
const [leftBtn, rightBtn] = screen.getAllByRole('button')
|
||||
// By name, not by position: the map layer carries controls of its own, so the
|
||||
// tabs are not simply the first two buttons on the page.
|
||||
const [leftBtn, rightBtn] = screen.getAllByLabelText('common.collapse')
|
||||
fireEvent.click(leftBtn)
|
||||
expect(hookState.toggleLeft).toHaveBeenCalled()
|
||||
|
||||
@@ -466,9 +468,8 @@ describe('TripPlannerPage — plan tab', () => {
|
||||
// announce themselves with nothing but a hover colour (#2247).
|
||||
it('FE-PAGE-TPW-013b: each collapse tab is named for what it does', () => {
|
||||
renderPage({ leftHidden: true, rightHidden: false })
|
||||
const [leftBtn, rightBtn] = screen.getAllByRole('button')
|
||||
expect(leftBtn).toHaveAttribute('aria-label', 'trip.mobilePlan')
|
||||
expect(rightBtn).toHaveAttribute('aria-label', 'common.collapse')
|
||||
expect(screen.getByLabelText('trip.mobilePlan')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('common.collapse')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// At a foldable's ~860px the viewport centre sits under the Places panel, so the
|
||||
@@ -485,6 +486,17 @@ describe('TripPlannerPage — plan tab', () => {
|
||||
expect(cluster.style.left).toBe('calc(350px + 0.5 * (100% - 350px - 0px))')
|
||||
})
|
||||
|
||||
// Leaflet's base-layer switcher owns the bottom-LEFT corner at z-index 1000, so an
|
||||
// overview control placed there is simply covered by it (#1736).
|
||||
it('FE-PAGE-TPW-013d: the trip-overview control hugs the right edge, clear of the layer switcher', () => {
|
||||
renderPage()
|
||||
|
||||
const stack = screen.getByTestId('trip-overview-pill').closest('[style*="position: absolute"]') as HTMLElement
|
||||
expect(stack).not.toBeNull()
|
||||
expect(stack.style.right).not.toBe('')
|
||||
expect(stack.style.left).toBe('')
|
||||
})
|
||||
|
||||
it('FE-PAGE-TPW-014: a collapsed panel keeps its toggle but drops the hover highlight', () => {
|
||||
renderPage({ leftHidden: true, rightHidden: true })
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useAuthStore } from '../../store/authStore'
|
||||
import { useResizablePanels } from '../../hooks/useResizablePanels'
|
||||
import { useTripWebSocket } from '../../hooks/useTripWebSocket'
|
||||
import { useRouteCalculation } from '../../hooks/useRouteCalculation'
|
||||
import { useTripRouteOverview } from '../../components/Map/useTripRouteOverview'
|
||||
import { useRoadtripRoutes } from '../../components/Roadtrip/useRoadtripRoutes'
|
||||
import { useRoadtripCorridor } from '../../components/Roadtrip/useRoadtripCorridor'
|
||||
import { useRoadtripVias } from '../../components/Roadtrip/useRoadtripVias'
|
||||
@@ -364,6 +365,17 @@ export function useTripPlanner() {
|
||||
// transport in the trip (#2019).
|
||||
const transitRoutesShown = routeShown && selectedDayId != null
|
||||
const [routeProfile, setRouteProfile] = useState<string>('driving')
|
||||
// Whole-trip route overview (#1736): every day's route at once, each in its own
|
||||
// colour. Per trip and per session like road trip mode — it answers "what does the
|
||||
// whole thing look like", which is a question you ask of one trip, not a preference.
|
||||
const [overviewShown, setOverviewShown] = useState<boolean>(() => sessionStorage.getItem(`trip-overview-${tripId}`) === '1')
|
||||
const toggleOverview = useCallback(() => {
|
||||
setOverviewShown(prev => {
|
||||
const next = !prev
|
||||
sessionStorage.setItem(`trip-overview-${tripId}`, next ? '1' : '0')
|
||||
return next
|
||||
})
|
||||
}, [tripId])
|
||||
const [fitKey, setFitKey] = useState<number>(0)
|
||||
const initialFitTripId = useRef<number | null>(null)
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<'left' | 'right' | null>(null)
|
||||
@@ -534,6 +546,10 @@ export function useTripPlanner() {
|
||||
}, [places, placesCategoryFilter, placesFilter, assignments, expandedDayIds, selectedDayId, days, tripAccommodations, reservations])
|
||||
|
||||
const { route, routeSegments, routeVias, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId, routeShown, routeProfile, tripAccommodations)
|
||||
// Road trip mode already draws the whole trip its own way, so the overview stands
|
||||
// down there rather than drawing a second set of lines over it.
|
||||
const overviewActive = overviewShown && !roadtripMode
|
||||
const tripOverview = useTripRouteOverview(tripId, days, assignments, reservations, tripAccommodations, routeProfile, overviewActive)
|
||||
|
||||
// Road trip mode reads the whole trip, not the selected day, so it owns its own legs.
|
||||
// Passing no days while the mode is off keeps it inert — no routing requests, no state.
|
||||
@@ -1785,6 +1801,7 @@ export function useTripPlanner() {
|
||||
pushUndo, undo, canUndo, lastActionLabel, handleUndo,
|
||||
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
|
||||
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripCorridor,
|
||||
overviewShown, toggleOverview, overviewActive, tripOverview,
|
||||
followTrack, roadtripViaCounts,
|
||||
allowedFileTypes, tripMembers, setTripMembers, refreshMembers, loadAccommodations,
|
||||
TRANSPORT_TYPES, TRIP_TABS, activeTab, setActiveTab, handleTabChange,
|
||||
|
||||
Reference in New Issue
Block a user