From e42622d71d4644462a1e5f86abed2ffb72b656d1 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 20 Oct 2026 20:48:07 +0200 Subject: [PATCH] feat(tours): let the user zoom during the 3D flyover The chase camera set a fixed zoom and pitch on every frame, so wheel, pinch and the +/- keys were undone at once. It now keeps the zoom and tilt of the user's latest gesture and only follows position and bearing. While a zoom is still easing the flyover holds for those frames, since any camera move would cancel the gesture. Wheel and pinch zoom around the rider while flying, and drag panning is off until the flyover stops. Starting over returns to the flyover's own zoom. --- .../Tours/terrain/TourTerrainMap.test.tsx | 139 +++++++++++++++++- .../Tours/terrain/useTerrainReplay.ts | 61 +++++++- 2 files changed, 194 insertions(+), 6 deletions(-) diff --git a/client/src/components/Tours/terrain/TourTerrainMap.test.tsx b/client/src/components/Tours/terrain/TourTerrainMap.test.tsx index 77315a1e5..798792ade 100644 --- a/client/src/components/Tours/terrain/TourTerrainMap.test.tsx +++ b/client/src/components/Tours/terrain/TourTerrainMap.test.tsx @@ -1,4 +1,4 @@ -// FE-COMP-TERRAIN-001 to FE-COMP-TERRAIN-015 +// FE-COMP-TERRAIN-001 to FE-COMP-TERRAIN-018 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { act, fireEvent, render, screen, waitFor } from '../../../../tests/helpers/render'; import type { DistanceIndexedProfileSample, RouteProfileFocus } from '../../../utils/routeGeometry'; @@ -23,6 +23,21 @@ const gl = vi.hoisted(() => { const sources = new Map>(); const markers: MarkerStub[] = []; const container = document.createElement('div'); + /** A gesture handler: on or off, and whether wheel or pinch zoom around the centre. */ + const handler = () => { + const state = { enabled: true, around: undefined as string | undefined }; + return { + state, + isEnabled: vi.fn(() => state.enabled), + enable: vi.fn((options?: { around?: string }) => { + state.enabled = true; + state.around = options?.around; + }), + disable: vi.fn(() => { + state.enabled = false; + }), + }; + }; const listen = (type: string, fn: Handler) => listeners.set(type, [...(listeners.get(type) ?? []), fn]); const map = { on: vi.fn((type: string, fn: Handler) => { @@ -51,6 +66,12 @@ const gl = vi.hoisted(() => { remove: vi.fn(), fitBounds: vi.fn(), jumpTo: vi.fn(), + isMoving: vi.fn(() => false), + getZoom: vi.fn(() => 13.4), + getPitch: vi.fn(() => 52), + dragPan: handler(), + scrollZoom: handler(), + touchZoomRotate: handler(), setStyle: vi.fn(), setTerrain: vi.fn(), setSky: vi.fn(), @@ -90,6 +111,13 @@ const gl = vi.hoisted(() => { }), }; const reset = () => { + for (const gesture of [map.dragPan, map.scrollZoom, map.touchZoomRotate]) { + gesture.state.enabled = true; + gesture.state.around = undefined; + } + map.isMoving.mockImplementation(() => false); + map.getZoom.mockImplementation(() => 13.4); + map.getPitch.mockImplementation(() => 52); listeners.clear(); sources.clear(); markers.length = 0; @@ -144,6 +172,49 @@ function loadStyle() { let openTiles: () => void = () => {}; +/** Holds the animation frames back so a test runs them one by one, 100 ms apart. */ +function frameClock() { + const frames = new Map(); + let nextId = 0; + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + nextId += 1; + frames.set(nextId, callback); + return nextId; + }); + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation((id) => { + frames.delete(id); + }); + // Starts at the first step, so no frame runs before the flyover's own start time. + let now: number | null = null; + return (count: number) => { + for (let i = 0; i < count && frames.size; i++) { + const [id, frame] = frames.entries().next().value as [number, FrameRequestCallback]; + frames.delete(id); + const at = (now ?? performance.now()) + 100; + now = at; + act(() => frame(at)); + } + }; +} + +type CameraCall = { zoom: number; pitch: number; center: [number, number] }; +const lastCamera = () => gl.map.jumpTo.mock.calls[gl.map.jumpTo.mock.calls.length - 1]?.[0] as CameraCall | undefined; + +/** 30 km by its profile, so the flyover is still under way after a few frames; drawn as short as TOUR. */ +const LONG_TOUR: DistanceIndexedProfileSample[] = [0, 1, 2, 3].map((i) => ({ + distanceMeters: i * 10000, + elevationMeters: 1000 + i * 20, + lat: 46 + i * 0.0009, + lng: 7, +})); + +async function startFlyover(overrides: Partial = {}) { + await renderTerrain({ samples: LONG_TOUR, ...overrides }); + loadStyle(); + await act(async () => openTiles()); + fireEvent.click(await screen.findByRole('button', { name: 'Play flyover' })); +} + beforeEach(() => { gl.reset(); vi.clearAllMocks(); @@ -426,4 +497,70 @@ describe('TourTerrainMap', () => { expect(screen.getByRole('button', { name: 'Set points' })).toHaveAttribute('aria-pressed', 'true'); expect(screen.getByText(/Click the terrain to set points/)).toBeInTheDocument(); }); + it('FE-COMP-TERRAIN-016: a zoom or tilt by the user stays while the flyover keeps following the route', async () => { + const step = frameClock(); + const onFocusChange = vi.fn(); + await startFlyover({ onFocusChange }); + step(2); + expect(lastCamera()).toEqual(expect.objectContaining({ zoom: 13.4, pitch: 52 })); + + gl.map.getZoom.mockReturnValue(15.2); + gl.map.getPitch.mockReturnValue(38); + act(() => gl.fire('zoom', { originalEvent: new Event('wheel') })); + act(() => gl.fire('pitch', { originalEvent: new Event('mousemove') })); + const before = lastCamera()?.center[1] ?? 0; + step(3); + expect(lastCamera()).toEqual(expect.objectContaining({ zoom: 15.2, pitch: 38 })); + expect(lastCamera()?.center[1]).toBeGreaterThan(before); + expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument(); + + // The chase camera's own moves carry no DOM event and never count as the user's zoom. + gl.map.getZoom.mockReturnValue(9); + act(() => gl.fire('zoom', {})); + step(2); + expect(lastCamera()).toEqual(expect.objectContaining({ zoom: 15.2, pitch: 38 })); + }); + + it('FE-COMP-TERRAIN-017: holds the camera while a zoom is under way, and turns drag panning off while flying', async () => { + const step = frameClock(); + const onFocusChange = vi.fn(); + await startFlyover({ onFocusChange }); + expect(gl.map.dragPan.state.enabled).toBe(false); + expect(gl.map.scrollZoom.state.around).toBe('center'); + expect(gl.map.touchZoomRotate.state.around).toBe('center'); + step(2); + + gl.map.isMoving.mockReturnValue(true); + gl.map.jumpTo.mockClear(); + const focusCalls = onFocusChange.mock.calls.length; + step(3); + expect(gl.map.jumpTo).not.toHaveBeenCalled(); + expect(onFocusChange.mock.calls.length).toBe(focusCalls); + expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument(); + + gl.map.isMoving.mockReturnValue(false); + step(1); + expect(gl.map.jumpTo).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole('button', { name: 'Pause' })); + expect(gl.map.dragPan.state.enabled).toBe(true); + expect(gl.map.scrollZoom.state.around).toBeUndefined(); + expect(gl.map.touchZoomRotate.state.around).toBeUndefined(); + }); + + it('FE-COMP-TERRAIN-018: a zoom kept through a pause, and starting over flies at the flyover zoom again', async () => { + const step = frameClock(); + await startFlyover(); + step(1); + gl.map.getZoom.mockReturnValue(11); + act(() => gl.fire('zoom', { originalEvent: new Event('wheel') })); + fireEvent.click(screen.getByRole('button', { name: 'Pause' })); + fireEvent.click(screen.getByRole('button', { name: 'Play flyover' })); + step(1); + expect(lastCamera()).toEqual(expect.objectContaining({ zoom: 11 })); + + fireEvent.click(screen.getByRole('button', { name: 'Start over' })); + step(1); + expect(lastCamera()).toEqual(expect.objectContaining({ zoom: 13.4, pitch: 52 })); + }); }); diff --git a/client/src/components/Tours/terrain/useTerrainReplay.ts b/client/src/components/Tours/terrain/useTerrainReplay.ts index 90a94d21a..41cb650b6 100644 --- a/client/src/components/Tours/terrain/useTerrainReplay.ts +++ b/client/src/components/Tours/terrain/useTerrainReplay.ts @@ -27,12 +27,40 @@ interface Options { preloaded: number; } +/** How close the chase camera sits: the flyover's own until the user zooms or tilts it. */ +interface ChaseLens { + zoom: number; + pitch: number; +} + +const flyoverLens = (): ChaseLens => ({ zoom: REPLAY_ZOOM, pitch: REPLAY_PITCH }); + +/** + * While the flyover runs, wheel and pinch zoom around the rider instead of the cursor, so the + * camera never jumps back to the line after a zoom. Dragging would only pan away from a camera + * that pulls straight back, so it is off until the flyover stops. Returns the undo. + */ +function followGestures(map: MapLibreMap): () => void { + const pan = map.dragPan.isEnabled(); + const wheel = map.scrollZoom.isEnabled(); + const pinch = map.touchZoomRotate.isEnabled(); + if (pan) map.dragPan.disable(); + if (wheel) map.scrollZoom.enable({ around: 'center' }); + if (pinch) map.touchZoomRotate.enable({ around: 'center' }); + return () => { + if (pan) map.dragPan.enable(); + if (wheel) map.scrollZoom.enable(); + if (pinch) map.touchZoomRotate.enable(); + }; +} + /** Moves the chase camera to `distance` along the route and returns where that is, if anywhere. */ function chase( map: MapLibreMap, samples: DistanceIndexedProfileSample[], distance: number, bearing: RefObject, + lens: ChaseLens, scene: TerrainScene ): RouteProfileFocus | null { const here = focusRouteProfileAtDistance(samples, distance); @@ -44,8 +72,8 @@ function chase( center: [here.lng, here.lat], elevation: (here.elevationMeters ?? 0) * scene.exaggeration, bearing: bearing.current, - pitch: REPLAY_PITCH, - zoom: REPLAY_ZOOM, + pitch: lens.pitch, + zoom: lens.zoom, padding: { top: 0, bottom: 0, ...sidePadding(scene.panels, 0) }, }); return here; @@ -57,6 +85,7 @@ export function useTerrainReplay({ gl, ready, samples, focus, onFocusChange, sce const frameRef = useRef(null); const distanceRef = useRef(0); const bearingRef = useRef(0); + const lensRef = useRef(flyoverLens()); const [playing, setPlaying] = useState(false); const [speed, setSpeed] = useState<1 | 2 | 4>(1); const totalMeters = samples.length ? samples[samples.length - 1].distanceMeters : 0; @@ -86,18 +115,33 @@ export function useTerrainReplay({ gl, ready, samples, focus, onFocusChange, sce useEffect(() => { if (!gl || !playing || !ready || totalMeters <= 0) return; + const { map } = gl; + // A zoom or tilt from the user (wheel, pinch, the +/- keys) carries its DOM event; + // the chase camera's own jumps do not. The flyover keeps what the user picked. + const keepLens = (event: { originalEvent?: unknown }) => { + if (event.originalEvent) lensRef.current = { zoom: map.getZoom(), pitch: map.getPitch() }; + }; + map.on('zoom', keepLens); + map.on('pitch', keepLens); + const restoreGestures = followGestures(map); let last = performance.now(); let lastFocus = 0; const tick = (now: number) => { const dt = Math.min(0.1, (now - last) / 1000); last = now; + // Every camera move ends a running gesture, so the flyover holds still while the + // user's zoom is under way and carries on from it once it has settled. + if (map.isMoving()) { + frameRef.current = requestAnimationFrame(tick); + return; + } distanceRef.current = Math.min( totalMeters, distanceRef.current + replayMetresPerSecond(totalMeters) * speed * dt ); - const here = chase(gl.map, samples, distanceRef.current, bearingRef, scene.current); + const here = chase(map, samples, distanceRef.current, bearingRef, lensRef.current, scene.current); if (here) { - riderRef.current?.setLngLat([here.lng, here.lat]).addTo(gl.map); + riderRef.current?.setLngLat([here.lng, here.lat]).addTo(map); if (now - lastFocus >= FOCUS_INTERVAL_MS || distanceRef.current >= totalMeters) { lastFocus = now; onFocusChange(here); @@ -112,18 +156,25 @@ export function useTerrainReplay({ gl, ready, samples, focus, onFocusChange, sce frameRef.current = requestAnimationFrame(tick); return () => { if (frameRef.current !== null) cancelAnimationFrame(frameRef.current); + map.off('zoom', keepLens); + map.off('pitch', keepLens); + restoreGestures(); }; }, [gl, playing, ready, samples, speed, totalMeters, onFocusChange, scene]); const togglePlay = useCallback(() => { if (!playing && preloaded < 1) return; - if (!playing && distanceRef.current >= totalMeters) distanceRef.current = 0; + if (!playing && distanceRef.current >= totalMeters) { + distanceRef.current = 0; + lensRef.current = flyoverLens(); + } setPlaying((value) => !value); }, [playing, preloaded, totalMeters]); const restart = useCallback(() => { if (preloaded < 1) return; distanceRef.current = 0; + lensRef.current = flyoverLens(); setPlaying(true); }, [preloaded]);