fix(tours): preserve map viewport during route updates

This commit is contained in:
elmocito
2026-10-06 00:44:43 +02:00
parent af7dbce105
commit 04c7491e2a
8 changed files with 146 additions and 6 deletions
@@ -441,6 +441,34 @@ describe('MapView', () => {
expect(mapMock.fitBounds).toHaveBeenCalled()
})
it('frames Tour focus once, ignores waypoint and route updates, then honors a new focus intent', () => {
const route: [number, number][][] = [[[48, 11], [48.2, 11.3]]]
const { rerender } = render(
<MapView places={[]} route={null} followSelection={false} focusKey={1} focusPoints={[]} />,
)
expect(mapMock.fitBounds).not.toHaveBeenCalled()
rerender(<MapView places={[]} route={route} followSelection={false} focusKey={1} focusPoints={[[48, 11], [48.2, 11.3]]} />)
expect(mapMock.fitBounds).toHaveBeenCalledOnce()
mapMock.fitBounds.mockClear()
rerender(
<MapView
places={[]}
route={[[[48, 11], [48.2, 11.3], [48.4, 11.6]]]}
followSelection={false}
focusKey={1}
focusPoints={[[48, 11], [48.2, 11.3], [48.4, 11.6]]}
/>,
)
expect(mapMock.fitBounds).not.toHaveBeenCalled()
rerender(
<MapView places={[]} followSelection={false} focusKey={2} focusPoints={[[48, 11], [48.5, 11.8]]} />,
)
expect(mapMock.fitBounds).toHaveBeenCalledOnce()
})
it('keeps selection fit and the pending route-arrival refit for semantic changes', () => {
const selectedPlace = buildMapPlace({ id: 901, lat: 48, lng: 11 })
const route: [number, number][][] = [[[48, 11], [48.2, 11.3]]]
+14 -3
View File
@@ -448,6 +448,8 @@ interface BoundsControllerProps {
* needs that leg on screen, which is neither the day nor the trip.
*/
focusPoints?: [number, number][]
/** Changes only when the caller intentionally wants a new initial frame. */
focusKey?: number
/** False while the map is locked (#2010): an arriving route no longer re-fits it. */
follow?: boolean
/**
@@ -470,11 +472,13 @@ function leafletPadding(box: ViewportPadding): L.FitBoundsOptions {
}
}
function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMount = false, focusPoints, fitPadding, follow = true }: BoundsControllerProps) {
function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMount = false, focusPoints, focusKey, fitPadding, follow = true }: BoundsControllerProps) {
const map = useMap()
const prevFitKey = useRef(-1)
const awaitingRoute = useRef(false)
const fitRan = useRef(false)
const didInitialFocus = useRef(false)
const prevFocusKey = useRef(focusKey)
const fitTo = useCallback((coords: [number, number][], padding: L.FitBoundsOptions = paddingOpts) => {
if (coords.length === 0) return
@@ -537,10 +541,16 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
// picker leaves the map where the user left it rather than snapping back.
useEffect(() => {
if (!focusPoints?.length) return
if (focusKey !== undefined) {
const shouldFit = !didInitialFocus.current || focusKey !== prevFocusKey.current
prevFocusKey.current = focusKey
if (!shouldFit) return
didInitialFocus.current = true
}
// A day fit that has not run yet must not overwrite this a moment later.
awaitingRoute.current = false
fitTo(focusPoints, fitPadding ? leafletPadding(fitPadding) : paddingOpts)
}, [focusPoints, fitPaddingKey]) // eslint-disable-line react-hooks/exhaustive-deps
}, [focusPoints, focusKey, fitPaddingKey]) // eslint-disable-line react-hooks/exhaustive-deps
return null
}
@@ -786,6 +796,7 @@ export const MapView = memo(function MapView({
// the shape a caller without one gets.
tileUrl = OFM_POSITRON,
fitKey = 0,
focusKey,
dayOrderMap = {},
leftWidth = 0,
rightWidth = 0,
@@ -1249,7 +1260,7 @@ export const MapView = memo(function MapView({
<MapController center={center} zoom={zoom} />
{hasViewBaseLayer && <MapCameraSnapshot onChange={saveCameraSnapshot} />}
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} focusPoints={focusPoints} fitPadding={fitPadding} follow={followSelection} />
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} focusPoints={focusPoints} focusKey={focusKey} fitPadding={fitPadding} follow={followSelection} />
<SelectionController follow={followSelection} places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} selectedPlace={selectedPlace} paddingOpts={paddingOpts} />
<MapClickHandler onClick={onMapClick} />
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
@@ -930,6 +930,61 @@ describe('MapViewGL', () => {
expect(glMap.easeTo).not.toHaveBeenCalled()
})
it('frames Tour focus once, ignores waypoint and route updates, then honors a new focus intent', async () => {
loadOnAttach()
const { rerender } = render(
<MapViewGL
places={[]}
route={null}
followSelection={false}
focusKey={1}
focusPoints={[]}
glProvider="maplibre-gl"
/>,
)
await act(async () => {})
expect(glMap.fitBounds).not.toHaveBeenCalled()
rerender(
<MapViewGL
places={[]}
route={[[[48, 11], [48.2, 11.3]]]}
followSelection={false}
focusKey={1}
focusPoints={[[48, 11], [48.2, 11.3]]}
glProvider="maplibre-gl"
/>,
)
await act(async () => {})
expect(glMap.fitBounds).toHaveBeenCalledOnce()
glMap.fitBounds.mockClear()
rerender(
<MapViewGL
places={[]}
route={[[[48, 11], [48.2, 11.3], [48.4, 11.6]]]}
followSelection={false}
focusKey={1}
focusPoints={[[48, 11], [48.2, 11.3], [48.4, 11.6]]}
glProvider="maplibre-gl"
/>,
)
await act(async () => {})
expect(glMap.fitBounds).not.toHaveBeenCalled()
rerender(
<MapViewGL
places={[]}
followSelection={false}
focusKey={2}
focusPoints={[[48, 11], [48.5, 11.8]]}
glProvider="maplibre-gl"
/>,
)
await act(async () => {})
expect(glMap.fitBounds).toHaveBeenCalledOnce()
})
it('keeps selection fit and the pending route-arrival refit for semantic changes', async () => {
loadOnAttach()
const selectedPlace = buildPlace({ id: 902, lat: 48, lng: 11 })
+12 -1
View File
@@ -216,6 +216,8 @@ interface Props {
* needs that leg on screen, which is neither the day nor the trip.
*/
focusPoints?: [number, number][]
/** Changes only when the caller intentionally wants a new initial frame. */
focusKey?: number
/**
* What the caller's own chrome covers while `focusPoints` is framed, in pixels per edge.
*
@@ -713,6 +715,7 @@ export function MapViewGL({
zoom = DEFAULT_MAP_ZOOM,
fitKey = 0,
focusPoints,
focusKey,
fitPadding,
clusterLoosely = false,
hazards,
@@ -2388,12 +2391,20 @@ export function MapViewGL({
// The caller's padding as a value, so a parent that builds the object inline on every
// render does not move the camera each time it renders.
const fitPaddingKey = fitPadding ? [fitPadding.top, fitPadding.right, fitPadding.bottom, fitPadding.left].join(' ') : ''
const didInitialFocusRef = useRef(false)
const prevFocusKeyRef = useRef(focusKey)
// Frame whatever was handed over. Nothing happens when it empties, so closing the
// picker leaves the map where the user left it rather than snapping back.
useEffect(() => {
const map = mapRef.current
if (!map || !focusPoints?.length) return
if (focusKey !== undefined) {
const shouldFit = !didInitialFocusRef.current || focusKey !== prevFocusKeyRef.current
prevFocusKeyRef.current = focusKey
if (!shouldFit) return
didInitialFocusRef.current = true
}
const bounds = new gl.LngLatBounds()
focusPoints.forEach(([lat, lng]) => bounds.extend([lng, lat]))
// A day fit still waiting on its route must not overwrite this a moment later.
@@ -2406,7 +2417,7 @@ export function MapViewGL({
duration: 400,
})
} catch { /* noop */ }
}, [focusPoints, fitPaddingKey]) // eslint-disable-line react-hooks/exhaustive-deps
}, [focusPoints, focusKey, fitPaddingKey]) // eslint-disable-line react-hooks/exhaustive-deps
// flyTo selected place
useEffect(() => {
@@ -81,6 +81,36 @@ describe('useTourPlanner', () => {
expect(result.current.waypoints).toHaveLength(3)
})
it('changes the map focus intent only for explicit Tour context changes', async () => {
detailTour.mockResolvedValue({
tour: savedTour,
waypoints: [
{ lat: 48, lng: 11, role: 'start', sequence: 0 },
{ lat: 48.01, lng: 11.02, role: 'end', sequence: 1 },
],
})
const { result } = renderHook(() => useTourPlanner({ tripId: 8 }))
const initialFocusKey = result.current.mapFocusKey
act(() => {
result.current.addWaypoint(48, 11)
result.current.addWaypoint(48.01, 11.02)
})
await act(async () => { await vi.advanceTimersByTimeAsync(450) })
expect(result.current.mapFocusKey).toBe(initialFocusKey)
act(() => result.current.retry())
expect(result.current.mapFocusKey).toBe(initialFocusKey)
await act(async () => { expect(await result.current.openTour(savedTour)).toBe(true) })
expect(result.current.mapFocusKey).toBe(initialFocusKey + 1)
act(() => result.current.addWaypoint(48.02, 11.03))
expect(result.current.mapFocusKey).toBe(initialFocusKey + 1)
act(() => result.current.viewGpxTour({ ...savedTour, has_waypoints: false }, '[[48,11],[48.01,11.02]]'))
expect(result.current.mapFocusKey).toBe(initialFocusKey + 2)
})
it('TOUR-PLANNER-002: restores a trip-scoped local draft without changing stable ids', () => {
localStorage.setItem('tour-draft-8', JSON.stringify({
version: 1,
@@ -136,6 +136,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
const [elevationProfileExpanded, setElevationProfileExpanded] = useState(true)
const [routeProfileFocus, setRouteProfileFocus] = useState<RouteProfileFocus | null>(null)
const [newDraftGeneration, setNewDraftGeneration] = useState(0)
const [mapFocusKey, setMapFocusKey] = useState(0)
const abortRef = useRef<AbortController | null>(null)
const generationRef = useRef(0)
const draftRevisionRef = useRef(0)
@@ -334,6 +335,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
setNewTourConfirmationOpen(false)
setElevationProfileExpanded(true)
setNewDraftGeneration(generation => generation + 1)
setMapFocusKey(key => key + 1)
}, [invalidateDetailRequest, interruptRouting, storageKey])
const startNewTour = useCallback((confirmed = false) => {
@@ -429,6 +431,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
setDraftRestored(false)
setEditingPlaceId(result.tour.place_id)
editingPlaceIdRef.current = result.tour.place_id
setMapFocusKey(key => key + 1)
setSaveOutcome(null)
setReadOnlyGpxTour(null)
return true
@@ -613,7 +616,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
mapBaseLayer, setMapBaseLayer,
mode, readOnlyGpxTour, readOnlyGpxAnalysis, viewGpxTour, closeGpxTour,
startNewTour, newTourConfirmationOpen, cancelNewTour,
elevationProfileExpanded, toggleElevationProfile, newDraftGeneration, draftRestored,
elevationProfileExpanded, toggleElevationProfile, newDraftGeneration, draftRestored, mapFocusKey,
editingPlaceId, openingTourId, openTour,
saveOutcome, hasUnsavedChanges, canSave, save, discard, returnToNeutral,
}
+2
View File
@@ -1082,6 +1082,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
tripId={tripId}
places={[]}
route={tourMap.route}
followSelection={false}
onMapClick={canPlaceEdit && !tourPlanner.isSaving && (tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved')
? ({ latlng }: { latlng: { lat: number; lng: number } }) => tourPlanner.addWaypoint(latlng.lat, latlng.lng)
: undefined}
@@ -1089,6 +1090,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
leftWidth={leftPanelPx}
rightWidth={rightPanelPx}
focusPoints={tourMap.focusPoints}
focusKey={tourMap.focusKey}
plannerWaypoints={tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved' ? tourPlanner.waypoints : []}
selectedPlannerWaypointId={tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved' ? tourPlanner.selectedWaypointId : null}
onPlannerWaypointClick={tourPlanner.setSelectedWaypointId}
@@ -101,6 +101,6 @@ export function useTripPlannerPage() {
permissions: { canPlaceEdit, canDayEdit },
tourPlanner,
tourDetails: { openerRef: tourDetailOpenerRef, onSelectTour },
tourMap: { route, focusPoints, captureViewport },
tourMap: { route, focusPoints, focusKey: tourPlanner.mapFocusKey, captureViewport },
}
}