From bf1900c3a0a060fcde0413d3e025c7fc09e268f6 Mon Sep 17 00:00:00 2001 From: Konstantinos Thermos Date: Thu, 10 Sep 2026 15:57:48 +0300 Subject: [PATCH] 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 --- client/src/components/Map/LocationButton.tsx | 3 +- .../src/components/Map/MapLayerSwitcher.tsx | 3 +- client/src/components/Map/MapView.test.tsx | 11 + client/src/components/Map/MapView.tsx | 10 +- client/src/components/Map/MapViewGL.test.tsx | 13 + client/src/components/Map/MapViewGL.tsx | 17 +- .../components/Map/TripRouteOverview.test.tsx | 90 +++++++ .../src/components/Map/TripRouteOverview.tsx | 130 ++++++++++ .../src/components/Map/dayRoutePlan.test.ts | 108 +++++++++ client/src/components/Map/dayRoutePlan.ts | 169 +++++++++++++ client/src/components/Map/mapControlShadow.ts | 10 + .../Map/useTripRouteOverview.test.ts | 137 +++++++++++ .../components/Map/useTripRouteOverview.ts | 225 ++++++++++++++++++ .../Planner/DayPlanSidebarRouteConnector.tsx | 2 +- client/src/hooks/useRouteCalculation.ts | 180 +------------- .../src/mobile/screens/trip/map/MMapArea.tsx | 31 ++- client/src/pages/TripPlannerPage.tsx | 32 ++- .../src/pages/TripPlannerPage.wiring.test.tsx | 20 +- .../src/pages/tripPlanner/useTripPlanner.ts | 17 ++ shared/src/i18n/ar/map.ts | 3 + shared/src/i18n/br/map.ts | 3 + shared/src/i18n/ca/map.ts | 3 + shared/src/i18n/cs/map.ts | 3 + shared/src/i18n/de/map.ts | 3 + shared/src/i18n/en/map.ts | 3 + shared/src/i18n/es/map.ts | 3 + shared/src/i18n/fr/map.ts | 3 + shared/src/i18n/gr/map.ts | 3 + shared/src/i18n/hu/map.ts | 3 + shared/src/i18n/id/map.ts | 3 + shared/src/i18n/it/map.ts | 3 + shared/src/i18n/ja/map.ts | 3 + shared/src/i18n/ko/map.ts | 3 + shared/src/i18n/nl/map.ts | 3 + shared/src/i18n/pl/map.ts | 3 + shared/src/i18n/ru/map.ts | 3 + shared/src/i18n/sv/map.ts | 3 + shared/src/i18n/tr/map.ts | 3 + shared/src/i18n/uk/map.ts | 3 + shared/src/i18n/vi/map.ts | 3 + shared/src/i18n/zh-TW/map.ts | 3 + shared/src/i18n/zh/map.ts | 3 + wiki/Map-Features.md | 10 + 43 files changed, 1095 insertions(+), 192 deletions(-) create mode 100644 client/src/components/Map/TripRouteOverview.test.tsx create mode 100644 client/src/components/Map/TripRouteOverview.tsx create mode 100644 client/src/components/Map/dayRoutePlan.test.ts create mode 100644 client/src/components/Map/dayRoutePlan.ts create mode 100644 client/src/components/Map/mapControlShadow.ts create mode 100644 client/src/components/Map/useTripRouteOverview.test.ts create mode 100644 client/src/components/Map/useTripRouteOverview.ts diff --git a/client/src/components/Map/LocationButton.tsx b/client/src/components/Map/LocationButton.tsx index 236a6e076..f2591b192 100644 --- a/client/src/components/Map/LocationButton.tsx +++ b/client/src/components/Map/LocationButton.tsx @@ -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', diff --git a/client/src/components/Map/MapLayerSwitcher.tsx b/client/src/components/Map/MapLayerSwitcher.tsx index 9b81bb3fe..2c7db30b8 100644 --- a/client/src/components/Map/MapLayerSwitcher.tsx +++ b/client/src/components/Map/MapLayerSwitcher.tsx @@ -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, }}> + + ) +} + +/** + * 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 ( +
+
+ + {t('map.overview.total')} + + {/* Same tier as the label beside it: the two read as one line rather than as a + heading with a number stuck under it. */} + + {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 && {' '}…} + +
+
+ {overview.days.map(day => { + const label = day.title || t('dayplan.dayN', { n: day.dayNumber }) + const row = ( + <> + + + {label} + + + {day.modes.map(mode => { + const Icon = profileIcon(mode) + return + })} + + + {formatDistance(day.distance / 1000, unit)} + + + ) + 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 ? ( + + ) : ( +
{row}
+ ) + })} +
+
+ ) +} diff --git a/client/src/components/Map/dayRoutePlan.test.ts b/client/src/components/Map/dayRoutePlan.test.ts new file mode 100644 index 000000000..58a56bde5 --- /dev/null +++ b/client/src/components/Map/dayRoutePlan.test.ts @@ -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 = {}) => + 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([]) + }) +}) diff --git a/client/src/components/Map/dayRoutePlan.ts b/client/src/components/Map/dayRoutePlan.ts new file mode 100644 index 000000000..72e43bfc0 --- /dev/null +++ b/client/src/components/Map/dayRoutePlan.ts @@ -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 +} diff --git a/client/src/components/Map/mapControlShadow.ts b/client/src/components/Map/mapControlShadow.ts new file mode 100644 index 000000000..3a24f123d --- /dev/null +++ b/client/src/components/Map/mapControlShadow.ts @@ -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)' diff --git a/client/src/components/Map/useTripRouteOverview.test.ts b/client/src/components/Map/useTripRouteOverview.test.ts new file mode 100644 index 000000000..2d33ca919 --- /dev/null +++ b/client/src/components/Map/useTripRouteOverview.test.ts @@ -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() + 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 = {}) => + 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) + }) +}) diff --git a/client/src/components/Map/useTripRouteOverview.ts b/client/src/components/Map/useTripRouteOverview.ts new file mode 100644 index 000000000..5aa8f34d3 --- /dev/null +++ b/client/src/components/Map/useTripRouteOverview.ts @@ -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(EMPTY) + const abortRef = useRef(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)[] = [] + 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 +} diff --git a/client/src/components/Planner/DayPlanSidebarRouteConnector.tsx b/client/src/components/Planner/DayPlanSidebarRouteConnector.tsx index e61dbff9f..255fe328e 100644 --- a/client/src/components/Planner/DayPlanSidebarRouteConnector.tsx +++ b/client/src/components/Planner/DayPlanSidebarRouteConnector.tsx @@ -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 diff --git a/client/src/hooks/useRouteCalculation.ts b/client/src/hooks/useRouteCalculation.ts index 0aa69e49f..ef4f9a8fe 100644 --- a/client/src/hooks/useRouteCalculation.ts +++ b/client/src/hooks/useRouteCalculation.ts @@ -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])) diff --git a/client/src/mobile/screens/trip/map/MMapArea.tsx b/client/src/mobile/screens/trip/map/MMapArea.tsx index 4ad3622a5..86f2324b7 100644 --- a/client/src/mobile/screens/trip/map/MMapArea.tsx +++ b/client/src/mobile/screens/trip/map/MMapArea.tsx @@ -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(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) { )} + + {/* 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 && ( +
+ {planner.overviewActive && ( + + )} + +
+ )} ) } diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 7410395af..50e03f72b 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -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 { )} + {/* 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 && ( +
+ {overviewActive && ( + + )} + +
+ )} + {(poiPillEnabled || glMap) && (
{ 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 }) diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index 9139bf098..301cdfc0a 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -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('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(() => 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(0) const initialFitTripId = useRef(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, diff --git a/shared/src/i18n/ar/map.ts b/shared/src/i18n/ar/map.ts index b286749a3..c2b148c7a 100644 --- a/shared/src/i18n/ar/map.ts +++ b/shared/src/i18n/ar/map.ts @@ -22,5 +22,8 @@ const map: TranslationStrings = { 'map.location.denied': 'تم حظر الوصول إلى الموقع. تحقق من إعدادات الجهاز؛ فالتطبيق المثبت لديه إذن موقع خاص به منفصل عن المتصفح.', 'map.location.unavailable': 'تعذر تحديد موقعك.', 'map.location.timeout': 'استغرق تحديد موقعك وقتًا طويلًا. حاول مرة أخرى تحت سماء مكشوفة.', + 'map.overview.show': 'عرض الرحلة كاملة', + 'map.overview.hide': 'إخفاء الرحلة كاملة', + 'map.overview.total': 'المسافة الإجمالية', }; export default map; diff --git a/shared/src/i18n/br/map.ts b/shared/src/i18n/br/map.ts index 167655546..6fca93b95 100644 --- a/shared/src/i18n/br/map.ts +++ b/shared/src/i18n/br/map.ts @@ -22,5 +22,8 @@ const map: TranslationStrings = { 'map.location.denied': 'O acesso à localização está bloqueado. Verifique as configurações do aparelho; um app instalado tem permissão de localização própria, separada do navegador.', 'map.location.unavailable': 'Não foi possível determinar sua localização.', 'map.location.timeout': 'A localização demorou demais. Tente de novo com uma visão mais aberta do céu.', + 'map.overview.show': 'Mostrar a viagem inteira', + 'map.overview.hide': 'Ocultar a viagem inteira', + 'map.overview.total': 'Distância total', }; export default map; diff --git a/shared/src/i18n/ca/map.ts b/shared/src/i18n/ca/map.ts index abd643901..e0bf1b992 100644 --- a/shared/src/i18n/ca/map.ts +++ b/shared/src/i18n/ca/map.ts @@ -22,5 +22,8 @@ const map: TranslationStrings = { 'map.location.denied': 'L’accés a la ubicació està bloquejat. Revisa la configuració del dispositiu; una app instal·lada té el seu propi permís d’ubicació, separat del navegador.', 'map.location.unavailable': 'No s’ha pogut determinar la teva ubicació.', 'map.location.timeout': 'La localització ha trigat massa. Torna-ho a provar amb una vista més clara del cel.', + 'map.overview.show': 'Mostra tot el viatge', + 'map.overview.hide': 'Amaga tot el viatge', + 'map.overview.total': 'Distància total', }; export default map; diff --git a/shared/src/i18n/cs/map.ts b/shared/src/i18n/cs/map.ts index c91d98cac..c12aea4db 100644 --- a/shared/src/i18n/cs/map.ts +++ b/shared/src/i18n/cs/map.ts @@ -22,5 +22,8 @@ const map: TranslationStrings = { 'map.location.denied': 'Přístup k poloze je zablokován. Zkontrolujte nastavení zařízení; nainstalovaná aplikace má vlastní oprávnění k poloze, nezávislé na prohlížeči.', 'map.location.unavailable': 'Vaši polohu se nepodařilo zjistit.', 'map.location.timeout': 'Zjišťování polohy trvalo příliš dlouho. Zkuste to znovu s volným výhledem na oblohu.', + 'map.overview.show': 'Zobrazit celou cestu', + 'map.overview.hide': 'Skrýt celou cestu', + 'map.overview.total': 'Celková vzdálenost', }; export default map; diff --git a/shared/src/i18n/de/map.ts b/shared/src/i18n/de/map.ts index c02a651fd..a4b0e874b 100644 --- a/shared/src/i18n/de/map.ts +++ b/shared/src/i18n/de/map.ts @@ -22,5 +22,8 @@ const map: TranslationStrings = { 'map.location.denied': 'Der Standortzugriff ist blockiert. Prüfe die Geräteeinstellungen; eine installierte App hat eine eigene Standortberechtigung, unabhängig vom Browser.', 'map.location.unavailable': 'Dein Standort konnte nicht ermittelt werden.', 'map.location.timeout': 'Die Standortbestimmung hat zu lange gedauert. Versuche es mit freier Sicht zum Himmel erneut.', + 'map.overview.show': 'Ganze Reise anzeigen', + 'map.overview.hide': 'Ganze Reise ausblenden', + 'map.overview.total': 'Gesamtstrecke', }; export default map; diff --git a/shared/src/i18n/en/map.ts b/shared/src/i18n/en/map.ts index 700fba7e1..b14c9b528 100644 --- a/shared/src/i18n/en/map.ts +++ b/shared/src/i18n/en/map.ts @@ -22,5 +22,8 @@ const map: TranslationStrings = { 'map.location.denied': 'Location access is blocked. Check your device settings; an installed app has its own location permission, separate from the browser.', 'map.location.unavailable': 'Your location could not be determined.', 'map.location.timeout': 'Locating you took too long. Try again with a clearer view of the sky.', + 'map.overview.show': 'Show whole trip', + 'map.overview.hide': 'Hide whole trip', + 'map.overview.total': 'Total distance', }; export default map; diff --git a/shared/src/i18n/es/map.ts b/shared/src/i18n/es/map.ts index 5dff94102..9478b1a0d 100644 --- a/shared/src/i18n/es/map.ts +++ b/shared/src/i18n/es/map.ts @@ -22,5 +22,8 @@ const map: TranslationStrings = { 'map.location.denied': 'El acceso a la ubicación está bloqueado. Revisa los ajustes del dispositivo; una app instalada tiene su propio permiso de ubicación, independiente del navegador.', 'map.location.unavailable': 'No se pudo determinar tu ubicación.', 'map.location.timeout': 'La localización tardó demasiado. Inténtalo de nuevo con una vista más despejada del cielo.', + 'map.overview.show': 'Mostrar todo el viaje', + 'map.overview.hide': 'Ocultar todo el viaje', + 'map.overview.total': 'Distancia total', }; export default map; diff --git a/shared/src/i18n/fr/map.ts b/shared/src/i18n/fr/map.ts index a40e1f64c..68854f27d 100644 --- a/shared/src/i18n/fr/map.ts +++ b/shared/src/i18n/fr/map.ts @@ -22,5 +22,8 @@ const map: TranslationStrings = { 'map.location.denied': 'L’accès à la position est bloqué. Vérifiez les réglages de l’appareil. Une app installée possède sa propre autorisation de localisation, distincte du navigateur.', 'map.location.unavailable': 'Votre position n’a pas pu être déterminée.', 'map.location.timeout': 'La localisation a pris trop de temps. Réessayez avec une vue plus dégagée du ciel.', + 'map.overview.show': 'Afficher tout le voyage', + 'map.overview.hide': 'Masquer tout le voyage', + 'map.overview.total': 'Distance totale', }; export default map; diff --git a/shared/src/i18n/gr/map.ts b/shared/src/i18n/gr/map.ts index 63ec7ff83..37ee38c72 100644 --- a/shared/src/i18n/gr/map.ts +++ b/shared/src/i18n/gr/map.ts @@ -22,5 +22,8 @@ const map: TranslationStrings = { 'map.location.denied': 'Η πρόσβαση στην τοποθεσία είναι αποκλεισμένη. Ελέγξτε τις ρυθμίσεις της συσκευής. Μια εγκατεστημένη εφαρμογή έχει δική της άδεια τοποθεσίας, ξεχωριστή από το πρόγραμμα περιήγησης.', 'map.location.unavailable': 'Δεν ήταν δυνατός ο προσδιορισμός της τοποθεσίας σας.', 'map.location.timeout': 'Ο εντοπισμός της θέσης σας άργησε πολύ. Δοκιμάστε ξανά με καθαρή θέα στον ουρανό.', + 'map.overview.show': 'Εμφάνιση όλου του ταξιδιού', + 'map.overview.hide': 'Απόκρυψη όλου του ταξιδιού', + 'map.overview.total': 'Συνολική απόσταση', }; export default map; diff --git a/shared/src/i18n/hu/map.ts b/shared/src/i18n/hu/map.ts index 6b2a927d5..4ed177293 100644 --- a/shared/src/i18n/hu/map.ts +++ b/shared/src/i18n/hu/map.ts @@ -22,5 +22,8 @@ const map: TranslationStrings = { 'map.location.denied': 'A helyhozzáférés le van tiltva. Ellenőrizd a készülék beállításait; a telepített alkalmazásnak saját helyengedélye van, a böngészőtől függetlenül.', 'map.location.unavailable': 'A tartózkodási helyed nem határozható meg.', 'map.location.timeout': 'A helymeghatározás túl sokáig tartott. Próbáld újra szabad ég alatt.', + 'map.overview.show': 'Teljes utazás megjelenítése', + 'map.overview.hide': 'Teljes utazás elrejtése', + 'map.overview.total': 'Teljes távolság', }; export default map; diff --git a/shared/src/i18n/id/map.ts b/shared/src/i18n/id/map.ts index 56454bdad..0b41df148 100644 --- a/shared/src/i18n/id/map.ts +++ b/shared/src/i18n/id/map.ts @@ -22,5 +22,8 @@ const map: TranslationStrings = { 'map.location.denied': 'Akses lokasi diblokir. Periksa pengaturan perangkat; aplikasi yang terpasang punya izin lokasi sendiri, terpisah dari browser.', 'map.location.unavailable': 'Lokasi Anda tidak dapat ditentukan.', 'map.location.timeout': 'Penentuan lokasi terlalu lama. Coba lagi di tempat dengan pandangan langit yang lebih terbuka.', + 'map.overview.show': 'Tampilkan seluruh perjalanan', + 'map.overview.hide': 'Sembunyikan seluruh perjalanan', + 'map.overview.total': 'Jarak total', }; export default map; diff --git a/shared/src/i18n/it/map.ts b/shared/src/i18n/it/map.ts index c42884b44..0ada52a73 100644 --- a/shared/src/i18n/it/map.ts +++ b/shared/src/i18n/it/map.ts @@ -22,5 +22,8 @@ const map: TranslationStrings = { 'map.location.denied': 'L’accesso alla posizione è bloccato. Controlla le impostazioni del dispositivo; un’app installata ha un proprio permesso di localizzazione, separato dal browser.', 'map.location.unavailable': 'Impossibile determinare la tua posizione.', 'map.location.timeout': 'La localizzazione ha richiesto troppo tempo. Riprova con una visuale più libera del cielo.', + 'map.overview.show': 'Mostra tutto il viaggio', + 'map.overview.hide': 'Nascondi tutto il viaggio', + 'map.overview.total': 'Distanza totale', }; export default map; diff --git a/shared/src/i18n/ja/map.ts b/shared/src/i18n/ja/map.ts index 7e2aa1ae2..a5d7d3ca9 100644 --- a/shared/src/i18n/ja/map.ts +++ b/shared/src/i18n/ja/map.ts @@ -22,5 +22,8 @@ const map: TranslationStrings = { 'map.location.denied': '位置情報へのアクセスがブロックされています。端末の設定を確認してください。インストールしたアプリにはブラウザとは別の位置情報の許可があります。', 'map.location.unavailable': '現在地を特定できませんでした。', 'map.location.timeout': '位置情報の取得に時間がかかりすぎました。空がよく見える場所でもう一度お試しください。', + 'map.overview.show': '旅程全体を表示', + 'map.overview.hide': '旅程全体を非表示', + 'map.overview.total': '合計距離', }; export default map; diff --git a/shared/src/i18n/ko/map.ts b/shared/src/i18n/ko/map.ts index d87093ad8..c20e69a9e 100644 --- a/shared/src/i18n/ko/map.ts +++ b/shared/src/i18n/ko/map.ts @@ -22,5 +22,8 @@ const map: TranslationStrings = { 'map.location.denied': '위치 접근이 차단되어 있습니다. 기기 설정을 확인하세요. 설치된 앱은 브라우저와 별도의 위치 권한을 사용합니다.', 'map.location.unavailable': '현재 위치를 확인할 수 없습니다.', 'map.location.timeout': '위치를 확인하는 데 시간이 너무 오래 걸렸습니다. 하늘이 잘 보이는 곳에서 다시 시도하세요.', + 'map.overview.show': '전체 여행 보기', + 'map.overview.hide': '전체 여행 숨기기', + 'map.overview.total': '총 거리', }; export default map; diff --git a/shared/src/i18n/nl/map.ts b/shared/src/i18n/nl/map.ts index 01cd0662e..5083b1925 100644 --- a/shared/src/i18n/nl/map.ts +++ b/shared/src/i18n/nl/map.ts @@ -22,5 +22,8 @@ const map: TranslationStrings = { 'map.location.denied': 'Locatietoegang is geblokkeerd. Controleer de apparaatinstellingen; een geïnstalleerde app heeft een eigen locatiemachtiging, los van de browser.', 'map.location.unavailable': 'Je locatie kon niet worden bepaald.', 'map.location.timeout': 'Het bepalen van je locatie duurde te lang. Probeer het opnieuw met vrij zicht op de lucht.', + 'map.overview.show': 'Hele reis tonen', + 'map.overview.hide': 'Hele reis verbergen', + 'map.overview.total': 'Totale afstand', }; export default map; diff --git a/shared/src/i18n/pl/map.ts b/shared/src/i18n/pl/map.ts index 8462c41c9..59c40db28 100644 --- a/shared/src/i18n/pl/map.ts +++ b/shared/src/i18n/pl/map.ts @@ -22,5 +22,8 @@ const map: TranslationStrings = { 'map.location.denied': 'Dostęp do lokalizacji jest zablokowany. Sprawdź ustawienia urządzenia; zainstalowana aplikacja ma własne uprawnienie do lokalizacji, niezależne od przeglądarki.', 'map.location.unavailable': 'Nie udało się ustalić Twojej lokalizacji.', 'map.location.timeout': 'Ustalanie lokalizacji trwało zbyt długo. Spróbuj ponownie przy lepszej widoczności nieba.', + 'map.overview.show': 'Pokaż całą podróż', + 'map.overview.hide': 'Ukryj całą podróż', + 'map.overview.total': 'Łączny dystans', }; export default map; diff --git a/shared/src/i18n/ru/map.ts b/shared/src/i18n/ru/map.ts index fe33312f9..a40c752a1 100644 --- a/shared/src/i18n/ru/map.ts +++ b/shared/src/i18n/ru/map.ts @@ -22,5 +22,8 @@ const map: TranslationStrings = { 'map.location.denied': 'Доступ к геопозиции заблокирован. Проверьте настройки устройства; у установленного приложения есть собственное разрешение на геопозицию, отдельное от браузера.', 'map.location.unavailable': 'Не удалось определить ваше местоположение.', 'map.location.timeout': 'Определение местоположения заняло слишком много времени. Попробуйте ещё раз под открытым небом.', + 'map.overview.show': 'Показать всю поездку', + 'map.overview.hide': 'Скрыть всю поездку', + 'map.overview.total': 'Общее расстояние', }; export default map; diff --git a/shared/src/i18n/sv/map.ts b/shared/src/i18n/sv/map.ts index f851d4016..37130e560 100644 --- a/shared/src/i18n/sv/map.ts +++ b/shared/src/i18n/sv/map.ts @@ -22,5 +22,8 @@ const map: TranslationStrings = { 'map.location.denied': 'Platsåtkomst är blockerad. Kontrollera enhetens inställningar; en installerad app har ett eget platstillstånd, skilt från webbläsaren.', 'map.location.unavailable': 'Din plats kunde inte fastställas.', 'map.location.timeout': 'Platsbestämningen tog för lång tid. Försök igen med friare sikt mot himlen.', + 'map.overview.show': 'Visa hela resan', + 'map.overview.hide': 'Dölj hela resan', + 'map.overview.total': 'Total sträcka', }; export default map; diff --git a/shared/src/i18n/tr/map.ts b/shared/src/i18n/tr/map.ts index f0440ce01..d317d6f94 100644 --- a/shared/src/i18n/tr/map.ts +++ b/shared/src/i18n/tr/map.ts @@ -22,5 +22,8 @@ const map: TranslationStrings = { 'map.location.denied': 'Konum erişimi engellendi. Cihaz ayarlarını kontrol edin; yüklü bir uygulamanın tarayıcıdan ayrı kendi konum izni vardır.', 'map.location.unavailable': 'Konumunuz belirlenemedi.', 'map.location.timeout': 'Konum belirleme çok uzun sürdü. Gökyüzünü daha iyi gören bir yerde tekrar deneyin.', + 'map.overview.show': 'Tüm geziyi göster', + 'map.overview.hide': 'Tüm geziyi gizle', + 'map.overview.total': 'Toplam mesafe', }; export default map; diff --git a/shared/src/i18n/uk/map.ts b/shared/src/i18n/uk/map.ts index 7ad961420..6dc038eb1 100644 --- a/shared/src/i18n/uk/map.ts +++ b/shared/src/i18n/uk/map.ts @@ -22,5 +22,8 @@ const map: TranslationStrings = { 'map.location.denied': 'Доступ до геолокації заблоковано. Перевірте налаштування пристрою; встановлений застосунок має власний дозвіл на геолокацію, окремий від браузера.', 'map.location.unavailable': 'Не вдалося визначити ваше місцезнаходження.', 'map.location.timeout': 'Визначення місцезнаходження тривало надто довго. Спробуйте ще раз просто неба.', + 'map.overview.show': 'Показати всю подорож', + 'map.overview.hide': 'Сховати всю подорож', + 'map.overview.total': 'Загальна відстань', }; export default map; diff --git a/shared/src/i18n/vi/map.ts b/shared/src/i18n/vi/map.ts index 98592065c..081fe6c22 100644 --- a/shared/src/i18n/vi/map.ts +++ b/shared/src/i18n/vi/map.ts @@ -22,5 +22,8 @@ const map: TranslationStrings = { 'map.location.denied': 'Quyền truy cập vị trí đang bị chặn. Hãy kiểm tra cài đặt thiết bị; ứng dụng đã cài đặt có quyền vị trí riêng, tách biệt với trình duyệt.', 'map.location.unavailable': 'Không thể xác định vị trí của bạn.', 'map.location.timeout': 'Việc xác định vị trí mất quá nhiều thời gian. Hãy thử lại ở nơi nhìn thấy bầu trời thoáng hơn.', + 'map.overview.show': 'Hiện toàn bộ chuyến đi', + 'map.overview.hide': 'Ẩn toàn bộ chuyến đi', + 'map.overview.total': 'Tổng quãng đường', }; export default map; diff --git a/shared/src/i18n/zh-TW/map.ts b/shared/src/i18n/zh-TW/map.ts index d5d2c5ea3..d5446d5f6 100644 --- a/shared/src/i18n/zh-TW/map.ts +++ b/shared/src/i18n/zh-TW/map.ts @@ -22,5 +22,8 @@ const map: TranslationStrings = { 'map.location.denied': '定位權限已被封鎖。請檢查裝置設定,已安裝的應用程式擁有與瀏覽器分開的定位權限。', 'map.location.unavailable': '無法判斷你的位置。', 'map.location.timeout': '定位花費的時間過長。請在能看到天空的開闊位置重試。', + 'map.overview.show': '顯示整趟行程', + 'map.overview.hide': '隱藏整趟行程', + 'map.overview.total': '總距離', }; export default map; diff --git a/shared/src/i18n/zh/map.ts b/shared/src/i18n/zh/map.ts index ddc257a2e..32dbf24e4 100644 --- a/shared/src/i18n/zh/map.ts +++ b/shared/src/i18n/zh/map.ts @@ -22,5 +22,8 @@ const map: TranslationStrings = { 'map.location.denied': '定位权限已被阻止。请检查设备设置,已安装的应用拥有与浏览器分开的定位权限。', 'map.location.unavailable': '无法确定你的位置。', 'map.location.timeout': '定位耗时过长。请在能看到天空的开阔位置重试。', + 'map.overview.show': '显示整个行程', + 'map.overview.hide': '隐藏整个行程', + 'map.overview.total': '总距离', }; export default map; diff --git a/wiki/Map-Features.md b/wiki/Map-Features.md index fe70854d4..a839f31ba 100644 --- a/wiki/Map-Features.md +++ b/wiki/Map-Features.md @@ -36,6 +36,16 @@ A day's route is drawn as a solid blue line — a bright core over a darker casi A straight line is drawn immediately, then upgraded to real road geometry from a public OSRM router (or from a plugin route profile), each leg routed in the transport mode that leg carries. If routing fails, that leg stays a straight line between the two stops. +### The whole trip at once + +The **Show whole trip** button in the bottom-right corner of the map swaps the single day for every travel day of the trip, each drawn in its own colour over a white casing so neighbouring days stay apart on any basemap. It is on desktop and on the phone, and your choice is remembered per trip for the rest of the session. + +A card above the button lists the days: each one by its title, or by its number when it has none, with an icon per travel mode it is actually driven or walked in and the distance covered that day. The trip's **Total distance** sits at the top. Picking a day in the list selects it, the same as picking it anywhere else. + +The total is real routed distance summed over every leg — road geometry from the router, not straight lines between stops — which is what makes it worth building a fuel estimate on. It arrives a few legs at a time: while they are still coming in the total is followed by an ellipsis to say it is a partial sum, and it settles once every leg has answered. A leg the router refuses keeps its straight line on the map and contributes nothing to the total, so a trip with an unroutable hop reads low rather than wrong. Days with fewer than two located stops have no route and are left out of the list entirely. + +Road trip mode already draws the whole trip its own way, so the button is not offered while it is on. + ## GPX tracks Tracks and routes imported from a `.gpx`, `.kml`, or `.kmz` file are drawn as lines on the map. Each track imported into a trip is given its own colour automatically, so several walks in the same area stay distinguishable without any setup.