From 5fd66f4833a0dbbfcd3c0feeba2ecb7bff9556bb Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 29 Jun 2026 23:36:13 +0200 Subject: [PATCH] feat(map): include the day's route in the map fit (#1128) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting a day already fits the map to that day's destinations; this also folds the route polyline into the bounds. BoundsController fits the destinations immediately, then re-fits once — when the day's route finishes computing asynchronously — to destinations + the full route, so a route that bulges past its stops (a detour or ferry) stays in view. One-shot per day selection, so later route-profile toggles don't re-zoom. --- client/src/components/Map/MapView.test.tsx | 18 ++++++++++ client/src/components/Map/MapView.tsx | 41 +++++++++++++++++----- 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/client/src/components/Map/MapView.test.tsx b/client/src/components/Map/MapView.test.tsx index ce27ae1c4..9149df22e 100644 --- a/client/src/components/Map/MapView.test.tsx +++ b/client/src/components/Map/MapView.test.tsx @@ -244,4 +244,22 @@ describe('MapView', () => { rerender() expect(mapMock.fitBounds.mock.calls.length).toBeGreaterThan(afterFirst) }) + + it('FE-COMP-MAPVIEW-020: a day fit expands to include the route once it arrives (#1128)', async () => { + const L = ((await import('leaflet')).default) as unknown as { latLngBounds: ReturnType } + const dayPlaces = [ + buildMapPlace({ id: 1, lat: 48.0, lng: 2.0 }), + buildMapPlace({ id: 2, lat: 48.1, lng: 2.1 }), + ] + // Day selected, route not computed yet → first fit is the two destinations. + const { rerender } = render() + const lastBounds = () => { const c = L.latLngBounds.mock.calls; return c[c.length - 1][0] } + expect(lastBounds()).toHaveLength(2) + + // The day's route arrives → one-shot re-fit including the 3 route points. + L.latLngBounds.mockClear() + rerender() + expect(L.latLngBounds).toHaveBeenCalled() + expect(lastBounds()).toHaveLength(5) // 2 destinations + 3 route points + }) }) diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 57d59202c..e473fc4f8 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -212,24 +212,27 @@ function MapController({ center, zoom }: MapControllerProps) { return null } -// Fit bounds when places change (fitKey triggers re-fit) +// Fit bounds when places change (fitKey triggers re-fit). On a day selection we +// fit to that day's destinations immediately, then — once the day's route has +// finished computing asynchronously — re-fit once more to include the full route +// polyline, so a route that bulges past its stops stays in view (#1128). interface BoundsControllerProps { hasDayDetail?: boolean places: Place[] + routeCoords: [number, number][] fitKey: number paddingOpts: L.FitBoundsOptions } -function BoundsController({ places, fitKey, paddingOpts, hasDayDetail }: BoundsControllerProps) { +function BoundsController({ places, routeCoords, fitKey, paddingOpts, hasDayDetail }: BoundsControllerProps) { const map = useMap() const prevFitKey = useRef(-1) + const awaitingRoute = useRef(false) - useEffect(() => { - if (fitKey === prevFitKey.current) return - prevFitKey.current = fitKey - if (places.length === 0) return + const fitTo = useCallback((coords: [number, number][]) => { + if (coords.length === 0) return try { - const bounds = L.latLngBounds(places.map(p => [p.lat, p.lng])) + const bounds = L.latLngBounds(coords) if (bounds.isValid()) { map.fitBounds(bounds, { ...paddingOpts, maxZoom: 16, animate: true }) if (hasDayDetail) { @@ -237,8 +240,27 @@ function BoundsController({ places, fitKey, paddingOpts, hasDayDetail }: BoundsC } } } catch {} + }, [map, paddingOpts, hasDayDetail]) + + // New fitKey (initial trip fit or a day selection): fit to the destinations now + // and arm a one-shot re-fit for when the route arrives. + useEffect(() => { + if (fitKey === prevFitKey.current) return + prevFitKey.current = fitKey + awaitingRoute.current = false + if (places.length === 0) return + fitTo(places.map(p => [p.lat, p.lng] as [number, number])) + awaitingRoute.current = true }, [fitKey]) // eslint-disable-line react-hooks/exhaustive-deps + // Once the just-selected day's route is ready, expand the fit to include it. + // One-shot per day-fit, so later route-profile toggles don't re-zoom the map. + useEffect(() => { + if (!awaitingRoute.current || routeCoords.length === 0) return + awaitingRoute.current = false + fitTo([...places.map(p => [p.lat, p.lng] as [number, number]), ...routeCoords]) + }, [routeCoords]) // eslint-disable-line react-hooks/exhaustive-deps + return null } @@ -463,6 +485,9 @@ export const MapView = memo(function MapView({ const thumbRafRef = useRef(null) const placeIds = useMemo(() => places.map(p => p.id).join(','), [places]) + // Flattened [lat,lng] points of the selected day's route, so the bounds fit can + // include the full polyline once it has been computed. + const routeCoords = useMemo<[number, number][]>(() => (route || []).flat() as [number, number][], [route]) useEffect(() => { if (!places || places.length === 0 || !placesPhotosEnabled) return const cleanups: (() => void)[] = [] @@ -597,7 +622,7 @@ export const MapView = memo(function MapView({ /> - 0 ? dayPlaces : places} fitKey={fitKey} paddingOpts={paddingOpts} hasDayDetail={hasDayDetail} /> + 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} hasDayDetail={hasDayDetail} />