mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +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
|
||||
}
|
||||
Reference in New Issue
Block a user