UNPKG

maplibre-gl

Version:

BSD licensed community fork of mapbox-gl, a WebGL interactive maps library

534 lines (463 loc) 21.1 kB
import {afterEach, beforeEach, describe, expect, vi, test} from 'vitest'; import Point from '@mapbox/point-geometry'; import type {HandlerManager, MapControlsScenarioOptions, EventInProgress, EventsInProgress} from './handler_manager.ts'; import type {Map} from './map.ts'; import {LngLat} from '../geo/lng_lat.ts'; import type {ICameraHelper, MapControlsDeltas} from '../geo/projection/camera_helper.ts'; import type {Terrain} from '../render/terrain.ts'; import type {ITransform} from '../geo/transform_interface.ts'; import {Event as MapEvent} from '../util/evented.ts'; import {MercatorCoordinate} from '../geo/mercator_coordinate.ts'; import {beforeMapTest, createMap, createTerrain} from '../util/test/util.ts'; import simulate from '../../test/unit/lib/simulate_interaction.ts'; let map: Map; let manager: HandlerManager; beforeEach(() => { beforeMapTest(); }); afterEach(() => { map.remove(); vi.restoreAllMocks(); }); describe('HandlerManager terrain scenarios', () => { beforeEach(() => { map = createMap(); manager = map._handlers; }); test('_handleMapControls keeps terrain movement disabled when terrain is not enabled', () => { const handleZoom = vi.fn(); const handlePan = vi.fn(); map._camera.cameraHelper = { handleMapControlsRollPitchBearingZoom: handleZoom, handleMapControlsPan: handlePan, useGlobeControls: false, } as unknown as ICameraHelper; const setCenterMock = vi.fn(); const transform = { centerPoint: new Point(0, 0), center: new LngLat(0, 0), screenPointToLocation: vi.fn(() => new LngLat(1, 1)), setCenter: setCenterMock, } satisfies Pick<ITransform, 'centerPoint' | 'center' | 'screenPointToLocation' | 'setCenter'>; const deltas: MapControlsDeltas = { panDelta: new Point(0, 0), zoomDelta: 0, rollDelta: 0, pitchDelta: 0, bearingDelta: 0, around: new Point(0, 0), }; const combinedEvents: EventsInProgress = { drag: createEventInProgress('drag'), }; const options: MapControlsScenarioOptions = { terrain: null, tr: transform as unknown as ITransform, deltasForHelper: deltas, preZoomAroundLoc: new LngLat(0, 0), combinedEventsInProgress: combinedEvents, panDelta: deltas.panDelta, }; manager._terrainMovement = false; map._camera.elevationFreeze = false; manager._handleMapControls(options); expect(handleZoom).toHaveBeenCalledWith(options.deltasForHelper, options.tr); expect(handlePan).toHaveBeenCalledWith(options.deltasForHelper, options.tr, options.preZoomAroundLoc); expect(map._camera.elevationFreeze).toBe(false); expect(manager._terrainMovement).toBe(false); expect(setCenterMock).not.toHaveBeenCalled(); }); test('_handleMapControls enables terrain movement for globe terrain handling', () => { const handleZoom = vi.fn(); const handlePan = vi.fn(); map._camera.cameraHelper = { handleMapControlsRollPitchBearingZoom: handleZoom, handleMapControlsPan: handlePan, useGlobeControls: true, } as unknown as ICameraHelper; const transform = { centerPoint: new Point(0, 0), center: new LngLat(0, 0), screenPointToLocation: vi.fn(() => new LngLat(0, 0)), setCenter: vi.fn(), } satisfies Pick<ITransform, 'centerPoint' | 'center' | 'screenPointToLocation' | 'setCenter'>; const options: MapControlsScenarioOptions = { terrain: {} as Terrain, tr: transform as unknown as ITransform, deltasForHelper: { panDelta: new Point(1, 1), zoomDelta: 0, rollDelta: 0, pitchDelta: 0, bearingDelta: 0, around: new Point(0, 0), }, preZoomAroundLoc: new LngLat(0, 0), combinedEventsInProgress: {drag: createEventInProgress('drag')}, panDelta: new Point(1, 1), }; manager._terrainMovement = false; map._camera.elevationFreeze = false; manager._handleMapControls(options); expect(manager._terrainMovement).toBe(true); expect(map._camera.elevationFreeze).toBe(true); expect(handlePan).toHaveBeenCalledWith(options.deltasForHelper, options.tr, options.preZoomAroundLoc); }); test('_handleMapControls keeps terrain movement state when globe terrain is already active', () => { const handleZoom = vi.fn(); const handlePan = vi.fn(); map._camera.cameraHelper = { handleMapControlsRollPitchBearingZoom: handleZoom, handleMapControlsPan: handlePan, useGlobeControls: true, } as unknown as ICameraHelper; const transform = { centerPoint: new Point(0, 0), center: new LngLat(0, 0), screenPointToLocation: vi.fn(() => new LngLat(0, 0)), setCenter: vi.fn(), } satisfies Pick<ITransform, 'centerPoint' | 'center' | 'screenPointToLocation' | 'setCenter'>; const options: MapControlsScenarioOptions = { terrain: {} as Terrain, tr: transform as unknown as ITransform, deltasForHelper: { panDelta: new Point(0, 0), zoomDelta: 0, rollDelta: 0, pitchDelta: 0, bearingDelta: 0, around: new Point(0, 0), }, preZoomAroundLoc: new LngLat(0, 0), combinedEventsInProgress: {}, panDelta: undefined, }; manager._terrainMovement = true; map._camera.elevationFreeze = true; manager._handleMapControls(options); expect(manager._terrainMovement).toBe(true); expect(map._camera.elevationFreeze).toBe(true); expect(handlePan).toHaveBeenCalledWith(options.deltasForHelper, options.tr, options.preZoomAroundLoc); }); test('_handleMapControls activates terrain movement on first drag in mercator terrain', () => { const handleZoom = vi.fn(); const handlePan = vi.fn(); map._camera.cameraHelper = { handleMapControlsRollPitchBearingZoom: handleZoom, handleMapControlsPan: handlePan, useGlobeControls: false, } as unknown as ICameraHelper; const setCenterMock = vi.fn(); const transform = { centerPoint: new Point(0, 0), center: new LngLat(0, 0), screenPointToLocation: vi.fn(() => new LngLat(5, 5)), setCenter: setCenterMock, } satisfies Pick<ITransform, 'centerPoint' | 'center' | 'screenPointToLocation' | 'setCenter'>; const deltas: MapControlsDeltas = { panDelta: new Point(2, 3), zoomDelta: 0, rollDelta: 0, pitchDelta: 0, bearingDelta: 0, around: new Point(0, 0), }; const options: MapControlsScenarioOptions = { terrain: {} as Terrain, tr: transform as unknown as ITransform, deltasForHelper: deltas, preZoomAroundLoc: new LngLat(0, 0), combinedEventsInProgress: {drag: createEventInProgress('drag')}, panDelta: deltas.panDelta, }; manager._terrainMovement = false; map._camera.elevationFreeze = false; manager._handleMapControls(options); expect(manager._terrainMovement).toBe(true); expect(map._camera.elevationFreeze).toBe(true); expect(handlePan).toHaveBeenCalledTimes(1); expect(setCenterMock).not.toHaveBeenCalled(); }); test('_handleMapControls drags using transform when already moving in mercator terrain', () => { const handleZoom = vi.fn(); const handlePan = vi.fn(); map._camera.cameraHelper = { handleMapControlsRollPitchBearingZoom: handleZoom, handleMapControlsPan: handlePan, useGlobeControls: false, } as unknown as ICameraHelper; const setCenterMock = vi.fn(); const screenPointToLocation = vi.fn(() => new LngLat(7, 8)); const transform = { centerPoint: new Point(10, 12), center: new LngLat(0, 0), screenPointToLocation, setCenter: setCenterMock, } satisfies Pick<ITransform, 'centerPoint' | 'center' | 'screenPointToLocation' | 'setCenter'>; const options: MapControlsScenarioOptions = { terrain: {} as Terrain, tr: transform as unknown as ITransform, deltasForHelper: { panDelta: new Point(4, 6), zoomDelta: 0, rollDelta: 0, pitchDelta: 0, bearingDelta: 0, around: new Point(0, 0), }, preZoomAroundLoc: new LngLat(0, 0), combinedEventsInProgress: {drag: createEventInProgress('drag')}, panDelta: new Point(4, 6), }; manager._terrainMovement = true; map._camera.elevationFreeze = true; manager._handleMapControls(options); expect(screenPointToLocation).toHaveBeenCalled(); expect(setCenterMock).toHaveBeenCalledTimes(1); const centerArg = setCenterMock.mock.calls[0][0] as LngLat; expect(centerArg.lng).toBeCloseTo(7); expect(centerArg.lat).toBeCloseTo(8); expect(handlePan).not.toHaveBeenCalled(); }); test('_handleMapControls falls back to helper panning when not dragging in mercator terrain', () => { const handleZoom = vi.fn(); const handlePan = vi.fn(); map._camera.cameraHelper = { handleMapControlsRollPitchBearingZoom: handleZoom, handleMapControlsPan: handlePan, useGlobeControls: false, } as unknown as ICameraHelper; const transform = { centerPoint: new Point(0, 0), center: new LngLat(0, 0), screenPointToLocation: vi.fn(() => new LngLat(0, 0)), setCenter: vi.fn(), } satisfies Pick<ITransform, 'centerPoint' | 'center' | 'screenPointToLocation' | 'setCenter'>; const options: MapControlsScenarioOptions = { terrain: {} as Terrain, tr: transform as unknown as ITransform, deltasForHelper: { panDelta: new Point(0, 0), zoomDelta: 0, rollDelta: 0, pitchDelta: 0, bearingDelta: 0, around: new Point(0, 0), }, preZoomAroundLoc: new LngLat(0, 0), combinedEventsInProgress: {}, panDelta: undefined, }; manager._terrainMovement = true; map._camera.elevationFreeze = true; manager._handleMapControls(options); expect(handlePan).toHaveBeenCalledWith(options.deltasForHelper, options.tr, options.preZoomAroundLoc); }); }); function createEventInProgress(name: keyof EventsInProgress): EventInProgress { return { handlerName: name, originalEvent: new MapEvent(`${String(name)}start`), }; } describe('terrain gesture anchoring', () => { async function setupGestureMap(pitch: number = 0): Promise<HTMLElement> { map = createMap({interactive: true, zoom: 11, center: [7.5, 45.9], pitch, bearing: 0}); map.touchZoomRotate.disableRotation(); map._handlers._handlersById.tapZoom.disable(); map.touchPitch.disable(); await map.once('style.load'); return map.getCanvas(); } function gestureStep(kind: 'touchstart' | 'touchmove', target: HTMLElement, positions: Point[]): void { const touches = positions.map((p, i) => ({target, identifier: i + 1, clientX: p.x, clientY: p.y})); simulate[kind](target, {touches}); map._renderTaskQueue.run(); } function endGesture(target: HTMLElement): void { simulate.touchend(target, {touches: []}); map._renderTaskQueue.run(); map._renderTaskQueue.run(); } function pinchTouches(mid: Point, halfSpread: number): Point[] { return [ new Point(mid.x, mid.y - halfSpread), new Point(mid.x, mid.y + halfSpread), ]; } function slipOf(anchor: LngLat, expectedAt: Point): number { return map.project(anchor).dist(expectedAt); } test('moving-centroid pinch with terrain keeps the grabbed terrain point under the fingers', async () => { const target = await setupGestureMap(); const anchor = new LngLat(7.49, 45.905); const anchorCoordinate = MercatorCoordinate.fromLngLat(anchor); let raycastElevation = 1000; map.terrain = { ...createTerrain(), pointCoordinate: () => new MercatorCoordinate(anchorCoordinate.x, anchorCoordinate.y, raycastElevation), } as any as Terrain; // Start the pinch with the finger midpoint exactly where the grabbed point renders const start = map.project(anchor); expect(start.x).toBeGreaterThan(20); expect(start.x).toBeLessThan(180); expect(start.y).toBeGreaterThan(20); expect(start.y).toBeLessThan(180); gestureStep('touchstart', target, pinchTouches(start, 30)); let mid = start; let halfSpread = 30; for (let step = 0; step < 3; step++) { mid = mid.add(new Point(15, 10)); halfSpread += 10; gestureStep('touchmove', target, pinchTouches(mid, halfSpread)); // terrain "loading" mid-gesture must not move the anchor plane raycastElevation = 4000; } expect(map.getZoom()).toBeGreaterThan(11.5); expect(map._handlers._terrainGestureAnchorElevation).toBe(1000); const slip = slipOf(anchor, mid); endGesture(target); expect(slip).toBeLessThan(0.5); expect(map._handlers._terrainGestureAnchorElevation).toBeNull(); }); test('pitched moving-centroid pinch with terrain keeps the grabbed terrain point under the fingers', async () => { const target = await setupGestureMap(60); const anchor = new LngLat(7.494, 45.904); const anchorCoordinate = MercatorCoordinate.fromLngLat(anchor); map.terrain = { ...createTerrain(), pointCoordinate: () => new MercatorCoordinate(anchorCoordinate.x, anchorCoordinate.y, 1000), } as any as Terrain; const start = map.project(anchor); expect(start.x).toBeGreaterThan(20); expect(start.x).toBeLessThan(180); expect(start.y).toBeGreaterThan(20); expect(start.y).toBeLessThan(180); gestureStep('touchstart', target, pinchTouches(start, 25)); let mid = start; let halfSpread = 25; for (let step = 0; step < 3; step++) { mid = mid.add(new Point(12, 8)); halfSpread += 8; gestureStep('touchmove', target, pinchTouches(mid, halfSpread)); } expect(map.getZoom()).toBeGreaterThan(11.5); const slip = slipOf(anchor, mid); endGesture(target); expect(slip).toBeLessThan(0.5); }); test('touch drag with terrain keeps a grabbed point elevated above the center plane under the finger', async () => { const target = await setupGestureMap(); // a 2000 m ridge east of the center, with the center itself at 300 m const ridgeStartLng = map.getCenter().lng + 0.003; const elevationAt = (lngLat: LngLat) => lngLat.lng > ridgeStartLng ? 2000 : 300; const anchor = new LngLat(map.getCenter().lng + 0.012, map.getCenter().lat); const anchorCoordinate = MercatorCoordinate.fromLngLat(anchor); map.terrain = { ...createTerrain(), getElevationForLngLat: (lngLat: LngLat) => elevationAt(lngLat), getElevationForLngLatZoom: (lngLat: LngLat) => elevationAt(lngLat), pointCoordinate: () => new MercatorCoordinate(anchorCoordinate.x, anchorCoordinate.y, 2000), } as any as Terrain; map._camera.transform.setElevation(300); const start = map.project(anchor); expect(start.x).toBeGreaterThan(20); expect(start.x).toBeLessThan(180); gestureStep('touchstart', target, [start]); let finger = start; for (let step = 0; step < 3; step++) { finger = finger.add(new Point(-20, 0)); gestureStep('touchmove', target, [finger]); } expect(map.getCenter().lng).not.toBeCloseTo(7.5, 5); const slip = slipOf(anchor, finger); endGesture(target); expect(slip).toBeLessThan(0.5); }); test('center-anchored pinch over terrain still pans with the centroid', async () => { const target = await setupGestureMap(); map.touchZoomRotate.enable({around: 'center'}); const anchor = new LngLat(7.49, 45.905); const anchorCoordinate = MercatorCoordinate.fromLngLat(anchor); map.terrain = { ...createTerrain(), pointCoordinate: () => new MercatorCoordinate(anchorCoordinate.x, anchorCoordinate.y, 1000), } as any as Terrain; const startCenter = map.getCenter(); const start = new Point(80, 90); gestureStep('touchstart', target, pinchTouches(start, 30)); let mid = start; let halfSpread = 30; for (let step = 0; step < 3; step++) { mid = mid.add(new Point(15, 10)); halfSpread += 10; gestureStep('touchmove', target, pinchTouches(mid, halfSpread)); } expect(map.getZoom()).toBeGreaterThan(11.5); // center-anchored zoom keeps the center fixed, so only the pan moves it const centerMovedPx = map.project(startCenter).dist(new Point(100, 100)); endGesture(target); expect(centerMovedPx).toBeGreaterThan(20); }); test('falls back to the center-elevation behavior when the terrain under the gesture is not loaded', async () => { const target = await setupGestureMap(); map.terrain = { ...createTerrain(), pointCoordinate: () => null, } as any as Terrain; const startCenter = map.getCenter(); const start = new Point(80, 90); gestureStep('touchstart', target, pinchTouches(start, 30)); let mid = start; let halfSpread = 30; for (let step = 0; step < 3; step++) { mid = mid.add(new Point(15, 10)); halfSpread += 10; gestureStep('touchmove', target, pinchTouches(mid, halfSpread)); } // the gesture still zooms and pans sanely near where it started expect(map.getZoom()).toBeGreaterThan(11.5); expect(Math.abs(map.getCenter().lng - startCenter.lng)).toBeLessThan(0.5); expect(Math.abs(map.getCenter().lat - startCenter.lat)).toBeLessThan(0.5); endGesture(target); }); test('falls back to the center-elevation behavior when the grabbed terrain point is above the camera altitude', async () => { const target = await setupGestureMap(); map.terrain = { ...createTerrain(), pointCoordinate: () => new MercatorCoordinate(0.5, 0.35, 1e6), } as any as Terrain; const startCenter = map.getCenter(); const start = new Point(80, 90); gestureStep('touchstart', target, pinchTouches(start, 30)); let mid = start; let halfSpread = 30; for (let step = 0; step < 3; step++) { mid = mid.add(new Point(15, 10)); halfSpread += 10; gestureStep('touchmove', target, pinchTouches(mid, halfSpread)); } // the gesture still zooms and pans sanely near where it started expect(map.getZoom()).toBeGreaterThan(11.5); expect(Math.abs(map.getCenter().lng - startCenter.lng)).toBeLessThan(0.5); expect(Math.abs(map.getCenter().lat - startCenter.lat)).toBeLessThan(0.5); endGesture(target); }); test('control: the same moving-centroid pinch without terrain keeps the grabbed point under the fingers', async () => { const target = await setupGestureMap(); const start = new Point(67, 76); const anchor = map.unproject(start); gestureStep('touchstart', target, pinchTouches(start, 30)); let mid = start; let halfSpread = 30; for (let step = 0; step < 3; step++) { mid = mid.add(new Point(15, 10)); halfSpread += 10; gestureStep('touchmove', target, pinchTouches(mid, halfSpread)); } expect(map.getZoom()).toBeGreaterThan(11.5); const slip = slipOf(anchor, mid); endGesture(target); expect(slip).toBeLessThan(0.5); }); });