mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
fix(tours): preserve map viewport during route updates
This commit is contained in:
@@ -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]]]
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user