From 7e38a83d9324fb9443f5a04e6d2fc9a1fef9c17c Mon Sep 17 00:00:00 2001 From: lvisei Date: Wed, 5 Jun 2024 20:46:01 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=8D=87=E7=BA=A7=E6=96=B0?= =?UTF-8?q?=E7=89=88=E4=B8=80=E6=96=B9=E5=9C=B0=E5=9B=BE=E4=BA=A4=E4=BA=92?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E6=9C=BA=E5=88=B6=EF=BC=8C=E8=A7=A3=E5=86=B3?= =?UTF-8?q?=E6=8A=96=E5=8A=A8=E9=97=AE=E9=A2=98=20(#2521)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 升级新版地图交互事件机制 * refactor: 默认升级 MapNext 至 maplibre-gl-js@4.3.2 --- .changeset/large-pumas-provide.md | 5 + __tests__/unit/preset/environment.ts | 3 + packages/map/README.md | 33 +- packages/map/__tests__/camera.spec.ts | 2460 +++++++++++++++++ .../map/__tests__/geo/edge_insets.spec.ts | 81 + packages/map/__tests__/geo/lng_lat.spec.ts | 65 + .../map/__tests__/geo/lng_lat_bounds.spec.ts | 249 ++ .../__tests__/geo/mercator_coordinate.spec.ts | 36 + packages/map/__tests__/geo/transform.spec.ts | 294 ++ .../map/__tests__/handler/box_zoom.spec.ts | 163 ++ .../__tests__/handler/dblclick_zoom.spec.ts | 161 ++ .../map/__tests__/handler/drag_pan.spec.ts | 471 ++++ .../map/__tests__/handler/drag_rotate.spec.ts | 906 ++++++ .../map/__tests__/handler/keyboard.spec.ts | 232 ++ .../map/__tests__/handler/map_event.spec.ts | 182 ++ .../handler/mouse_handler_interface.spec.ts | 117 + .../__tests__/handler/mouse_rotate.spec.ts | 62 + ...inger_touch_drag_handler_interface.spec.ts | 96 + .../map/__tests__/handler/scroll_zoom.spec.ts | 354 ++- .../__tests__/handler/tap_drag_zoom.spec.ts | 113 + .../handler/two_fingers_touch.spec.ts | 230 ++ packages/map/__tests__/hash.spec.ts | 38 - packages/map/__tests__/libs/fixed.ts | 25 + .../__tests__/libs/simulate_interaction.ts | 108 + packages/map/__tests__/libs/util.ts | 69 + packages/map/__tests__/map.init.spec.ts | 24 - packages/map/__tests__/map.spec.ts | 183 -- .../map/__tests__/map/map_animation.spec.ts | 62 + packages/map/__tests__/map/map_basic.spec.ts | 77 + packages/map/__tests__/map/map_bounds.spec.ts | 152 + .../map/map_disable_handlers.spec.ts | 37 + packages/map/__tests__/map/map_events.spec.ts | 254 ++ .../map/__tests__/map/map_is_moving.spec.ts | 177 ++ .../map/__tests__/map/map_is_rotating.spec.ts | 64 + .../map/__tests__/map/map_is_zooming.spec.ts | 93 + packages/map/__tests__/map/map_pitch.spec.ts | 90 + .../map/map_request_render_frame.spec.ts | 34 + packages/map/__tests__/map/map_resize.spec.ts | 102 + .../__tests__/map/map_world_copies.spec.ts | 101 + packages/map/__tests__/map/map_zoom.spec.ts | 78 + packages/map/__tests__/util/browser.spec.ts | 19 + packages/map/__tests__/util/evented.spec.ts | 193 ++ .../map/__tests__/util/task_queue.spec.ts | 114 + packages/map/legacy/README.md | 3 + packages/map/{src => legacy}/camera.ts | 0 packages/map/{src => legacy}/css/l7.css | 48 +- packages/map/{src => legacy}/earthmap.ts | 4 + .../map/{src => legacy}/geo/edge_insets.ts | 0 packages/map/{src => legacy}/geo/lng_lat.ts | 0 .../map/{src => legacy}/geo/lng_lat_bounds.ts | 0 packages/map/{src => legacy}/geo/mercator.ts | 4 +- packages/map/{src => legacy}/geo/point.ts | 0 packages/map/{src => legacy}/geo/simple.ts | 4 +- packages/map/{src => legacy}/geo/transform.ts | 2 +- .../map/{src => legacy}/handler/IHandler.ts | 0 .../handler/blockable_map_event.ts | 0 .../map/{src => legacy}/handler/box_zoom.ts | 0 .../map/{src => legacy}/handler/click_zoom.ts | 0 .../{src => legacy}/handler/events/event.ts | 0 .../{src => legacy}/handler/events/index.ts | 0 .../handler/events/map_mouse_event.ts | 0 .../handler/events/map_touch_event.ts | 0 .../handler/events/map_wheel_event.ts | 0 .../handler/events/render_event.ts | 0 .../handler/handler_inertia.ts | 0 .../handler/handler_manager.ts | 0 .../{src => legacy}/handler/handler_util.ts | 0 .../map/{src => legacy}/handler/keyboard.ts | 0 .../map/{src => legacy}/handler/map_event.ts | 0 .../{src => legacy}/handler/mouse/index.ts | 0 .../handler/mouse/mouse_handler.ts | 0 .../handler/mouse/mousepan_handler.ts | 0 .../handler/mouse/mousepitch_hander.ts | 0 .../handler/mouse/mouserotate_hander.ts | 0 .../map/{src => legacy}/handler/mouse/util.ts | 0 .../{src => legacy}/handler/scroll_zoom.ts | 0 .../handler/shim/dblclick_zoom.ts | 0 .../{src => legacy}/handler/shim/drag_pan.ts | 4 +- .../handler/shim/drag_rotate.ts | 0 .../handler/shim/touch_zoom_rotate.ts | 0 .../handler/tap/single_tap_recognizer.ts | 0 .../handler/tap/tap_drag_zoom.ts | 0 .../handler/tap/tap_recognizer.ts | 0 .../{src => legacy}/handler/tap/tap_zoom.ts | 0 .../{src => legacy}/handler/touch/index.ts | 0 .../handler/touch/touch_pan.ts | 0 .../handler/touch/touch_pitch.ts | 0 .../handler/touch/touch_rotate.ts | 0 .../handler/touch/touch_zoom.ts | 0 .../handler/touch/two_touch.ts | 0 packages/map/{src => legacy}/hash.ts | 0 packages/map/{src => legacy}/interface.ts | 4 + packages/map/{src => legacy}/map.ts | 6 +- packages/map/{src => legacy}/util.ts | 0 packages/map/{src => legacy}/utils/Aabb.ts | 0 packages/map/{src => legacy}/utils/dom.ts | 0 .../map/{src => legacy}/utils/performance.ts | 0 .../map/{src => legacy}/utils/primitives.ts | 0 .../map/{src => legacy}/utils/task_queue.ts | 0 packages/map/package.json | 5 +- packages/map/src/index.ts | 7 +- packages/map/src/map/camera.ts | 1503 ++++++++++ packages/map/src/map/css/l7.css | 163 ++ packages/map/src/map/events.ts | 469 ++++ packages/map/src/map/geo/edge_insets.ts | 158 ++ packages/map/src/map/geo/lng_lat.ts | 177 ++ packages/map/src/map/geo/lng_lat_bounds.ts | 350 +++ .../map/src/map/geo/mercator_coordinate.ts | 173 ++ packages/map/src/map/geo/transform.ts | 849 ++++++ packages/map/src/map/handler/box_zoom.ts | 178 ++ packages/map/src/map/handler/click_zoom.ts | 57 + .../src/map/handler/cooperative_gestures.ts | 107 + packages/map/src/map/handler/drag_handler.ts | 176 ++ .../map/handler/drag_move_state_manager.ts | 115 + packages/map/src/map/handler/handler_util.ts | 13 + packages/map/src/map/handler/keyboard.ts | 215 ++ packages/map/src/map/handler/map_event.ts | 163 ++ packages/map/src/map/handler/mouse.ts | 108 + .../src/map/handler/one_finger_touch_drag.ts | 57 + packages/map/src/map/handler/scroll_zoom.ts | 363 +++ .../map/src/map/handler/shim/dblclick_zoom.ts | 63 + packages/map/src/map/handler/shim/drag_pan.ts | 103 + .../map/src/map/handler/shim/drag_rotate.ts | 81 + .../src/map/handler/shim/two_fingers_touch.ts | 124 + packages/map/src/map/handler/tap_drag_zoom.ts | 110 + .../map/src/map/handler/tap_recognizer.ts | 129 + packages/map/src/map/handler/tap_zoom.ts | 106 + packages/map/src/map/handler/touch_pan.ts | 109 + .../map/src/map/handler/transform-provider.ts | 43 + .../map/src/map/handler/two_fingers_touch.ts | 342 +++ packages/map/src/map/handler_inertia.ts | 168 ++ packages/map/src/map/handler_manager.ts | 690 +++++ packages/map/src/map/map.ts | 1039 +++++++ packages/map/src/map/util/abort_error.ts | 21 + packages/map/src/map/util/browser.ts | 37 + packages/map/src/map/util/dom.ts | 155 ++ packages/map/src/map/util/evented.ts | 192 ++ packages/map/src/map/util/task_queue.ts | 64 + packages/map/src/map/util/util.ts | 171 ++ packages/map/tsconfig.json | 5 +- packages/maps/src/bmap/index.ts | 3 +- packages/maps/src/earth/index.ts | 4 +- packages/maps/src/earth/map.ts | 7 +- packages/maps/src/map/map.ts | 11 +- packages/maps/src/utils/BaseMapService.ts | 13 +- 145 files changed, 17317 insertions(+), 365 deletions(-) create mode 100644 .changeset/large-pumas-provide.md create mode 100644 packages/map/__tests__/camera.spec.ts create mode 100644 packages/map/__tests__/geo/edge_insets.spec.ts create mode 100644 packages/map/__tests__/geo/lng_lat.spec.ts create mode 100644 packages/map/__tests__/geo/lng_lat_bounds.spec.ts create mode 100644 packages/map/__tests__/geo/mercator_coordinate.spec.ts create mode 100644 packages/map/__tests__/geo/transform.spec.ts create mode 100644 packages/map/__tests__/handler/box_zoom.spec.ts create mode 100644 packages/map/__tests__/handler/dblclick_zoom.spec.ts create mode 100644 packages/map/__tests__/handler/drag_pan.spec.ts create mode 100644 packages/map/__tests__/handler/drag_rotate.spec.ts create mode 100644 packages/map/__tests__/handler/keyboard.spec.ts create mode 100644 packages/map/__tests__/handler/map_event.spec.ts create mode 100644 packages/map/__tests__/handler/mouse_handler_interface.spec.ts create mode 100644 packages/map/__tests__/handler/mouse_rotate.spec.ts create mode 100644 packages/map/__tests__/handler/one_finger_touch_drag_handler_interface.spec.ts create mode 100644 packages/map/__tests__/handler/tap_drag_zoom.spec.ts create mode 100644 packages/map/__tests__/handler/two_fingers_touch.spec.ts delete mode 100644 packages/map/__tests__/hash.spec.ts create mode 100644 packages/map/__tests__/libs/fixed.ts create mode 100644 packages/map/__tests__/libs/simulate_interaction.ts create mode 100644 packages/map/__tests__/libs/util.ts delete mode 100644 packages/map/__tests__/map.init.spec.ts delete mode 100644 packages/map/__tests__/map.spec.ts create mode 100644 packages/map/__tests__/map/map_animation.spec.ts create mode 100644 packages/map/__tests__/map/map_basic.spec.ts create mode 100644 packages/map/__tests__/map/map_bounds.spec.ts create mode 100644 packages/map/__tests__/map/map_disable_handlers.spec.ts create mode 100644 packages/map/__tests__/map/map_events.spec.ts create mode 100644 packages/map/__tests__/map/map_is_moving.spec.ts create mode 100644 packages/map/__tests__/map/map_is_rotating.spec.ts create mode 100644 packages/map/__tests__/map/map_is_zooming.spec.ts create mode 100644 packages/map/__tests__/map/map_pitch.spec.ts create mode 100644 packages/map/__tests__/map/map_request_render_frame.spec.ts create mode 100644 packages/map/__tests__/map/map_resize.spec.ts create mode 100644 packages/map/__tests__/map/map_world_copies.spec.ts create mode 100644 packages/map/__tests__/map/map_zoom.spec.ts create mode 100644 packages/map/__tests__/util/browser.spec.ts create mode 100644 packages/map/__tests__/util/evented.spec.ts create mode 100644 packages/map/__tests__/util/task_queue.spec.ts create mode 100644 packages/map/legacy/README.md rename packages/map/{src => legacy}/camera.ts (100%) rename packages/map/{src => legacy}/css/l7.css (71%) rename packages/map/{src => legacy}/earthmap.ts (99%) rename packages/map/{src => legacy}/geo/edge_insets.ts (100%) rename packages/map/{src => legacy}/geo/lng_lat.ts (100%) rename packages/map/{src => legacy}/geo/lng_lat_bounds.ts (100%) rename packages/map/{src => legacy}/geo/mercator.ts (95%) rename packages/map/{src => legacy}/geo/point.ts (100%) rename packages/map/{src => legacy}/geo/simple.ts (95%) rename packages/map/{src => legacy}/geo/transform.ts (99%) rename packages/map/{src => legacy}/handler/IHandler.ts (100%) rename packages/map/{src => legacy}/handler/blockable_map_event.ts (100%) rename packages/map/{src => legacy}/handler/box_zoom.ts (100%) rename packages/map/{src => legacy}/handler/click_zoom.ts (100%) rename packages/map/{src => legacy}/handler/events/event.ts (100%) rename packages/map/{src => legacy}/handler/events/index.ts (100%) rename packages/map/{src => legacy}/handler/events/map_mouse_event.ts (100%) rename packages/map/{src => legacy}/handler/events/map_touch_event.ts (100%) rename packages/map/{src => legacy}/handler/events/map_wheel_event.ts (100%) rename packages/map/{src => legacy}/handler/events/render_event.ts (100%) rename packages/map/{src => legacy}/handler/handler_inertia.ts (100%) rename packages/map/{src => legacy}/handler/handler_manager.ts (100%) rename packages/map/{src => legacy}/handler/handler_util.ts (100%) rename packages/map/{src => legacy}/handler/keyboard.ts (100%) rename packages/map/{src => legacy}/handler/map_event.ts (100%) rename packages/map/{src => legacy}/handler/mouse/index.ts (100%) rename packages/map/{src => legacy}/handler/mouse/mouse_handler.ts (100%) rename packages/map/{src => legacy}/handler/mouse/mousepan_handler.ts (100%) rename packages/map/{src => legacy}/handler/mouse/mousepitch_hander.ts (100%) rename packages/map/{src => legacy}/handler/mouse/mouserotate_hander.ts (100%) rename packages/map/{src => legacy}/handler/mouse/util.ts (100%) rename packages/map/{src => legacy}/handler/scroll_zoom.ts (100%) rename packages/map/{src => legacy}/handler/shim/dblclick_zoom.ts (100%) rename packages/map/{src => legacy}/handler/shim/drag_pan.ts (95%) rename packages/map/{src => legacy}/handler/shim/drag_rotate.ts (100%) rename packages/map/{src => legacy}/handler/shim/touch_zoom_rotate.ts (100%) rename packages/map/{src => legacy}/handler/tap/single_tap_recognizer.ts (100%) rename packages/map/{src => legacy}/handler/tap/tap_drag_zoom.ts (100%) rename packages/map/{src => legacy}/handler/tap/tap_recognizer.ts (100%) rename packages/map/{src => legacy}/handler/tap/tap_zoom.ts (100%) rename packages/map/{src => legacy}/handler/touch/index.ts (100%) rename packages/map/{src => legacy}/handler/touch/touch_pan.ts (100%) rename packages/map/{src => legacy}/handler/touch/touch_pitch.ts (100%) rename packages/map/{src => legacy}/handler/touch/touch_rotate.ts (100%) rename packages/map/{src => legacy}/handler/touch/touch_zoom.ts (100%) rename packages/map/{src => legacy}/handler/touch/two_touch.ts (100%) rename packages/map/{src => legacy}/hash.ts (100%) rename packages/map/{src => legacy}/interface.ts (94%) rename packages/map/{src => legacy}/map.ts (99%) rename packages/map/{src => legacy}/util.ts (100%) rename packages/map/{src => legacy}/utils/Aabb.ts (100%) rename packages/map/{src => legacy}/utils/dom.ts (100%) rename packages/map/{src => legacy}/utils/performance.ts (100%) rename packages/map/{src => legacy}/utils/primitives.ts (100%) rename packages/map/{src => legacy}/utils/task_queue.ts (100%) create mode 100644 packages/map/src/map/camera.ts create mode 100644 packages/map/src/map/css/l7.css create mode 100644 packages/map/src/map/events.ts create mode 100644 packages/map/src/map/geo/edge_insets.ts create mode 100644 packages/map/src/map/geo/lng_lat.ts create mode 100644 packages/map/src/map/geo/lng_lat_bounds.ts create mode 100644 packages/map/src/map/geo/mercator_coordinate.ts create mode 100644 packages/map/src/map/geo/transform.ts create mode 100644 packages/map/src/map/handler/box_zoom.ts create mode 100644 packages/map/src/map/handler/click_zoom.ts create mode 100644 packages/map/src/map/handler/cooperative_gestures.ts create mode 100644 packages/map/src/map/handler/drag_handler.ts create mode 100644 packages/map/src/map/handler/drag_move_state_manager.ts create mode 100644 packages/map/src/map/handler/handler_util.ts create mode 100644 packages/map/src/map/handler/keyboard.ts create mode 100644 packages/map/src/map/handler/map_event.ts create mode 100644 packages/map/src/map/handler/mouse.ts create mode 100644 packages/map/src/map/handler/one_finger_touch_drag.ts create mode 100644 packages/map/src/map/handler/scroll_zoom.ts create mode 100644 packages/map/src/map/handler/shim/dblclick_zoom.ts create mode 100644 packages/map/src/map/handler/shim/drag_pan.ts create mode 100644 packages/map/src/map/handler/shim/drag_rotate.ts create mode 100644 packages/map/src/map/handler/shim/two_fingers_touch.ts create mode 100644 packages/map/src/map/handler/tap_drag_zoom.ts create mode 100644 packages/map/src/map/handler/tap_recognizer.ts create mode 100644 packages/map/src/map/handler/tap_zoom.ts create mode 100644 packages/map/src/map/handler/touch_pan.ts create mode 100644 packages/map/src/map/handler/transform-provider.ts create mode 100644 packages/map/src/map/handler/two_fingers_touch.ts create mode 100644 packages/map/src/map/handler_inertia.ts create mode 100644 packages/map/src/map/handler_manager.ts create mode 100644 packages/map/src/map/map.ts create mode 100644 packages/map/src/map/util/abort_error.ts create mode 100755 packages/map/src/map/util/browser.ts create mode 100644 packages/map/src/map/util/dom.ts create mode 100644 packages/map/src/map/util/evented.ts create mode 100644 packages/map/src/map/util/task_queue.ts create mode 100644 packages/map/src/map/util/util.ts diff --git a/.changeset/large-pumas-provide.md b/.changeset/large-pumas-provide.md new file mode 100644 index 00000000000..7f9d79da86d --- /dev/null +++ b/.changeset/large-pumas-provide.md @@ -0,0 +1,5 @@ +--- +'@antv/l7-map': patch +--- + +refactor: 升级新版一方地图交互事件机制,解决抖动问题 diff --git a/__tests__/unit/preset/environment.ts b/__tests__/unit/preset/environment.ts index 4e023f37de5..6ee7506267d 100644 --- a/__tests__/unit/preset/environment.ts +++ b/__tests__/unit/preset/environment.ts @@ -1 +1,4 @@ (window as any).URL.createObjectURL = jest.fn; +(window as any).ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), +})); diff --git a/packages/map/README.md b/packages/map/README.md index 74ed6847c3d..d10e2d1591d 100644 --- a/packages/map/README.md +++ b/packages/map/README.md @@ -1,11 +1,32 @@ -# `map` +## Map -> TODO: description +Map fork from [maplibre-gl-js@4.3.2](https://github.com/maplibre/maplibre-gl-js), keep event loop, responds user interaction and updates the internal state of the map (current viewport, camera angle, etc.) -## Usage +```mermaid +sequenceDiagram + actor user + participant DOM + participant handler_manager + participant handler + participant camera + participant transform + participant map -``` -const map = require('map'); + user->>camera: map#setCenter, map#panTo + camera->>transform: update + camera->>map: fire move event + map->>map: _render() -// TODO: DEMONSTRATE API + user->>DOM: resize, pan,
click, scroll,
... + DOM->>handler_manager: DOM events + handler_manager->>handler: forward event + handler-->>handler_manager: HandlerResult + handler_manager->>transform: update + handler_manager->>map: fire move event + map->>map: _render() ``` + +- [Transform](./src/geo/transform.ts) holds the current viewport details (pitch, zoom, bearing, bounds, etc.). Two places in the code update transform directly: + - [Camera](./src/camera.ts) (parent class of [Map](./src/map)) in response to explicit calls to [Camera#panTo](./src/camera.ts#L207), [Camera#setCenter](./src/camera.ts#L169) + - [HandlerManager](./src/handler_manager.ts) in response to DOM events. It forwards those events to interaction processors that live in [src/ui/handler](./src/handler), which accumulate a merged [HandlerResult](./src/handler_manager.ts#L64) that kick off a render frame loop, decreasing the inertia and nudging map.transform by that amount on each frame from [HandlerManager#\_updateMapTransform()](./src/handler_manager.ts#L413). That loop continues in the inertia decreases to 0. +- Both camera and handler_manager are responsible for firing `move`, `zoom`, `movestart`, `moveend`, ... events on the map after they update transform. Each of these events (along with style changes and data load events) triggers a call to [Map#\_render()](./src/map.ts#L2480) which renders a single frame of the map. diff --git a/packages/map/__tests__/camera.spec.ts b/packages/map/__tests__/camera.spec.ts new file mode 100644 index 00000000000..6167baf1100 --- /dev/null +++ b/packages/map/__tests__/camera.spec.ts @@ -0,0 +1,2460 @@ +import type { CameraOptions } from '../src/map/camera'; +import { Camera } from '../src/map/camera'; +import { Transform } from '../src/map/geo/transform'; +import { browser } from '../src/map/util/browser'; +import type { TaskID } from '../src/map/util/task_queue'; +import { TaskQueue } from '../src/map/util/task_queue'; + +import { LngLat } from '../src/map/geo/lng_lat'; +import { LngLatBounds } from '../src/map/geo/lng_lat_bounds'; +import { mercatorZfromAltitude } from '../src/map/geo/mercator_coordinate'; +import type { Event } from '../src/map/util/evented'; +import { fixedLngLat, fixedNum } from './libs/fixed'; +import { setMatchMedia } from './libs/util'; + +beforeEach(() => { + setMatchMedia(); + Object.defineProperty(browser, 'prefersReducedMotion', { value: false }); +}); + +class CameraMock extends Camera { + // eslint-disable-next-line + _requestRenderFrame(a: () => void): TaskID { + return undefined; + } + + _cancelRenderFrame(): void { + return undefined; + } +} + +function attachSimulateFrame(camera) { + const queue = new TaskQueue(); + camera._requestRenderFrame = (cb) => queue.add(cb); + camera._cancelRenderFrame = (id) => queue.remove(id); + camera.simulateFrame = () => queue.run(); + return camera; +} + +function createCamera(options?) { + options = options || {}; + + const transform = new Transform(0, 20, 0, 60, options.renderWorldCopies); + transform.resize(512, 512); + + const camera = attachSimulateFrame(new CameraMock(transform, {} as any)).jumpTo(options); + + camera._update = () => {}; + + return camera; +} + +function assertTransitionTime(done, camera, min, max) { + let startTime; + camera + .on('movestart', () => { + startTime = new Date(); + }) + .on('moveend', () => { + const endTime = new Date(); + const timeDiff = endTime.getTime() - startTime.getTime(); + expect(timeDiff >= min && timeDiff < max).toBeTruthy(); + done(); + }); +} + +describe('#calculateCameraOptionsFromTo', () => { + // Choose initial zoom to avoid center being constrained by mercator latitude limits. + const camera = createCamera({ zoom: 1 }); + + test('look at north', () => { + const cameraOptions: CameraOptions = camera.calculateCameraOptionsFromTo( + { lng: 1, lat: 0 }, + 0, + { lng: 1, lat: 1 }, + ); + expect(cameraOptions).toBeDefined(); + expect(cameraOptions.center).toBeDefined(); + expect(cameraOptions.bearing).toBeCloseTo(0); + }); + + test('look at west', () => { + const cameraOptions = camera.calculateCameraOptionsFromTo({ lng: 1, lat: 0 }, 0, { + lng: 0, + lat: 0, + }); + expect(cameraOptions).toBeDefined(); + expect(cameraOptions.bearing).toBeCloseTo(-90); + }); + + test('pitch 45', () => { + // altitude same as grounddistance => 45° + // distance between lng x and lng x+1 is 111.2km at same lat + const cameraOptions: CameraOptions = camera.calculateCameraOptionsFromTo( + { lng: 1, lat: 0 }, + 111200, + { lng: 0, lat: 0 }, + ); + expect(cameraOptions).toBeDefined(); + expect(cameraOptions.pitch).toBeCloseTo(45); + }); + + test('pitch 90', () => { + const cameraOptions = camera.calculateCameraOptionsFromTo({ lng: 1, lat: 0 }, 0, { + lng: 0, + lat: 0, + }); + expect(cameraOptions).toBeDefined(); + expect(cameraOptions.pitch).toBeCloseTo(90); + }); + + test('pitch 153.435', () => { + // distance between lng x and lng x+1 is 111.2km at same lat + // (elevation difference of cam and center) / 2 = grounddistance => + // acos(111.2 / sqrt(111.2² + (111.2 * 2)²)) = acos(1/sqrt(5)) => 63.435 + 90 (looking up) = 153.435 + const cameraOptions: CameraOptions = camera.calculateCameraOptionsFromTo( + { lng: 1, lat: 0 }, + 111200, + { lng: 0, lat: 0 }, + 111200 * 3, + ); + expect(cameraOptions).toBeDefined(); + expect(cameraOptions.pitch).toBeCloseTo(153.435); + }); + + test('zoom distance 1000', () => { + const expectedZoom = Math.log2( + camera.transform.cameraToCenterDistance / + mercatorZfromAltitude(1000, 0) / + camera.transform.tileSize, + ); + const cameraOptions = camera.calculateCameraOptionsFromTo( + { lng: 0, lat: 0 }, + 0, + { lng: 0, lat: 0 }, + 1000, + ); + + expect(cameraOptions).toBeDefined(); + expect(cameraOptions.zoom).toBeCloseTo(expectedZoom); + }); + + test('zoom distance 1 lng (111.2km), 111.2km altitude away', () => { + const expectedZoom = Math.log2( + camera.transform.cameraToCenterDistance / + mercatorZfromAltitude(Math.hypot(111200, 111200), 0) / + camera.transform.tileSize, + ); + const cameraOptions = camera.calculateCameraOptionsFromTo( + { lng: 0, lat: 0 }, + 0, + { lng: 1, lat: 0 }, + 111200, + ); + + expect(cameraOptions).toBeDefined(); + expect(cameraOptions.zoom).toBeCloseTo(expectedZoom); + }); + + test('same To as From error', () => { + expect(() => { + camera.calculateCameraOptionsFromTo({ lng: 0, lat: 0 }, 0, { lng: 0, lat: 0 }, 0); + }).toThrow(); + }); +}); + +describe('#jumpTo', () => { + // Choose initial zoom to avoid center being constrained by mercator latitude limits. + const camera = createCamera({ zoom: 1 }); + + test('sets center', () => { + camera.jumpTo({ center: [1, 2] }); + expect(camera.getCenter()).toEqual({ lng: 1, lat: 2 }); + }); + + test('throws on invalid center argument', () => { + expect(() => { + camera.jumpTo({ center: 1 }); + }).toThrow(Error); + }); + + test('keeps current center if not specified', () => { + camera.jumpTo({}); + expect(camera.getCenter()).toEqual({ lng: 1, lat: 2 }); + }); + + test('sets zoom', () => { + camera.jumpTo({ zoom: 3 }); + expect(camera.getZoom()).toBe(3); + }); + + test('keeps current zoom if not specified', () => { + camera.jumpTo({}); + expect(camera.getZoom()).toBe(3); + }); + + test('sets bearing', () => { + camera.jumpTo({ bearing: 4 }); + expect(camera.getBearing()).toBe(4); + }); + + test('keeps current bearing if not specified', () => { + camera.jumpTo({}); + expect(camera.getBearing()).toBe(4); + }); + + test('sets pitch', () => { + camera.jumpTo({ pitch: 45 }); + expect(camera.getPitch()).toBe(45); + }); + + test('keeps current pitch if not specified', () => { + camera.jumpTo({}); + expect(camera.getPitch()).toBe(45); + }); + + test('sets multiple properties', () => { + camera.jumpTo({ + center: [10, 20], + zoom: 10, + bearing: 180, + pitch: 60, + }); + expect(camera.getCenter()).toEqual({ lng: 10, lat: 20 }); + expect(camera.getZoom()).toBe(10); + expect(camera.getBearing()).toBe(180); + expect(camera.getPitch()).toBe(60); + }); + + test('emits move events, preserving eventData', (done) => { + let started, moved, ended; + const eventData = { data: 'ok' }; + + camera + .on('movestart', (d) => { + started = d.data; + }) + .on('move', (d) => { + moved = d.data; + }) + .on('moveend', (d) => { + ended = d.data; + }); + + camera.jumpTo({ center: [1, 2] }, eventData); + expect(started).toBe('ok'); + expect(moved).toBe('ok'); + expect(ended).toBe('ok'); + done(); + }); + + test('emits zoom events, preserving eventData', (done) => { + let started, zoomed, ended; + const eventData = { data: 'ok' }; + + camera + .on('zoomstart', (d) => { + started = d.data; + }) + .on('zoom', (d) => { + zoomed = d.data; + }) + .on('zoomend', (d) => { + ended = d.data; + }); + + camera.jumpTo({ zoom: 3 }, eventData); + expect(started).toBe('ok'); + expect(zoomed).toBe('ok'); + expect(ended).toBe('ok'); + done(); + }); + + test('emits rotate events, preserving eventData', (done) => { + let started, rotated, ended; + const eventData = { data: 'ok' }; + + camera + .on('rotatestart', (d) => { + started = d.data; + }) + .on('rotate', (d) => { + rotated = d.data; + }) + .on('rotateend', (d) => { + ended = d.data; + }); + + camera.jumpTo({ bearing: 90 }, eventData); + expect(started).toBe('ok'); + expect(rotated).toBe('ok'); + expect(ended).toBe('ok'); + done(); + }); + + test('emits pitch events, preserving eventData', (done) => { + let started, pitched, ended; + const eventData = { data: 'ok' }; + + camera + .on('pitchstart', (d) => { + started = d.data; + }) + .on('pitch', (d) => { + pitched = d.data; + }) + .on('pitchend', (d) => { + ended = d.data; + }); + + camera.jumpTo({ pitch: 10 }, eventData); + expect(started).toBe('ok'); + expect(pitched).toBe('ok'); + expect(ended).toBe('ok'); + done(); + }); + + test('cancels in-progress easing', () => { + camera.panTo([3, 4]); + expect(camera.isEasing()).toBeTruthy(); + camera.jumpTo({ center: [1, 2] }); + expect(!camera.isEasing()).toBeTruthy(); + }); +}); + +describe('#setCenter', () => { + // Choose initial zoom to avoid center being constrained by mercator latitude limits. + const camera = createCamera({ zoom: 1 }); + + test('sets center', () => { + camera.setCenter([1, 2]); + expect(camera.getCenter()).toEqual({ lng: 1, lat: 2 }); + }); + + test('throws on invalid center argument', () => { + expect(() => { + camera.jumpTo({ center: 1 }); + }).toThrow(Error); + }); + + test('emits move events, preserving eventData', (done) => { + let started, moved, ended; + const eventData = { data: 'ok' }; + + camera + .on('movestart', (d) => { + started = d.data; + }) + .on('move', (d) => { + moved = d.data; + }) + .on('moveend', (d) => { + ended = d.data; + }); + + camera.setCenter([10, 20], eventData); + expect(started).toBe('ok'); + expect(moved).toBe('ok'); + expect(ended).toBe('ok'); + done(); + }); + + test('cancels in-progress easing', () => { + camera.panTo([3, 4]); + expect(camera.isEasing()).toBeTruthy(); + camera.setCenter([1, 2]); + expect(!camera.isEasing()).toBeTruthy(); + }); +}); + +describe('#setZoom', () => { + const camera = createCamera(); + + test('sets zoom', () => { + camera.setZoom(3); + expect(camera.getZoom()).toBe(3); + }); + + test('emits move and zoom events, preserving eventData', (done) => { + let movestarted, moved, moveended, zoomstarted, zoomed, zoomended; + const eventData = { data: 'ok' }; + + camera + .on('movestart', (d) => { + movestarted = d.data; + }) + .on('move', (d) => { + moved = d.data; + }) + .on('moveend', (d) => { + moveended = d.data; + }) + .on('zoomstart', (d) => { + zoomstarted = d.data; + }) + .on('zoom', (d) => { + zoomed = d.data; + }) + .on('zoomend', (d) => { + zoomended = d.data; + }); + + camera.setZoom(4, eventData); + expect(movestarted).toBe('ok'); + expect(moved).toBe('ok'); + expect(moveended).toBe('ok'); + expect(zoomstarted).toBe('ok'); + expect(zoomed).toBe('ok'); + expect(zoomended).toBe('ok'); + done(); + }); + + test('cancels in-progress easing', () => { + camera.panTo([3, 4]); + expect(camera.isEasing()).toBeTruthy(); + camera.setZoom(5); + expect(!camera.isEasing()).toBeTruthy(); + }); +}); + +describe('#setBearing', () => { + const camera = createCamera(); + + test('sets bearing', () => { + camera.setBearing(4); + expect(camera.getBearing()).toBe(4); + }); + + test('emits move and rotate events, preserving eventData', (done) => { + let movestarted, moved, moveended, rotatestarted, rotated, rotateended; + const eventData = { data: 'ok' }; + + camera + .on('movestart', (d) => { + movestarted = d.data; + }) + .on('move', (d) => { + moved = d.data; + }) + .on('moveend', (d) => { + moveended = d.data; + }) + .on('rotatestart', (d) => { + rotatestarted = d.data; + }) + .on('rotate', (d) => { + rotated = d.data; + }) + .on('rotateend', (d) => { + rotateended = d.data; + }); + + camera.setBearing(5, eventData); + expect(movestarted).toBe('ok'); + expect(moved).toBe('ok'); + expect(moveended).toBe('ok'); + expect(rotatestarted).toBe('ok'); + expect(rotated).toBe('ok'); + expect(rotateended).toBe('ok'); + done(); + }); + + test('cancels in-progress easing', () => { + camera.panTo([3, 4]); + expect(camera.isEasing()).toBeTruthy(); + camera.setBearing(6); + expect(!camera.isEasing()).toBeTruthy(); + }); +}); + +describe('#setPadding', () => { + test('sets padding', () => { + const camera = createCamera(); + const padding = { left: 300, top: 100, right: 50, bottom: 10 }; + camera.setPadding(padding); + expect(camera.getPadding()).toEqual(padding); + }); + + test('existing padding is retained if no new values are passed in', () => { + const camera = createCamera(); + const padding = { left: 300, top: 100, right: 50, bottom: 10 }; + camera.setPadding(padding); + camera.setPadding({}); + + const currentPadding = camera.getPadding(); + expect(currentPadding).toEqual(padding); + }); + + test("doesn't change padding thats already present if new value isn't passed in", () => { + const camera = createCamera(); + const padding = { left: 300, top: 100, right: 50, bottom: 10 }; + camera.setPadding(padding); + const padding1 = { right: 100 }; + camera.setPadding(padding1); + + const currentPadding = camera.getPadding(); + expect(currentPadding.left).toBe(padding.left); + expect(currentPadding.top).toBe(padding.top); + // padding1 here + expect(currentPadding.right).toBe(padding1.right); + expect(currentPadding.bottom).toBe(padding.bottom); + }); +}); + +describe('#panBy', () => { + test('pans by specified amount', () => { + const camera = createCamera(); + camera.panBy([100, 0], { duration: 0 }); + expect(fixedLngLat(camera.getCenter())).toEqual({ lng: 70.3125, lat: 0 }); + }); + + test('pans relative to viewport on a rotated camera', () => { + const camera = createCamera({ bearing: 180 }); + camera.panBy([100, 0], { duration: 0 }); + expect(fixedLngLat(camera.getCenter())).toEqual({ lng: -70.3125, lat: 0 }); + }); + + test('emits move events, preserving eventData', (done) => { + const camera = createCamera(); + let started, moved; + const eventData = { data: 'ok' }; + + camera + .on('movestart', (d) => { + started = d.data; + }) + .on('move', (d) => { + moved = d.data; + }) + .on('moveend', (d) => { + expect(started).toBe('ok'); + expect(moved).toBe('ok'); + expect(d.data).toBe('ok'); + done(); + }); + + camera.panBy([100, 0], { duration: 0 }, eventData); + }); + + test('suppresses movestart if noMoveStart option is true', (done) => { + const camera = createCamera(); + let started; + + // fire once in advance to satisfy assertions that moveend only comes after movestart + camera.fire('movestart'); + + camera + .on('movestart', () => { + started = true; + }) + .on('moveend', () => { + expect(!started).toBeTruthy(); + done(); + }); + + camera.panBy([100, 0], { duration: 0, noMoveStart: true }); + }); +}); + +describe('#panTo', () => { + test('pans to specified location', () => { + const camera = createCamera(); + camera.panTo([100, 0], { duration: 0 }); + expect(camera.getCenter()).toEqual({ lng: 100, lat: 0 }); + }); + + test('throws on invalid center argument', () => { + const camera = createCamera(); + expect(() => { + camera.panTo({ center: 1 }); + }).toThrow(Error); + }); + + test('pans with specified offset', () => { + const camera = createCamera(); + camera.panTo([100, 0], { offset: [100, 0], duration: 0 }); + expect(fixedLngLat(camera.getCenter())).toEqual({ lng: 29.6875, lat: 0 }); + }); + + test('pans with specified offset relative to viewport on a rotated camera', () => { + const camera = createCamera({ bearing: 180 }); + camera.panTo([100, 0], { offset: [100, 0], duration: 0 }); + expect(fixedLngLat(camera.getCenter())).toEqual({ lng: 170.3125, lat: 0 }); + }); + + test('emits move events, preserving eventData', (done) => { + const camera = createCamera(); + let started, moved; + const eventData = { data: 'ok' }; + + camera + .on('movestart', (d) => { + started = d.data; + }) + .on('move', (d) => { + moved = d.data; + }) + .on('moveend', (d) => { + expect(started).toBe('ok'); + expect(moved).toBe('ok'); + expect(d.data).toBe('ok'); + done(); + }); + + camera.panTo([100, 0], { duration: 0 }, eventData); + }); + + test('suppresses movestart if noMoveStart option is true', (done) => { + const camera = createCamera(); + let started; + + // fire once in advance to satisfy assertions that moveend only comes after movestart + camera.fire('movestart'); + + camera + .on('movestart', () => { + started = true; + }) + .on('moveend', () => { + expect(!started).toBeTruthy(); + done(); + }); + + camera.panTo([100, 0], { duration: 0, noMoveStart: true }); + }); +}); + +describe('#zoomTo', () => { + test('zooms to specified level', () => { + const camera = createCamera(); + camera.zoomTo(3.2, { duration: 0 }); + expect(camera.getZoom()).toBe(3.2); + }); + + test('zooms around specified location', () => { + const camera = createCamera(); + camera.zoomTo(3.2, { around: [5, 0], duration: 0 }); + expect(camera.getZoom()).toBe(3.2); + expect(fixedLngLat(camera.getCenter())).toEqual( + fixedLngLat({ lng: 4.455905897939886, lat: 0 }), + ); + }); + + test('zooms with specified offset', () => { + const camera = createCamera(); + camera.zoomTo(3.2, { offset: [100, 0], duration: 0 }); + expect(camera.getZoom()).toBe(3.2); + expect(fixedLngLat(camera.getCenter())).toEqual( + fixedLngLat({ lng: 62.66117668978015, lat: 0 }), + ); + }); + + test('zooms with specified offset relative to viewport on a rotated camera', () => { + const camera = createCamera({ bearing: 180 }); + camera.zoomTo(3.2, { offset: [100, 0], duration: 0 }); + expect(camera.getZoom()).toBe(3.2); + expect(fixedLngLat(camera.getCenter())).toEqual( + fixedLngLat({ lng: -62.66117668978012, lat: 0 }), + ); + }); + + test('emits move and zoom events, preserving eventData', (done) => { + const camera = createCamera(); + let movestarted, moved, zoomstarted, zoomed; + const eventData = { data: 'ok' }; + + expect.assertions(6); + + camera + .on('movestart', (d) => { + movestarted = d.data; + }) + .on('move', (d) => { + moved = d.data; + }) + .on('moveend', (d) => { + expect(movestarted).toBe('ok'); + expect(moved).toBe('ok'); + expect(d.data).toBe('ok'); + }); + + camera + .on('zoomstart', (d) => { + zoomstarted = d.data; + }) + .on('zoom', (d) => { + zoomed = d.data; + }) + .on('zoomend', (d) => { + expect(zoomstarted).toBe('ok'); + expect(zoomed).toBe('ok'); + expect(d.data).toBe('ok'); + }); + + camera.zoomTo(5, { duration: 0 }, eventData); + done(); + }); +}); + +describe('#rotateTo', () => { + test('rotates to specified bearing', () => { + const camera = createCamera(); + camera.rotateTo(90, { duration: 0 }); + expect(camera.getBearing()).toBe(90); + }); + + test('rotates around specified location', () => { + const camera = createCamera({ zoom: 3 }); + camera.rotateTo(90, { around: [5, 0], duration: 0 }); + expect(camera.getBearing()).toBe(90); + expect(fixedLngLat(camera.getCenter())).toEqual( + fixedLngLat({ lng: 4.999999999999972, lat: 4.993665859353271 }), + ); + }); + + test('rotates around specified location, constrained to fit the view', () => { + const camera = createCamera({ zoom: 0 }); + camera.rotateTo(90, { around: [5, 0], duration: 0 }); + expect(camera.getBearing()).toBe(90); + expect(fixedLngLat(camera.getCenter())).toEqual( + fixedLngLat({ lng: 4.999999999999972, lat: 0.000002552471840999715 }), + ); + }); + + test('rotates with specified offset', () => { + const camera = createCamera({ zoom: 1 }); + camera.rotateTo(90, { offset: [200, 0], duration: 0 }); + expect(camera.getBearing()).toBe(90); + expect(fixedLngLat(camera.getCenter())).toEqual( + fixedLngLat({ lng: 70.3125, lat: 57.3265212252 }), + ); + }); + + test('rotates with specified offset, constrained to fit the view', () => { + const camera = createCamera({ zoom: 0 }); + camera.rotateTo(90, { offset: [100, 0], duration: 0 }); + expect(camera.getBearing()).toBe(90); + expect(fixedLngLat(camera.getCenter())).toEqual( + fixedLngLat({ lng: 70.3125, lat: 0.000002552471840999715 }), + ); + }); + + test('rotates with specified offset relative to viewport on a rotated camera', () => { + const camera = createCamera({ bearing: 180, zoom: 1 }); + camera.rotateTo(90, { offset: [200, 0], duration: 0 }); + expect(camera.getBearing()).toBe(90); + expect(fixedLngLat(camera.getCenter())).toEqual( + fixedLngLat({ lng: -70.3125, lat: 57.3265212252 }), + ); + }); + + test('emits move and rotate events, preserving eventData', (done) => { + const camera = createCamera(); + let movestarted, moved, rotatestarted, rotated; + const eventData = { data: 'ok' }; + + expect.assertions(6); + + camera + .on('movestart', (d) => { + movestarted = d.data; + }) + .on('move', (d) => { + moved = d.data; + }) + .on('moveend', (d) => { + expect(movestarted).toBe('ok'); + expect(moved).toBe('ok'); + expect(d.data).toBe('ok'); + }); + + camera + .on('rotatestart', (d) => { + rotatestarted = d.data; + }) + .on('rotate', (d) => { + rotated = d.data; + }) + .on('rotateend', (d) => { + expect(rotatestarted).toBe('ok'); + expect(rotated).toBe('ok'); + expect(d.data).toBe('ok'); + }); + + camera.rotateTo(90, { duration: 0 }, eventData); + done(); + }); +}); + +describe('#easeTo', () => { + test('pans to specified location', () => { + const camera = createCamera(); + camera.easeTo({ center: [100, 0], duration: 0 }); + expect(camera.getCenter()).toEqual({ lng: 100, lat: 0 }); + }); + + test('zooms to specified level', () => { + const camera = createCamera(); + camera.easeTo({ zoom: 3.2, duration: 0 }); + expect(camera.getZoom()).toBe(3.2); + }); + + test('rotates to specified bearing', () => { + const camera = createCamera(); + camera.easeTo({ bearing: 90, duration: 0 }); + expect(camera.getBearing()).toBe(90); + }); + + test('pitches to specified pitch', () => { + const camera = createCamera(); + camera.easeTo({ pitch: 45, duration: 0 }); + expect(camera.getPitch()).toBe(45); + }); + + test('pans and zooms', () => { + const camera = createCamera(); + camera.easeTo({ center: [100, 0], zoom: 3.2, duration: 0 }); + expect(fixedLngLat(camera.getCenter())).toEqual(fixedLngLat({ lng: 100, lat: 0 })); + expect(camera.getZoom()).toBe(3.2); + }); + + test('zooms around a point', () => { + const camera = createCamera(); + camera.easeTo({ around: [100, 0], zoom: 3, duration: 0 }); + expect(fixedLngLat(camera.getCenter())).toEqual(fixedLngLat({ lng: 87.5, lat: 0 })); + expect(camera.getZoom()).toBe(3); + }); + + test('pans and rotates', () => { + const camera = createCamera(); + camera.easeTo({ center: [100, 0], bearing: 90, duration: 0 }); + expect(camera.getCenter()).toEqual({ lng: 100, lat: 0 }); + expect(camera.getBearing()).toBe(90); + }); + + test('zooms and rotates', () => { + const camera = createCamera(); + camera.easeTo({ zoom: 3.2, bearing: 90, duration: 0 }); + expect(camera.getZoom()).toBe(3.2); + expect(camera.getBearing()).toBe(90); + }); + + test('pans, zooms, and rotates', () => { + const camera = createCamera({ bearing: -90 }); + camera.easeTo({ center: [100, 0], zoom: 3.2, bearing: 90, duration: 0 }); + expect(fixedLngLat(camera.getCenter())).toEqual(fixedLngLat({ lng: 100, lat: 0 })); + expect(camera.getZoom()).toBe(3.2); + expect(camera.getBearing()).toBe(90); + }); + + test('noop', () => { + const camera = createCamera(); + camera.easeTo({ duration: 0 }); + expect(fixedLngLat(camera.getCenter())).toEqual({ lng: 0, lat: 0 }); + expect(camera.getZoom()).toBe(0); + expect(camera.getBearing()).toBeCloseTo(0); + }); + + test('noop with offset', () => { + const camera = createCamera(); + camera.easeTo({ offset: [100, 0], duration: 0 }); + expect(fixedLngLat(camera.getCenter())).toEqual({ lng: 0, lat: 0 }); + expect(camera.getZoom()).toBe(0); + expect(camera.getBearing()).toBeCloseTo(0); + }); + + test('pans with specified offset', () => { + const camera = createCamera(); + camera.easeTo({ center: [100, 0], offset: [100, 0], duration: 0 }); + expect(fixedLngLat(camera.getCenter())).toEqual({ lng: 29.6875, lat: 0 }); + }); + + test('pans with specified offset relative to viewport on a rotated camera', () => { + const camera = createCamera({ bearing: 180 }); + camera.easeTo({ center: [100, 0], offset: [100, 0], duration: 0 }); + expect(fixedLngLat(camera.getCenter())).toEqual({ lng: 170.3125, lat: 0 }); + }); + + test('zooms with specified offset', () => { + const camera = createCamera(); + camera.easeTo({ zoom: 3.2, offset: [100, 0], duration: 0 }); + expect(camera.getZoom()).toBe(3.2); + expect(fixedLngLat(camera.getCenter())).toEqual( + fixedLngLat({ lng: 62.66117668978015, lat: 0 }), + ); + }); + + test('zooms with specified offset relative to viewport on a rotated camera', () => { + const camera = createCamera({ bearing: 180 }); + camera.easeTo({ zoom: 3.2, offset: [100, 0], duration: 0 }); + expect(camera.getZoom()).toBe(3.2); + expect(fixedLngLat(camera.getCenter())).toEqual( + fixedLngLat({ lng: -62.66117668978012, lat: 0 }), + ); + }); + + test('rotates with specified offset', () => { + const camera = createCamera(); + camera.easeTo({ bearing: 90, offset: [100, 0], duration: 0 }); + expect(camera.getBearing()).toBe(90); + expect(fixedLngLat(camera.getCenter())).toEqual( + fixedLngLat({ lng: 70.3125, lat: 0.000002552471840999715 }), + ); + }); + + test('rotates with specified offset relative to viewport on a rotated camera', () => { + const camera = createCamera({ bearing: 180 }); + camera.easeTo({ bearing: 90, offset: [100, 0], duration: 0 }); + expect(camera.getBearing()).toBe(90); + expect(fixedLngLat(camera.getCenter())).toEqual( + fixedLngLat({ lng: -70.3125, lat: 0.000002552471840999715 }), + ); + }); + + test('emits move, zoom, rotate, and pitch events, preserving eventData', (done) => { + const camera = createCamera(); + let movestarted, moved, zoomstarted, zoomed, rotatestarted, rotated, pitchstarted, pitched; + const eventData = { data: 'ok' }; + + expect.assertions(18); + + camera + .on('movestart', (d) => { + movestarted = d.data; + }) + .on('move', (d) => { + moved = d.data; + }) + .on('moveend', (d) => { + expect(camera._zooming).toBeFalsy(); + expect(camera._panning).toBeFalsy(); + expect(camera._rotating).toBeFalsy(); + + expect(movestarted).toBe('ok'); + expect(moved).toBe('ok'); + expect(zoomed).toBe('ok'); + expect(rotated).toBe('ok'); + expect(pitched).toBe('ok'); + expect(d.data).toBe('ok'); + }); + + camera + .on('zoomstart', (d) => { + zoomstarted = d.data; + }) + .on('zoom', (d) => { + zoomed = d.data; + }) + .on('zoomend', (d) => { + expect(zoomstarted).toBe('ok'); + expect(zoomed).toBe('ok'); + expect(d.data).toBe('ok'); + }); + + camera + .on('rotatestart', (d) => { + rotatestarted = d.data; + }) + .on('rotate', (d) => { + rotated = d.data; + }) + .on('rotateend', (d) => { + expect(rotatestarted).toBe('ok'); + expect(rotated).toBe('ok'); + expect(d.data).toBe('ok'); + }); + + camera + .on('pitchstart', (d) => { + pitchstarted = d.data; + }) + .on('pitch', (d) => { + pitched = d.data; + }) + .on('pitchend', (d) => { + expect(pitchstarted).toBe('ok'); + expect(pitched).toBe('ok'); + expect(d.data).toBe('ok'); + }); + + camera.easeTo({ center: [100, 0], zoom: 3.2, bearing: 90, duration: 0, pitch: 45 }, eventData); + done(); + }); + + test('does not emit zoom events if not zooming', (done) => { + const camera = createCamera(); + + camera + .on('zoomstart', () => { + done('zoomstart failed'); + }) + .on('zoom', () => { + done('zoom failed'); + }) + .on('zoomend', () => { + done('zoomend failed'); + }) + .on('moveend', () => { + done(); + }); + + camera.easeTo({ center: [100, 0], duration: 0 }); + }); + + test('stops existing ease', () => { + const camera = createCamera(); + camera.easeTo({ center: [200, 0], duration: 100 }); + camera.easeTo({ center: [100, 0], duration: 0 }); + expect(camera.getCenter()).toEqual({ lng: 100, lat: 0 }); + }); + + test('can be called from within a moveend event handler', (done) => { + const camera = createCamera(); + const stub = jest.spyOn(browser, 'now'); + + stub.mockImplementation(() => 0); + camera.easeTo({ center: [100, 0], duration: 10 }); + + camera.once('moveend', () => { + camera.easeTo({ center: [200, 0], duration: 10 }); + camera.once('moveend', () => { + camera.easeTo({ center: [300, 0], duration: 10 }); + camera.once('moveend', () => { + done(); + }); + + setTimeout(() => { + stub.mockImplementation(() => 30); + camera.simulateFrame(); + }, 0); + }); + + // setTimeout to avoid a synchronous callback + setTimeout(() => { + stub.mockImplementation(() => 20); + camera.simulateFrame(); + }, 0); + }); + + // setTimeout to avoid a synchronous callback + setTimeout(() => { + stub.mockImplementation(() => 10); + camera.simulateFrame(); + }, 0); + }); + + test('pans eastward across the antimeridian', (done) => { + const camera = createCamera(); + const stub = jest.spyOn(browser, 'now'); + + camera.setCenter([170, 0]); + let crossedAntimeridian; + + camera.on('move', () => { + if (camera.getCenter().lng > 170) { + crossedAntimeridian = true; + } + }); + + camera.on('moveend', () => { + expect(crossedAntimeridian).toBeTruthy(); + done(); + }); + + stub.mockImplementation(() => 0); + camera.easeTo({ center: [-170, 0], duration: 10 }); + + setTimeout(() => { + stub.mockImplementation(() => 1); + camera.simulateFrame(); + + setTimeout(() => { + stub.mockImplementation(() => 10); + camera.simulateFrame(); + }, 0); + }, 0); + }); + + test('does not pan eastward across the antimeridian on a single-globe mercator map', (done) => { + const camera = createCamera({ renderWorldCopies: false, zoom: 2 }); + camera.setCenter([170, 0]); + const initialLng = camera.getCenter().lng; + camera.on('moveend', () => { + expect(camera.getCenter().lng).toBeCloseTo(initialLng, 0); + done(); + }); + camera.easeTo({ center: [210, 0], duration: 0 }); + }); + + test('pans westward across the antimeridian', (done) => { + const camera = createCamera(); + const stub = jest.spyOn(browser, 'now'); + + camera.setCenter([-170, 0]); + let crossedAntimeridian; + + camera.on('move', () => { + if (camera.getCenter().lng < -170) { + crossedAntimeridian = true; + } + }); + + camera.on('moveend', () => { + expect(crossedAntimeridian).toBeTruthy(); + done(); + }); + + stub.mockImplementation(() => 0); + camera.easeTo({ center: [170, 0], duration: 10 }); + + setTimeout(() => { + stub.mockImplementation(() => 1); + camera.simulateFrame(); + + setTimeout(() => { + stub.mockImplementation(() => 10); + camera.simulateFrame(); + }, 0); + }, 0); + }); + + test('does not pan westward across the antimeridian on a single-globe mercator map', (done) => { + const camera = createCamera({ renderWorldCopies: false, zoom: 2 }); + camera.setCenter([-170, 0]); + const initialLng = camera.getCenter().lng; + camera.on('moveend', () => { + expect(camera.getCenter().lng).toBeCloseTo(initialLng, 0); + done(); + }); + camera.easeTo({ center: [-210, 0], duration: 0 }); + }); + + test('animation occurs when prefers-reduced-motion: reduce is set but overridden by essential: true', (done) => { + const camera = createCamera(); + Object.defineProperty(browser, 'prefersReducedMotion', { value: true }); + const stubNow = jest.spyOn(browser, 'now'); + + // camera transition expected to take in this range when prefersReducedMotion is set and essential: true, + // when a duration of 200 is requested + const min = 100; + const max = 300; + + let startTime; + camera + .on('movestart', () => { + startTime = browser.now(); + }) + .on('moveend', () => { + const endTime = browser.now(); + const timeDiff = endTime - startTime; + expect(timeDiff >= min && timeDiff < max).toBeTruthy(); + done(); + }); + + setTimeout(() => { + stubNow.mockImplementation(() => 0); + camera.simulateFrame(); + + camera.easeTo({ center: [100, 0], zoom: 3.2, bearing: 90, duration: 200, essential: true }); + + setTimeout(() => { + stubNow.mockImplementation(() => 200); + camera.simulateFrame(); + }, 0); + }, 0); + }); + + test('duration is 0 when prefers-reduced-motion: reduce is set', (done) => { + const camera = createCamera(); + Object.defineProperty(browser, 'prefersReducedMotion', { value: true }); + assertTransitionTime(done, camera, 0, 10); + camera.easeTo({ center: [100, 0], zoom: 3.2, bearing: 90, duration: 1000 }); + }); + + test('jumpTo on("move") during easeTo with zoom, pitch, etc', (done) => { + const camera = createCamera(); + + camera.on('moveend', (e: Event & { done?: true }) => { + if ('done' in e) { + setTimeout(() => { + done(); + }, 50); + } + }); + + camera.easeTo({ zoom: 20, bearing: 90, pitch: 60, duration: 500 }, { done: true }); + camera.once('move', () => { + camera.jumpTo({ pitch: 40 }); + }); + + camera.simulateFrame(); + camera.simulateFrame(); + }); + + test('jumpTo on("zoom") during easeTo', (done) => { + const camera = createCamera(); + + camera.on('moveend', (e: Event & { done?: true }) => { + if ('done' in e) { + setTimeout(() => { + done(); + }, 50); + } + }); + + camera.easeTo({ zoom: 20, duration: 500 }, { done: true }); + camera.once('zoom', () => { + camera.jumpTo({ pitch: 40 }); + }); + + camera.simulateFrame(); + camera.simulateFrame(); + }); + + test('jumpTo on("pitch") during easeTo', (done) => { + const camera = createCamera(); + + camera.on('moveend', (e: Event & { done?: true }) => { + if ('done' in e) { + setTimeout(() => { + done(); + }, 50); + } + }); + + camera.easeTo({ pitch: 60, duration: 500 }, { done: true }); + camera.once('pitch', () => { + camera.jumpTo({ pitch: 40 }); + }); + + camera.simulateFrame(); + camera.simulateFrame(); + }); + + test('jumpTo on("rotate") during easeTo', (done) => { + const camera = createCamera(); + + camera.on('moveend', (e: Event & { done?: true }) => { + if ('done' in e) { + setTimeout(() => { + done(); + }, 50); + } + }); + + camera.easeTo({ bearing: 90, duration: 500 }, { done: true }); + camera.once('rotate', () => { + camera.jumpTo({ pitch: 40 }); + }); + + camera.simulateFrame(); + camera.simulateFrame(); + }); +}); + +describe('#flyTo', () => { + test('pans to specified location', () => { + const camera = createCamera(); + camera.flyTo({ center: [100, 0], animate: false }); + expect(fixedLngLat(camera.getCenter())).toEqual({ lng: 100, lat: 0 }); + }); + + test('throws on invalid center argument', () => { + const camera = createCamera(); + expect(() => { + camera.flyTo({ center: 1 }); + }).toThrow(Error); + }); + + test('does not throw when cameras current zoom is sufficiently greater than passed zoom option', () => { + const camera = createCamera({ zoom: 22, center: [0, 0] }); + expect(() => camera.flyTo({ zoom: 10, center: [0, 0] })).not.toThrow(); + }); + + test('does not throw when cameras current zoom is above maxzoom and an offset creates infinite zoom out factor', () => { + const transform = new Transform(0, 20.9999, 0, 60, true); + transform.resize(512, 512); + const camera = attachSimulateFrame(new CameraMock(transform, {} as any)).jumpTo({ + zoom: 21, + center: [0, 0], + }); + camera._update = () => {}; + expect(() => camera.flyTo({ zoom: 7.5, center: [0, 0], offset: [0, 70] })).not.toThrow(); + }); + + test('zooms to specified level', () => { + const camera = createCamera(); + camera.flyTo({ zoom: 3.2, animate: false }); + expect(fixedNum(camera.getZoom())).toBe(3.2); + }); + + test('zooms to integer level without floating point errors', () => { + const camera = createCamera({ zoom: 0.6 }); + camera.flyTo({ zoom: 2, animate: false }); + expect(camera.getZoom()).toBe(2); + }); + + test('Zoom out from the same position to the same position with animation', (done) => { + const pos = { lng: 0, lat: 0 }; + const camera = createCamera({ zoom: 20, center: pos }); + const stub = jest.spyOn(browser, 'now'); + + camera.once('zoomend', () => { + expect(fixedLngLat(camera.getCenter())).toEqual(fixedLngLat(pos)); + expect(camera.getZoom()).toBe(19); + done(); + }); + + stub.mockImplementation(() => 0); + camera.flyTo({ zoom: 19, center: pos, duration: 2 }); + + stub.mockImplementation(() => 3); + camera.simulateFrame(); + }); + + test('rotates to specified bearing', () => { + const camera = createCamera(); + camera.flyTo({ bearing: 90, animate: false }); + expect(camera.getBearing()).toBe(90); + }); + + test('tilts to specified pitch', () => { + const camera = createCamera(); + camera.flyTo({ pitch: 45, animate: false }); + expect(camera.getPitch()).toBe(45); + }); + + test('pans and zooms', () => { + const camera = createCamera(); + camera.flyTo({ center: [100, 0], zoom: 3.2, animate: false }); + expect(fixedLngLat(camera.getCenter())).toEqual({ lng: 100, lat: 0 }); + expect(fixedNum(camera.getZoom())).toBe(3.2); + }); + + test('pans and rotates', () => { + const camera = createCamera(); + camera.flyTo({ center: [100, 0], bearing: 90, animate: false }); + expect(fixedLngLat(camera.getCenter())).toEqual({ lng: 100, lat: 0 }); + expect(camera.getBearing()).toBe(90); + }); + + test('zooms and rotates', () => { + const camera = createCamera(); + camera.flyTo({ zoom: 3.2, bearing: 90, animate: false }); + expect(fixedNum(camera.getZoom())).toBe(3.2); + expect(camera.getBearing()).toBe(90); + }); + + test('pans, zooms, and rotates', () => { + const camera = createCamera(); + camera.flyTo({ center: [100, 0], zoom: 3.2, bearing: 90, duration: 0, animate: false }); + expect(fixedLngLat(camera.getCenter())).toEqual({ lng: 100, lat: 0 }); + expect(fixedNum(camera.getZoom())).toBe(3.2); + expect(camera.getBearing()).toBe(90); + }); + + test('noop', () => { + const camera = createCamera(); + camera.flyTo({ animate: false }); + expect(fixedLngLat(camera.getCenter())).toEqual({ lng: 0, lat: 0 }); + expect(camera.getZoom()).toBe(0); + expect(camera.getBearing()).toBeCloseTo(0); + }); + + test('noop with offset', () => { + const camera = createCamera(); + camera.flyTo({ offset: [100, 0], animate: false }); + expect(fixedLngLat(camera.getCenter())).toEqual({ lng: 0, lat: 0 }); + expect(camera.getZoom()).toBe(0); + expect(camera.getBearing()).toBeCloseTo(0); + }); + + test('pans with specified offset', () => { + const camera = createCamera(); + camera.flyTo({ center: [100, 0], offset: [100, 0], animate: false }); + expect(fixedLngLat(camera.getCenter())).toEqual({ lng: 29.6875, lat: 0 }); + }); + + test('pans with specified offset relative to viewport on a rotated camera', () => { + const camera = createCamera({ bearing: 180 }); + camera.easeTo({ center: [100, 0], offset: [100, 0], animate: false }); + expect(fixedLngLat(camera.getCenter())).toEqual({ lng: 170.3125, lat: 0 }); + }); + + test('emits move, zoom, rotate, and pitch events, preserving eventData', (done) => { + expect.assertions(18); + + const camera = createCamera(); + let movestarted, moved, zoomstarted, zoomed, rotatestarted, rotated, pitchstarted, pitched; + const eventData = { data: 'ok' }; + + camera + .on('movestart', (d) => { + movestarted = d.data; + }) + .on('move', (d) => { + moved = d.data; + }) + .on('rotate', (d) => { + rotated = d.data; + }) + .on('pitch', (d) => { + pitched = d.data; + }) + .on('moveend', (d) => { + expect(camera._zooming).toBeFalsy(); + expect(camera._panning).toBeFalsy(); + expect(camera._rotating).toBeFalsy(); + + expect(movestarted).toBe('ok'); + expect(moved).toBe('ok'); + expect(zoomed).toBe('ok'); + expect(rotated).toBe('ok'); + expect(pitched).toBe('ok'); + expect(d.data).toBe('ok'); + }); + + camera + .on('zoomstart', (d) => { + zoomstarted = d.data; + }) + .on('zoom', (d) => { + zoomed = d.data; + }) + .on('zoomend', (d) => { + expect(zoomstarted).toBe('ok'); + expect(zoomed).toBe('ok'); + expect(d.data).toBe('ok'); + }); + + camera + .on('rotatestart', (d) => { + rotatestarted = d.data; + }) + .on('rotate', (d) => { + rotated = d.data; + }) + .on('rotateend', (d) => { + expect(rotatestarted).toBe('ok'); + expect(rotated).toBe('ok'); + expect(d.data).toBe('ok'); + }); + + camera + .on('pitchstart', (d) => { + pitchstarted = d.data; + }) + .on('pitch', (d) => { + pitched = d.data; + }) + .on('pitchend', (d) => { + expect(pitchstarted).toBe('ok'); + expect(pitched).toBe('ok'); + expect(d.data).toBe('ok'); + }); + + camera.flyTo( + { center: [100, 0], zoom: 3.2, bearing: 90, duration: 0, pitch: 45, animate: false }, + eventData, + ); + done(); + }); + + test('for short flights, emits (solely) move events, preserving eventData', (done) => { + //As I type this, the code path for guiding super-short flights is (and will probably remain) different. + //As such; it deserves a separate test case. This test case flies the map from A to A. + const camera = createCamera({ center: [100, 0] }); + let movestarted, + moved, + zoomstarted, + zoomed, + zoomended, + rotatestarted, + rotated, + rotateended, + pitchstarted, + pitched, + pitchended; + const eventData = { data: 'ok' }; + + camera + .on('movestart', (d) => { + movestarted = d.data; + }) + .on('move', (d) => { + moved = d.data; + }) + .on('zoomstart', (d) => { + zoomstarted = d.data; + }) + .on('zoom', (d) => { + zoomed = d.data; + }) + .on('zoomend', (d) => { + zoomended = d.data; + }) + .on('rotatestart', (d) => { + rotatestarted = d.data; + }) + .on('rotate', (d) => { + rotated = d.data; + }) + .on('rotateend', (d) => { + rotateended = d.data; + }) + .on('pitchstart', (d) => { + pitchstarted = d.data; + }) + .on('pitch', (d) => { + pitched = d.data; + }) + .on('pitchend', (d) => { + pitchended = d.data; + }) + .on('moveend', (d) => { + expect(camera._zooming).toBeFalsy(); + expect(camera._panning).toBeFalsy(); + expect(camera._rotating).toBeFalsy(); + + expect(movestarted).toBe('ok'); + expect(moved).toBe('ok'); + expect(zoomstarted).toBeUndefined(); + expect(zoomed).toBeUndefined(); + expect(zoomended).toBeUndefined(); + expect(rotatestarted).toBeUndefined(); + expect(rotated).toBeUndefined(); + expect(rotateended).toBeUndefined(); + expect(pitched).toBeUndefined(); + expect(pitchstarted).toBeUndefined(); + expect(pitchended).toBeUndefined(); + expect(d.data).toBe('ok'); + done(); + }); + + const stub = jest.spyOn(browser, 'now'); + stub.mockImplementation(() => 0); + + camera.flyTo({ center: [100, 0], duration: 10 }, eventData); + + setTimeout(() => { + stub.mockImplementation(() => 1); + camera.simulateFrame(); + + setTimeout(() => { + stub.mockImplementation(() => 10); + camera.simulateFrame(); + }, 0); + }, 0); + }); + + test('stops existing ease', (done) => { + const camera = createCamera(); + camera.flyTo({ center: [200, 0], duration: 100 }); + camera.flyTo({ center: [100, 0], duration: 0 }); + expect(fixedLngLat(camera.getCenter())).toEqual({ lng: 100, lat: 0 }); + done(); + }); + + test('can be called from within a moveend event handler', (done) => { + const camera = createCamera(); + const stub = jest.spyOn(browser, 'now'); + stub.mockImplementation(() => 0); + + camera.flyTo({ center: [100, 0], duration: 10 }); + camera.once('moveend', () => { + camera.flyTo({ center: [200, 0], duration: 10 }); + camera.once('moveend', () => { + camera.flyTo({ center: [300, 0], duration: 10 }); + camera.once('moveend', () => { + done(); + }); + }); + }); + + setTimeout(() => { + stub.mockImplementation(() => 10); + camera.simulateFrame(); + + setTimeout(() => { + stub.mockImplementation(() => 20); + camera.simulateFrame(); + + setTimeout(() => { + stub.mockImplementation(() => 30); + camera.simulateFrame(); + }, 0); + }, 0); + }, 0); + }); + + test('ascends', (done) => { + const camera = createCamera(); + camera.setZoom(18); + let ascended; + + camera.on('zoom', () => { + if (camera.getZoom() < 18) { + ascended = true; + } + }); + + camera.on('moveend', () => { + expect(ascended).toBeTruthy(); + done(); + }); + + const stub = jest.spyOn(browser, 'now'); + stub.mockImplementation(() => 0); + + camera.flyTo({ center: [100, 0], zoom: 18, duration: 10 }); + + setTimeout(() => { + stub.mockImplementation(() => 1); + camera.simulateFrame(); + + setTimeout(() => { + stub.mockImplementation(() => 10); + camera.simulateFrame(); + }, 0); + }, 0); + }); + + test('pans eastward across the prime meridian', (done) => { + const camera = createCamera(); + const stub = jest.spyOn(browser, 'now'); + + camera.setCenter([-10, 0]); + let crossedPrimeMeridian; + + camera.on('move', () => { + if (Math.abs(camera.getCenter().lng) < 10) { + crossedPrimeMeridian = true; + } + }); + + camera.on('moveend', () => { + expect(crossedPrimeMeridian).toBeTruthy(); + done(); + }); + + stub.mockImplementation(() => 0); + camera.flyTo({ center: [10, 0], duration: 20 }); + + setTimeout(() => { + stub.mockImplementation(() => 1); + camera.simulateFrame(); + + setTimeout(() => { + stub.mockImplementation(() => 20); + camera.simulateFrame(); + }, 0); + }, 0); + }); + + test('pans westward across the prime meridian', (done) => { + const camera = createCamera(); + const stub = jest.spyOn(browser, 'now'); + + camera.setCenter([10, 0]); + let crossedPrimeMeridian; + + camera.on('move', () => { + if (Math.abs(camera.getCenter().lng) < 10) { + crossedPrimeMeridian = true; + } + }); + + camera.on('moveend', () => { + expect(crossedPrimeMeridian).toBeTruthy(); + done(); + }); + + stub.mockImplementation(() => 0); + camera.flyTo({ center: [-10, 0], duration: 20 }); + + setTimeout(() => { + stub.mockImplementation(() => 1); + camera.simulateFrame(); + + setTimeout(() => { + stub.mockImplementation(() => 20); + camera.simulateFrame(); + }, 0); + }, 0); + }); + + test('pans eastward across the antimeridian', (done) => { + const camera = createCamera(); + const stub = jest.spyOn(browser, 'now'); + + camera.setCenter([170, 0]); + let crossedAntimeridian; + + camera.on('move', () => { + if (camera.getCenter().lng > 170) { + crossedAntimeridian = true; + } + }); + + camera.on('moveend', () => { + expect(crossedAntimeridian).toBeTruthy(); + done(); + }); + + stub.mockImplementation(() => 0); + camera.flyTo({ center: [-170, 0], duration: 20 }); + + setTimeout(() => { + stub.mockImplementation(() => 1); + camera.simulateFrame(); + + setTimeout(() => { + stub.mockImplementation(() => 20); + camera.simulateFrame(); + }, 0); + }, 0); + }); + + test('pans westward across the antimeridian', (done) => { + const camera = createCamera(); + const stub = jest.spyOn(browser, 'now'); + + camera.setCenter([-170, 0]); + let crossedAntimeridian; + + camera.on('move', () => { + if (camera.getCenter().lng < -170) { + crossedAntimeridian = true; + } + }); + + camera.on('moveend', () => { + expect(crossedAntimeridian).toBeTruthy(); + done(); + }); + + stub.mockImplementation(() => 0); + camera.flyTo({ center: [170, 0], duration: 10 }); + + setTimeout(() => { + stub.mockImplementation(() => 1); + camera.simulateFrame(); + + setTimeout(() => { + stub.mockImplementation(() => 10); + camera.simulateFrame(); + }, 0); + }, 0); + }); + + test('does not pan eastward across the antimeridian if no world copies', (done) => { + const camera = createCamera({ renderWorldCopies: false }); + const stub = jest.spyOn(browser, 'now'); + + camera.setCenter([170, 0]); + let crossedAntimeridian; + + camera.on('move', () => { + if (camera.getCenter().lng > 170) { + crossedAntimeridian = true; + } + }); + + camera.on('moveend', () => { + expect(crossedAntimeridian).toBeFalsy(); + done(); + }); + + stub.mockImplementation(() => 0); + camera.flyTo({ center: [-170, 0], duration: 10 }); + + setTimeout(() => { + stub.mockImplementation(() => 1); + camera.simulateFrame(); + + setTimeout(() => { + stub.mockImplementation(() => 10); + camera.simulateFrame(); + }, 0); + }, 0); + }); + + test('does not pan westward across the antimeridian if no world copies', (done) => { + const camera = createCamera({ renderWorldCopies: false }); + const stub = jest.spyOn(browser, 'now'); + + camera.setCenter([-170, 0]); + let crossedAntimeridian; + + camera.on('move', () => { + if (fixedLngLat(camera.getCenter(), 10).lng < -170) { + crossedAntimeridian = true; + } + }); + + camera.on('moveend', () => { + expect(crossedAntimeridian).toBeFalsy(); + done(); + }); + + stub.mockImplementation(() => 0); + camera.flyTo({ center: [170, 0], duration: 10 }); + + setTimeout(() => { + stub.mockImplementation(() => 1); + camera.simulateFrame(); + + setTimeout(() => { + stub.mockImplementation(() => 10); + camera.simulateFrame(); + }, 0); + }, 0); + }); + + test('jumps back to world 0 when crossing the antimeridian', (done) => { + const camera = createCamera(); + const stub = jest.spyOn(browser, 'now'); + + camera.setCenter([-170, 0]); + + let leftWorld0 = false; + + camera.on('move', () => { + leftWorld0 = leftWorld0 || camera.getCenter().lng < -180; + }); + + camera.on('moveend', () => { + expect(leftWorld0).toBeFalsy(); + done(); + }); + + stub.mockImplementation(() => 0); + camera.flyTo({ center: [170, 0], duration: 10 }); + + setTimeout(() => { + stub.mockImplementation(() => 1); + camera.simulateFrame(); + + setTimeout(() => { + stub.mockImplementation(() => 10); + camera.simulateFrame(); + }, 0); + }, 0); + }); + + test('peaks at the specified zoom level', (done) => { + const camera = createCamera({ zoom: 20 }); + const stub = jest.spyOn(browser, 'now'); + + const minZoom = 1; + let zoomed = false; + + camera.on('zoom', () => { + const zoom = camera.getZoom(); + if (zoom < 1) { + fail(`${zoom} should be >= ${minZoom} during flyTo`); + } + + if (camera.getZoom() < minZoom + 1) { + zoomed = true; + } + }); + + camera.on('moveend', () => { + expect(zoomed).toBeTruthy(); + done(); + }); + + stub.mockImplementation(() => 0); + camera.flyTo({ center: [1, 0], zoom: 20, minZoom, duration: 10 }); + + setTimeout(() => { + stub.mockImplementation(() => 3); + camera.simulateFrame(); + + setTimeout(() => { + stub.mockImplementation(() => 10); + camera.simulateFrame(); + }, 0); + }, 0); + }); + + test("respects transform's maxZoom", (done) => { + const transform = new Transform(2, 10, 0, 60, false); + transform.resize(512, 512); + + const camera = attachSimulateFrame(new CameraMock(transform, {} as any)); + camera._update = () => {}; + + camera.on('moveend', () => { + expect(camera.getZoom()).toBeCloseTo(10); + const { lng, lat } = camera.getCenter(); + expect(lng).toBeCloseTo(12); + expect(lat).toBeCloseTo(34); + done(); + }); + + const stub = jest.spyOn(browser, 'now'); + stub.mockImplementation(() => 0); + camera.flyTo({ center: [12, 34], zoom: 30, duration: 10 }); + + setTimeout(() => { + stub.mockImplementation(() => 10); + camera.simulateFrame(); + }, 0); + }); + + test("respects transform's minZoom", (done) => { + const transform = new Transform(2, 10, 0, 60, false); + transform.resize(512, 512); + + const camera = attachSimulateFrame(new CameraMock(transform, {} as any)); + camera._update = () => {}; + + camera.on('moveend', () => { + expect(camera.getZoom()).toBeCloseTo(2); + const { lng, lat } = camera.getCenter(); + expect(lng).toBeCloseTo(12); + expect(lat).toBeCloseTo(34); + done(); + }); + + const stub = jest.spyOn(browser, 'now'); + stub.mockImplementation(() => 0); + camera.flyTo({ center: [12, 34], zoom: 1, duration: 10 }); + + setTimeout(() => { + stub.mockImplementation(() => 10); + camera.simulateFrame(); + }, 0); + }); + + test('resets duration to 0 if it exceeds maxDuration', (done) => { + let startTime, endTime, timeDiff; + const camera = createCamera({ center: [37.63454, 55.75868], zoom: 18 }); + + camera + .on('movestart', () => { + startTime = new Date(); + }) + .on('moveend', () => { + endTime = new Date(); + timeDiff = endTime - startTime; + expect(timeDiff).toBeLessThan(30); + done(); + }); + + camera.flyTo({ center: [-122.3998631, 37.7884307], maxDuration: 100 }); + }); + + test('flys instantly when prefers-reduce-motion:reduce is set', (done) => { + const camera = createCamera(); + Object.defineProperty(browser, 'prefersReducedMotion', { value: true }); + assertTransitionTime(done, camera, 0, 10); + camera.flyTo({ center: [100, 0], bearing: 90, animate: true }); + }); +}); + +describe('#isEasing', () => { + test('returns false when not easing', () => { + const camera = createCamera(); + expect(!camera.isEasing()).toBeTruthy(); + }); + + test('returns true when panning', () => { + const camera = createCamera(); + camera.panTo([100, 0], { duration: 1 }); + expect(camera.isEasing()).toBeTruthy(); + }); + + test('returns false when done panning', (done) => { + const camera = createCamera(); + camera.on('moveend', () => { + expect(!camera.isEasing()).toBeTruthy(); + done(); + }); + const stub = jest.spyOn(browser, 'now'); + stub.mockImplementation(() => 0); + camera.panTo([100, 0], { duration: 1 }); + setTimeout(() => { + stub.mockImplementation(() => 1); + camera.simulateFrame(); + }, 0); + }); + + test('returns true when zooming', () => { + const camera = createCamera(); + camera.zoomTo(3.2, { duration: 1 }); + + expect(camera.isEasing()).toBeTruthy(); + }); + + test('returns false when done zooming', (done) => { + const camera = createCamera(); + camera.on('moveend', () => { + expect(!camera.isEasing()).toBeTruthy(); + done(); + }); + const stub = jest.spyOn(browser, 'now'); + stub.mockImplementation(() => 0); + camera.zoomTo(3.2, { duration: 1 }); + setTimeout(() => { + stub.mockImplementation(() => 1); + camera.simulateFrame(); + }, 0); + }); + + test('returns true when rotating', () => { + const camera = createCamera(); + camera.rotateTo(90, { duration: 1 }); + expect(camera.isEasing()).toBeTruthy(); + }); + + test('returns false when done rotating', (done) => { + const camera = createCamera(); + camera.on('moveend', () => { + expect(!camera.isEasing()).toBeTruthy(); + done(); + }); + const stub = jest.spyOn(browser, 'now'); + stub.mockImplementation(() => 0); + camera.rotateTo(90, { duration: 1 }); + setTimeout(() => { + stub.mockImplementation(() => 1); + camera.simulateFrame(); + }, 0); + }); +}); + +describe('#stop', () => { + test('resets camera._zooming', () => { + const camera = createCamera(); + camera.zoomTo(3.2); + camera.stop(); + expect(!camera._zooming).toBeTruthy(); + }); + + test('resets camera._rotating', () => { + const camera = createCamera(); + camera.rotateTo(90); + camera.stop(); + expect(!camera._rotating).toBeTruthy(); + }); + + test('emits moveend if panning, preserving eventData', (done) => { + const camera = createCamera(); + const eventData = { data: 'ok' }; + + camera.on('moveend', (d) => { + expect(d.data).toBe('ok'); + done(); + }); + + camera.panTo([100, 0], {}, eventData); + camera.stop(); + }); + + test('emits moveend if zooming, preserving eventData', (done) => { + const camera = createCamera(); + const eventData = { data: 'ok' }; + + camera.on('moveend', (d) => { + expect(d.data).toBe('ok'); + done(); + }); + + camera.zoomTo(3.2, {}, eventData); + camera.stop(); + }); + + test('emits moveend if rotating, preserving eventData', (done) => { + const camera = createCamera(); + const eventData = { data: 'ok' }; + + camera.on('moveend', (d) => { + expect(d.data).toBe('ok'); + done(); + }); + + camera.rotateTo(90, {}, eventData); + camera.stop(); + }); + + test('does not emit moveend if not moving', (done) => { + const camera = createCamera(); + const eventData = { data: 'ok' }; + + camera.on('moveend', (d) => { + expect(d.data).toBe('ok'); + camera.stop(); + done(); // Fails with ".end() called twice" if we get here a second time. + }); + + const stub = jest.spyOn(browser, 'now'); + stub.mockImplementation(() => 0); + camera.panTo([100, 0], { duration: 1 }, eventData); + + setTimeout(() => { + stub.mockImplementation(() => 1); + camera.simulateFrame(); + }, 0); + }); +}); + +describe('#cameraForBounds', () => { + test('no options passed', () => { + const camera = createCamera(); + const bb = [ + [-133, 16], + [-68, 50], + ]; + const transform = camera.cameraForBounds(bb); + + expect(fixedLngLat(transform.center, 4)).toEqual({ lng: -100.5, lat: 34.7171 }); + expect(fixedNum(transform.zoom, 3)).toBe(2.469); + }); + + test('bearing positive number', () => { + const camera = createCamera(); + const bb = [ + [-133, 16], + [-68, 50], + ]; + const transform = camera.cameraForBounds(bb, { bearing: 175 }); + + expect(fixedLngLat(transform.center, 4)).toEqual({ lng: -100.5, lat: 34.7171 }); + expect(fixedNum(transform.zoom, 3)).toBe(2.396); + expect(transform.bearing).toBe(175); + }); + + test('bearing negative number', () => { + const camera = createCamera(); + const bb = [ + [-133, 16], + [-68, 50], + ]; + const transform = camera.cameraForBounds(bb, { bearing: -30 }); + + expect(fixedLngLat(transform.center, 4)).toEqual({ lng: -100.5, lat: 34.7171 }); + expect(fixedNum(transform.zoom, 3)).toBe(2.222); + expect(transform.bearing).toBe(-30); + }); + + test('padding number', () => { + const camera = createCamera(); + const bb = [ + [-133, 16], + [-68, 50], + ]; + const transform = camera.cameraForBounds(bb, { padding: 15 }); + + expect(fixedLngLat(transform.center, 4)).toEqual({ lng: -100.5, lat: 34.7171 }); + expect(fixedNum(transform.zoom, 3)).toBe(2.382); + }); + + test('padding object', () => { + const camera = createCamera(); + const bb = [ + [-133, 16], + [-68, 50], + ]; + const transform = camera.cameraForBounds(bb, { + padding: { top: 15, right: 15, bottom: 15, left: 15 }, + duration: 0, + }); + + expect(fixedLngLat(transform.center, 4)).toEqual({ lng: -100.5, lat: 34.7171 }); + }); + + test('asymmetrical padding', () => { + const camera = createCamera(); + const bb = [ + [-133, 16], + [-68, 50], + ]; + const transform = camera.cameraForBounds(bb, { + padding: { top: 10, right: 75, bottom: 50, left: 25 }, + duration: 0, + }); + + expect(fixedLngLat(transform.center, 4)).toEqual({ lng: -96.5558, lat: 32.0833 }); + }); + + test('bearing and asymmetrical padding', () => { + const camera = createCamera(); + const bb = [ + [-133, 16], + [-68, 50], + ]; + const transform = camera.cameraForBounds(bb, { + bearing: 90, + padding: { top: 10, right: 75, bottom: 50, left: 25 }, + duration: 0, + }); + + expect(fixedLngLat(transform.center, 4)).toEqual({ lng: -103.3761, lat: 31.7099 }); + }); + + test('offset', () => { + const camera = createCamera(); + const bb = [ + [-133, 16], + [-68, 50], + ]; + const transform = camera.cameraForBounds(bb, { offset: [0, 100] }); + + expect(fixedLngLat(transform.center, 4)).toEqual({ lng: -100.5, lat: 44.4717 }); + }); + + test('offset and padding', () => { + const camera = createCamera(); + const bb = [ + [-133, 16], + [-68, 50], + ]; + const transform = camera.cameraForBounds(bb, { + padding: { top: 10, right: 75, bottom: 50, left: 25 }, + offset: [0, 100], + }); + + expect(fixedLngLat(transform.center, 4)).toEqual({ lng: -96.5558, lat: 44.4189 }); + }); + + test('bearing, asymmetrical padding, and offset', () => { + const camera = createCamera(); + const bb = [ + [-133, 16], + [-68, 50], + ]; + const transform = camera.cameraForBounds(bb, { + bearing: 90, + padding: { top: 10, right: 75, bottom: 50, left: 25 }, + offset: [0, 100], + duration: 0, + }); + + expect(fixedLngLat(transform.center, 4)).toEqual({ lng: -103.3761, lat: 43.0929 }); + }); + + test('asymmetrical transform using LngLatBounds instance', () => { + const transform = new Transform(2, 10, 0, 60, false); + transform.resize(2048, 512); + + const camera = attachSimulateFrame(new CameraMock(transform, {} as any)); + camera._update = () => {}; + + const bb = new LngLatBounds(); + bb.extend([-66.9326, 49.5904]); + bb.extend([-125.0011, 24.9493]); + + const rotatedTransform = camera.cameraForBounds(bb, { bearing: 45 }); + + expect(fixedLngLat(rotatedTransform.center, 4)).toEqual({ lng: -95.9669, lat: 38.3048 }); + expect(fixedNum(rotatedTransform.zoom, 3)).toBe(2.507); + expect(rotatedTransform.bearing).toBe(45); + }); +}); + +describe('#fitBounds', () => { + test('no padding passed', () => { + const camera = createCamera(); + const bb = [ + [-133, 16], + [-68, 50], + ]; + camera.fitBounds(bb, { duration: 0 }); + + expect(fixedLngLat(camera.getCenter(), 4)).toEqual({ lng: -100.5, lat: 34.7171 }); + expect(fixedNum(camera.getZoom(), 3)).toBe(2.469); + }); + + test('padding number', () => { + const camera = createCamera(); + const bb = [ + [-133, 16], + [-68, 50], + ]; + camera.fitBounds(bb, { padding: 15, duration: 0 }); + + expect(fixedLngLat(camera.getCenter(), 4)).toEqual({ lng: -100.5, lat: 34.7171 }); + expect(fixedNum(camera.getZoom(), 3)).toBe(2.382); + }); + + test('padding object', () => { + const camera = createCamera(); + const bb = [ + [-133, 16], + [-68, 50], + ]; + camera.fitBounds(bb, { padding: { top: 10, right: 75, bottom: 50, left: 25 }, duration: 0 }); + + expect(fixedLngLat(camera.getCenter(), 4)).toEqual({ lng: -96.5558, lat: 32.0833 }); + }); + + test('padding does not get propagated to transform.padding', () => { + const camera = createCamera(); + const bb = [ + [-133, 16], + [-68, 50], + ]; + camera.fitBounds(bb, { padding: { top: 10, right: 75, bottom: 50, left: 25 }, duration: 0 }); + const padding = camera.transform.padding; + + expect(padding).toEqual({ + left: 0, + right: 0, + top: 0, + bottom: 0, + }); + }); +}); + +describe('#fitScreenCoordinates', () => { + test('bearing 225', () => { + const camera = createCamera(); + const p0 = [128, 128]; + const p1 = [256, 256]; + const bearing = 225; + camera.fitScreenCoordinates(p0, p1, bearing, { duration: 0 }); + + expect(fixedLngLat(camera.getCenter(), 4)).toEqual({ lng: -45, lat: 40.9799 }); + expect(fixedNum(camera.getZoom(), 3)).toBe(1.5); + expect(camera.getBearing()).toBe(-135); + }); + + test('bearing 0', () => { + const camera = createCamera(); + const p0 = [128, 128]; + const p1 = [256, 256]; + const bearing = 0; + camera.fitScreenCoordinates(p0, p1, bearing, { duration: 0 }); + + expect(fixedLngLat(camera.getCenter(), 4)).toEqual({ lng: -45, lat: 40.9799 }); + expect(fixedNum(camera.getZoom(), 3)).toBe(2); + expect(camera.getBearing()).toBeCloseTo(0); + }); + + test('inverted points', () => { + const camera = createCamera(); + const p1 = [128, 128]; + const p0 = [256, 256]; + const bearing = 0; + camera.fitScreenCoordinates(p0, p1, bearing, { duration: 0 }); + + expect(fixedLngLat(camera.getCenter(), 4)).toEqual({ lng: -45, lat: 40.9799 }); + expect(fixedNum(camera.getZoom(), 3)).toBe(2); + expect(camera.getBearing()).toBeCloseTo(0); + }); +}); + +describe('#transformCameraUpdate', () => { + test('invoke transformCameraUpdate callback during jumpTo', (done) => { + const camera = createCamera(); + + let callbackCount = 0; + let eventCount = 0; + + camera.transformCameraUpdate = () => { + callbackCount++; + return {}; + }; + + camera + .on('move', () => { + eventCount++; + expect(eventCount).toBe(callbackCount); + }) + .on('moveend', () => { + done(); + }); + + camera.jumpTo({ center: [100, 0] }); + }); + + test('invoke transformCameraUpdate callback during easeTo', (done) => { + expect.assertions(2); + const camera = createCamera(); + const stub = jest.spyOn(browser, 'now'); + stub.mockImplementation(() => 0); + + let callbackCount = 0; + let eventCount = 0; + + camera.transformCameraUpdate = () => { + callbackCount++; + return {}; + }; + + camera + .on('move', () => { + eventCount++; + expect(eventCount).toBe(callbackCount); + }) + .on('moveend', () => { + done(); + }); + + camera.easeTo({ center: [100, 0], duration: 10 }); + + setTimeout(() => { + stub.mockImplementation(() => 1); + camera.simulateFrame(); + + setTimeout(() => { + stub.mockImplementation(() => 10); + camera.simulateFrame(); + }, 0); + }, 0); + }); + + test('invoke transformCameraUpdate callback during flyTo', (done) => { + expect.assertions(2); + const camera = createCamera(); + const stub = jest.spyOn(browser, 'now'); + stub.mockImplementation(() => 0); + + let callbackCount = 0; + let eventCount = 0; + + camera.transformCameraUpdate = () => { + callbackCount++; + return {}; + }; + + camera + .on('move', () => { + eventCount++; + expect(eventCount).toBe(callbackCount); + }) + .on('moveend', () => { + done(); + }); + + camera.flyTo({ center: [100, 0], duration: 10 }); + + setTimeout(() => { + stub.mockImplementation(() => 1); + camera.simulateFrame(); + + setTimeout(() => { + stub.mockImplementation(() => 10); + camera.simulateFrame(); + }, 0); + }, 0); + }); + + test('transformCameraUpdate overrides proposed camera settings', () => { + const camera = createCamera(); + + camera.transformCameraUpdate = ({ center, zoom }) => { + return { + center: LngLat.convert([center.lng, center.lat + 10]), + zoom: Math.round(zoom), + }; + }; + + camera.flyTo({ center: [100, 0], zoom: 3.2, animate: false }); + expect(fixedLngLat(camera.getCenter())).toEqual({ lng: 100, lat: 10 }); + expect(fixedNum(camera.getZoom())).toBe(3); + }); +}); diff --git a/packages/map/__tests__/geo/edge_insets.spec.ts b/packages/map/__tests__/geo/edge_insets.spec.ts new file mode 100644 index 00000000000..a46dc483c08 --- /dev/null +++ b/packages/map/__tests__/geo/edge_insets.spec.ts @@ -0,0 +1,81 @@ +import { EdgeInsets } from '../../src/map/geo/edge_insets'; + +describe('EdgeInsets', () => { + describe('#constructor', () => { + test('creates an object with default values', () => { + expect(new EdgeInsets() instanceof EdgeInsets).toBeTruthy(); + }); + + test('invalid initialization', () => { + expect(() => { + new EdgeInsets(NaN, 10); + }).toThrow('Invalid value for edge-insets, top, bottom, left and right must all be numbers'); + + expect(() => { + new EdgeInsets(-10, 10, 20, 10); + }).toThrow('Invalid value for edge-insets, top, bottom, left and right must all be numbers'); + }); + + test('valid initialization', () => { + const top = 10; + const bottom = 15; + const left = 26; + const right = 19; + + const inset = new EdgeInsets(top, bottom, left, right); + expect(inset.top).toBe(top); + expect(inset.bottom).toBe(bottom); + expect(inset.left).toBe(left); + expect(inset.right).toBe(right); + }); + }); + + describe('#getCenter', () => { + test('valid input', () => { + const inset = new EdgeInsets(10, 15, 50, 10); + const center = inset.getCenter(600, 400); + expect(center.x).toBe(320); + expect(center.y).toBe(197.5); + }); + + test('center clamping', () => { + const inset = new EdgeInsets(300, 200, 500, 200); + const center = inset.getCenter(600, 400); + + // Midpoint of the overlap when padding overlaps + expect(center.x).toBe(450); + expect(center.y).toBe(250); + }); + }); + + describe('#interpolate', () => { + test('it works', () => { + const inset1 = new EdgeInsets(10, 15, 50, 10); + const inset2 = new EdgeInsets(20, 30, 100, 10); + const inset3 = inset1.interpolate(inset1, inset2, 0.5); + + // inset1 is mutated in-place + expect(inset3).toBe(inset1); + + expect(inset3.top).toBe(15); + expect(inset3.bottom).toBe(22.5); + expect(inset3.left).toBe(75); + expect(inset3.right).toBe(10); + }); + }); + + test('#equals', () => { + const inset1 = new EdgeInsets(10, 15, 50, 10); + const inset2 = new EdgeInsets(10, 15, 50, 10); + const inset3 = new EdgeInsets(10, 15, 50, 11); + expect(inset1.equals(inset2)).toBeTruthy(); + expect(inset2.equals(inset3)).toBeFalsy(); + }); + + test('#clone', () => { + const inset1 = new EdgeInsets(10, 15, 50, 10); + const inset2 = inset1.clone(); + expect(inset2 === inset1).toBeFalsy(); + expect(inset1.equals(inset2)).toBeTruthy(); + }); +}); diff --git a/packages/map/__tests__/geo/lng_lat.spec.ts b/packages/map/__tests__/geo/lng_lat.spec.ts new file mode 100644 index 00000000000..28624475649 --- /dev/null +++ b/packages/map/__tests__/geo/lng_lat.spec.ts @@ -0,0 +1,65 @@ +import { LngLat } from '../../src/map/geo/lng_lat'; + +describe('LngLat', () => { + test('#constructor', () => { + expect(new LngLat(0, 0) instanceof LngLat).toBeTruthy(); + + expect(() => { + /*eslint no-new: 0*/ + new LngLat(0, -91); + }).toThrow('Invalid LngLat latitude value: must be between -90 and 90'); + + expect(() => { + /*eslint no-new: 0*/ + new LngLat(0, 91); + }).toThrow('Invalid LngLat latitude value: must be between -90 and 90'); + }); + + test('#convert', () => { + expect(LngLat.convert([0, 10]) instanceof LngLat).toBeTruthy(); + expect(LngLat.convert({ lng: 0, lat: 10 }) instanceof LngLat).toBeTruthy(); + expect(LngLat.convert({ lng: 0, lat: 0 }) instanceof LngLat).toBeTruthy(); + expect(LngLat.convert({ lon: 0, lat: 10 }) instanceof LngLat).toBeTruthy(); + expect(LngLat.convert({ lon: 0, lat: 0 }) instanceof LngLat).toBeTruthy(); + expect(LngLat.convert(new LngLat(0, 0)) instanceof LngLat).toBeTruthy(); + }); + + test('#wrap', () => { + expect(new LngLat(0, 0).wrap()).toEqual({ lng: 0, lat: 0 }); + expect(new LngLat(10, 20).wrap()).toEqual({ lng: 10, lat: 20 }); + expect(new LngLat(360, 0).wrap()).toEqual({ lng: 0, lat: 0 }); + expect(new LngLat(190, 0).wrap()).toEqual({ lng: -170, lat: 0 }); + }); + + test('#toArray', () => { + expect(new LngLat(10, 20).toArray()).toEqual([10, 20]); + }); + + test('#toString', () => { + expect(new LngLat(10, 20).toString()).toBe('LngLat(10, 20)'); + }); + + test('#distanceTo', () => { + const newYork = new LngLat(-74.006, 40.7128); + const losAngeles = new LngLat(-118.2437, 34.0522); + const d = newYork.distanceTo(losAngeles); // 3935751.690893987, "true distance" is 3966km + expect(d > 3935750).toBeTruthy(); + expect(d < 3935752).toBeTruthy(); + }); + + test('#distanceTo to pole', () => { + const newYork = new LngLat(-74.006, 40.7128); + const northPole = new LngLat(-135, 90); + const d = newYork.distanceTo(northPole); // 5480494.158486183 , "true distance" is 5499km + expect(d > 5480493).toBeTruthy(); + expect(d < 5480495).toBeTruthy(); + }); + + test('#distanceTo to Null Island', () => { + const newYork = new LngLat(-74.006, 40.7128); + const nullIsland = new LngLat(0, 0); + const d = newYork.distanceTo(nullIsland); // 8667080.125666846 , "true distance" is 8661km + expect(d > 8667079).toBeTruthy(); + expect(d < 8667081).toBeTruthy(); + }); +}); diff --git a/packages/map/__tests__/geo/lng_lat_bounds.spec.ts b/packages/map/__tests__/geo/lng_lat_bounds.spec.ts new file mode 100644 index 00000000000..1ee5c63efdc --- /dev/null +++ b/packages/map/__tests__/geo/lng_lat_bounds.spec.ts @@ -0,0 +1,249 @@ +import { LngLat } from '../../src/map/geo/lng_lat'; +import { LngLatBounds } from '../../src/map/geo/lng_lat_bounds'; + +describe('LngLatBounds', () => { + test('#constructor', () => { + const sw = new LngLat(0, 0); + const ne = new LngLat(-10, 10); + const bounds = new LngLatBounds(sw, ne); + expect(bounds.getSouth()).toBe(0); + expect(bounds.getWest()).toBe(0); + expect(bounds.getNorth()).toBe(10); + expect(bounds.getEast()).toBe(-10); + }); + + test('#constructor across dateline', () => { + const sw = new LngLat(170, 0); + const ne = new LngLat(-170, 10); + const bounds = new LngLatBounds(sw, ne); + expect(bounds.getSouth()).toBe(0); + expect(bounds.getWest()).toBe(170); + expect(bounds.getNorth()).toBe(10); + expect(bounds.getEast()).toBe(-170); + }); + + test('#constructor across pole', () => { + const sw = new LngLat(0, 85); + const ne = new LngLat(-10, -85); + const bounds = new LngLatBounds(sw, ne); + expect(bounds.getSouth()).toBe(85); + expect(bounds.getWest()).toBe(0); + expect(bounds.getNorth()).toBe(-85); + expect(bounds.getEast()).toBe(-10); + }); + + test('#constructor no args', () => { + const bounds = new LngLatBounds(); + const t1 = () => { + bounds.getCenter(); + }; + expect(t1).toThrow(); + }); + + test('#extend with coordinate', () => { + const bounds = new LngLatBounds([0, 0], [10, 10]); + bounds.extend([-10, -10]); + + expect(bounds.getSouth()).toBe(-10); + expect(bounds.getWest()).toBe(-10); + expect(bounds.getNorth()).toBe(10); + expect(bounds.getEast()).toBe(10); + + bounds.extend(new LngLat(-15, -15)); + + expect(bounds.getSouth()).toBe(-15); + expect(bounds.getWest()).toBe(-15); + expect(bounds.getNorth()).toBe(10); + expect(bounds.getEast()).toBe(10); + + bounds.extend([-80, -80, 80, 80]); + + expect(bounds.getSouth()).toBe(-80); + expect(bounds.getWest()).toBe(-80); + expect(bounds.getNorth()).toBe(80); + expect(bounds.getEast()).toBe(80); + + bounds.extend({ lng: -90, lat: -90 }); + + expect(bounds.getSouth()).toBe(-90); + expect(bounds.getWest()).toBe(-90); + expect(bounds.getNorth()).toBe(80); + expect(bounds.getEast()).toBe(80); + + bounds.extend({ lon: 90, lat: 90 }); + + expect(bounds.getSouth()).toBe(-90); + expect(bounds.getWest()).toBe(-90); + expect(bounds.getNorth()).toBe(90); + expect(bounds.getEast()).toBe(90); + }); + + test('#extend with bounds', () => { + const bounds1 = new LngLatBounds([0, 0], [10, 10]); + const bounds2 = new LngLatBounds([-10, -10], [10, 10]); + + bounds1.extend(bounds2); + + expect(bounds1.getSouth()).toBe(-10); + expect(bounds1.getWest()).toBe(-10); + expect(bounds1.getNorth()).toBe(10); + expect(bounds1.getEast()).toBe(10); + + const bounds4 = new LngLatBounds([-20, -20, 20, 20]); + bounds1.extend(bounds4); + + expect(bounds1.getSouth()).toBe(-20); + expect(bounds1.getWest()).toBe(-20); + expect(bounds1.getNorth()).toBe(20); + expect(bounds1.getEast()).toBe(20); + + const bounds5 = new LngLatBounds(); + bounds1.extend(bounds5); + + expect(bounds1.getSouth()).toBe(-20); + expect(bounds1.getWest()).toBe(-20); + expect(bounds1.getNorth()).toBe(20); + expect(bounds1.getEast()).toBe(20); + }); + + test('#extend with null', () => { + const bounds = new LngLatBounds([0, 0], [10, 10]); + + bounds.extend(null); + + expect(bounds.getSouth()).toBe(0); + expect(bounds.getWest()).toBe(0); + expect(bounds.getNorth()).toBe(10); + expect(bounds.getEast()).toBe(10); + }); + + test('#extend undefined bounding box', () => { + const bounds1 = new LngLatBounds(undefined, undefined); + const bounds2 = new LngLatBounds([-10, -10], [10, 10]); + + bounds1.extend(bounds2); + + expect(bounds1.getSouth()).toBe(-10); + expect(bounds1.getWest()).toBe(-10); + expect(bounds1.getNorth()).toBe(10); + expect(bounds1.getEast()).toBe(10); + }); + + test('#extend same LngLat instance', () => { + const point = new LngLat(0, 0); + const bounds = new LngLatBounds(point, point); + + bounds.extend(new LngLat(15, 15)); + + expect(bounds.getSouth()).toBe(0); + expect(bounds.getWest()).toBe(0); + expect(bounds.getNorth()).toBe(15); + expect(bounds.getEast()).toBe(15); + }); + + test('accessors', () => { + const sw = new LngLat(0, 0); + const ne = new LngLat(-10, -20); + const bounds = new LngLatBounds(sw, ne); + expect(bounds.getCenter()).toEqual(new LngLat(-5, -10)); + expect(bounds.getSouth()).toBe(0); + expect(bounds.getWest()).toBe(0); + expect(bounds.getNorth()).toBe(-20); + expect(bounds.getEast()).toBe(-10); + expect(bounds.getSouthWest()).toEqual(new LngLat(0, 0)); + expect(bounds.getSouthEast()).toEqual(new LngLat(-10, 0)); + expect(bounds.getNorthEast()).toEqual(new LngLat(-10, -20)); + expect(bounds.getNorthWest()).toEqual(new LngLat(0, -20)); + }); + + test('#convert', () => { + const sw = new LngLat(0, 0); + const ne = new LngLat(-10, 10); + const bounds = new LngLatBounds(sw, ne); + expect(LngLatBounds.convert(bounds)).toEqual(bounds); + expect(LngLatBounds.convert([sw, ne])).toEqual(bounds); + expect( + LngLatBounds.convert([ + bounds.getWest(), + bounds.getSouth(), + bounds.getEast(), + bounds.getNorth(), + ]), + ).toEqual(bounds); + }); + + test('#toArray', () => { + const llb = new LngLatBounds([-73.9876, 40.7661], [-73.9397, 40.8002]); + expect(llb.toArray()).toEqual([ + [-73.9876, 40.7661], + [-73.9397, 40.8002], + ]); + }); + + test('#toString', () => { + const llb = new LngLatBounds([-73.9876, 40.7661], [-73.9397, 40.8002]); + expect(llb.toString()).toBe( + 'LngLatBounds(LngLat(-73.9876, 40.7661), LngLat(-73.9397, 40.8002))', + ); + }); + + test('#isEmpty', () => { + const nullBounds = new LngLatBounds(); + expect(nullBounds.isEmpty()).toBe(true); + + const sw = new LngLat(0, 0); + const ne = new LngLat(-10, 10); + const bounds = new LngLatBounds(sw, ne); + expect(bounds.isEmpty()).toBe(false); + }); + + test('#fromLngLat', () => { + const center0 = new LngLat(0, 0); + const center1 = new LngLat(-73.9749, 40.7736); + + const center0Radius10 = LngLatBounds.fromLngLat(center0, 10); + const center1Radius10 = LngLatBounds.fromLngLat(center1, 10); + const center1Radius0 = LngLatBounds.fromLngLat(center1); + + expect(center0Radius10.toArray()).toEqual([ + [-0.00008983152770714982, -0.00008983152770714982], + [0.00008983152770714982, 0.00008983152770714982], + ]); + expect(center1Radius10.toArray()).toEqual([ + [-73.97501862141328, 40.77351016847229], + [-73.97478137858673, 40.77368983152771], + ]); + expect(center1Radius0.toArray()).toEqual([ + [-73.9749, 40.7736], + [-73.9749, 40.7736], + ]); + }); + + describe('contains', () => { + describe('point', () => { + test('point is in bounds', () => { + const llb = new LngLatBounds([-1, -1], [1, 1]); + const ll = { lng: 0, lat: 0 }; + expect(llb.contains(ll)).toBeTruthy(); + }); + + test('point is not in bounds', () => { + const llb = new LngLatBounds([-1, -1], [1, 1]); + const ll = { lng: 3, lat: 3 }; + expect(llb.contains(ll)).toBeFalsy(); + }); + + test('point is in bounds that spans dateline', () => { + const llb = new LngLatBounds([190, -10], [170, 10]); + const ll = { lng: 180, lat: 0 }; + expect(llb.contains(ll)).toBeTruthy(); + }); + + test('point is not in bounds that spans dateline', () => { + const llb = new LngLatBounds([190, -10], [170, 10]); + const ll = { lng: 0, lat: 0 }; + expect(llb.contains(ll)).toBeFalsy(); + }); + }); + }); +}); diff --git a/packages/map/__tests__/geo/mercator_coordinate.spec.ts b/packages/map/__tests__/geo/mercator_coordinate.spec.ts new file mode 100644 index 00000000000..e0647639438 --- /dev/null +++ b/packages/map/__tests__/geo/mercator_coordinate.spec.ts @@ -0,0 +1,36 @@ +import { LngLat } from '../../src/map/geo/lng_lat'; +import { MercatorCoordinate, mercatorScale } from '../../src/map/geo/mercator_coordinate'; + +describe('LngLat', () => { + test('#constructor', () => { + expect(new MercatorCoordinate(0, 0) instanceof MercatorCoordinate).toBeTruthy(); + expect(new MercatorCoordinate(0, 0, 0) instanceof MercatorCoordinate).toBeTruthy(); + }); + + test('#fromLngLat', () => { + const nullIsland = new LngLat(0, 0); + expect(MercatorCoordinate.fromLngLat(nullIsland)).toEqual({ x: 0.5, y: 0.5, z: 0 }); + }); + + test('#toLngLat', () => { + const dc = new LngLat(-77, 39); + expect(MercatorCoordinate.fromLngLat(dc, 500).toLngLat()).toEqual({ lng: -77, lat: 39 }); + }); + + test('#toAltitude', () => { + const dc = new LngLat(-77, 39); + expect(MercatorCoordinate.fromLngLat(dc, 500).toAltitude()).toBe(500); + }); + + test('#mercatorScale', () => { + expect(mercatorScale(0)).toBe(1); + expect(mercatorScale(45)).toBe(1.414213562373095); + }); + + test('#meterInMercatorCoordinateUnits', () => { + const nullIsland = new LngLat(0, 0); + expect(MercatorCoordinate.fromLngLat(nullIsland).meterInMercatorCoordinateUnits()).toBe( + 2.4981121214570498e-8, + ); + }); +}); diff --git a/packages/map/__tests__/geo/transform.spec.ts b/packages/map/__tests__/geo/transform.spec.ts new file mode 100644 index 00000000000..10fe9fac1c6 --- /dev/null +++ b/packages/map/__tests__/geo/transform.spec.ts @@ -0,0 +1,294 @@ +import Point from '@mapbox/point-geometry'; +import { LngLat } from '../../src/map/geo/lng_lat'; +import { MAX_VALID_LATITUDE, Transform } from '../../src/map/geo/transform'; +import { fixedCoord, fixedLngLat } from '../libs/fixed'; + +describe('transform', () => { + test('creates a transform', () => { + const transform = new Transform(0, 22, 0, 60, true); + transform.resize(500, 500); + expect(transform.unmodified).toBe(true); + expect(transform.tileSize).toBe(512); + expect(transform.worldSize).toBe(512); + expect(transform.width).toBe(500); + expect(transform.minZoom).toBe(0); + expect(transform.minPitch).toBe(0); + // Support signed zero + expect(transform.bearing === 0 ? 0 : transform.bearing).toBe(0); + expect((transform.bearing = 1)).toBe(1); + expect(transform.bearing).toBe(1); + expect((transform.bearing = 0)).toBe(0); + expect(transform.unmodified).toBe(false); + expect((transform.minZoom = 10)).toBe(10); + expect((transform.maxZoom = 10)).toBe(10); + expect(transform.minZoom).toBe(10); + expect(transform.center).toEqual({ lng: 0, lat: 0 }); + expect(transform.maxZoom).toBe(10); + expect((transform.minPitch = 10)).toBe(10); + expect((transform.maxPitch = 10)).toBe(10); + expect(transform.size.equals(new Point(500, 500))).toBe(true); + expect(transform.centerPoint.equals(new Point(250, 250))).toBe(true); + expect(transform.scaleZoom(0)).toBe(-Infinity); + expect(transform.scaleZoom(10)).toBe(3.3219280948873626); + expect(transform.point).toEqual(new Point(262144, 262144)); + expect(transform.height).toBe(500); + expect(fixedLngLat(transform.pointLocation(new Point(250, 250)))).toEqual({ lng: 0, lat: 0 }); + expect(fixedCoord(transform.pointCoordinate(new Point(250, 250)))).toEqual({ + x: 0.5, + y: 0.5, + z: 0, + }); + expect(transform.locationPoint(new LngLat(0, 0))).toEqual({ x: 250, y: 250 }); + expect(transform.locationCoordinate(new LngLat(0, 0))).toEqual({ x: 0.5, y: 0.5, z: 0 }); + }); + + test('does not throw on bad center', () => { + expect(() => { + const transform = new Transform(0, 22, 0, 60, true); + transform.resize(500, 500); + transform.center = new LngLat(50, -90); + }).not.toThrow(); + }); + + test('setLocationAt', () => { + const transform = new Transform(0, 22, 0, 60, true); + transform.resize(500, 500); + transform.zoom = 4; + expect(transform.center).toEqual({ lng: 0, lat: 0 }); + transform.setLocationAtPoint(new LngLat(13, 10), new Point(15, 45)); + expect(fixedLngLat(transform.pointLocation(new Point(15, 45)))).toEqual({ lng: 13, lat: 10 }); + }); + + test('setLocationAt tilted', () => { + const transform = new Transform(0, 22, 0, 60, true); + transform.resize(500, 500); + transform.zoom = 4; + transform.pitch = 50; + expect(transform.center).toEqual({ lng: 0, lat: 0 }); + transform.setLocationAtPoint(new LngLat(13, 10), new Point(15, 45)); + expect(fixedLngLat(transform.pointLocation(new Point(15, 45)))).toEqual({ lng: 13, lat: 10 }); + }); + + test('has a default zoom', () => { + const transform = new Transform(0, 22, 0, 60, true); + transform.resize(500, 500); + expect(transform.tileZoom).toBe(0); + expect(transform.tileZoom).toBe(transform.zoom); + }); + + test('set zoom inits tileZoom with zoom value', () => { + const transform = new Transform(0, 22, 0, 60); + transform.zoom = 5; + expect(transform.tileZoom).toBe(5); + }); + + test('set zoom clamps tileZoom to non negative value ', () => { + const transform = new Transform(-2, 22, 0, 60); + transform.zoom = -2; + expect(transform.tileZoom).toBe(0); + }); + + test('set fov', () => { + const transform = new Transform(0, 22, 0, 60, true); + transform.fov = 10; + expect(transform.fov).toBe(10); + transform.fov = 10; + expect(transform.fov).toBe(10); + }); + + test('lngRange & latRange constrain zoom and center', () => { + const transform = new Transform(0, 22, 0, 60, true); + transform.center = new LngLat(0, 0); + transform.zoom = 10; + transform.resize(500, 500); + + transform.lngRange = [-5, 5]; + transform.latRange = [-5, 5]; + + transform.zoom = 0; + expect(transform.zoom).toBe(5.1357092861044045); + + transform.center = new LngLat(-50, -30); + expect(transform.center).toEqual(new LngLat(0, -0.0063583052861417855)); + + transform.zoom = 10; + transform.center = new LngLat(-50, -30); + expect(transform.center).toEqual(new LngLat(-4.828338623046875, -4.828969771321582)); + }); + + test('lngRange can constrain zoom and center across meridian', () => { + const transform = new Transform(0, 22, 0, 60, true); + transform.center = new LngLat(180, 0); + transform.zoom = 10; + transform.resize(500, 500); + + // equivalent ranges + const lngRanges: [number, number][] = [ + [175, -175], + [175, 185], + [-185, -175], + [-185, 185], + ]; + + for (const lngRange of lngRanges) { + transform.lngRange = lngRange; + transform.latRange = [-5, 5]; + + transform.zoom = 0; + expect(transform.zoom).toBe(5.1357092861044045); + + transform.center = new LngLat(-50, -30); + expect(transform.center).toEqual(new LngLat(180, -0.0063583052861417855)); + + transform.zoom = 10; + transform.center = new LngLat(-50, -30); + expect(transform.center).toEqual(new LngLat(-175.171661376953125, -4.828969771321582)); + + transform.center = new LngLat(230, 0); + expect(transform.center).toEqual(new LngLat(-175.171661376953125, 0)); + + transform.center = new LngLat(130, 0); + expect(transform.center).toEqual(new LngLat(175.171661376953125, 0)); + } + }); + + test('coveringZoomLevel', () => { + const options = { + minzoom: 1, + maxzoom: 10, + tileSize: 512, + roundZoom: false, + }; + + const transform = new Transform(0, 22, 0, 60, true); + + transform.zoom = 0; + expect(transform.coveringZoomLevel(options)).toBe(0); + + transform.zoom = 0.1; + expect(transform.coveringZoomLevel(options)).toBe(0); + + transform.zoom = 1; + expect(transform.coveringZoomLevel(options)).toBe(1); + + transform.zoom = 2.4; + expect(transform.coveringZoomLevel(options)).toBe(2); + + transform.zoom = 10; + expect(transform.coveringZoomLevel(options)).toBe(10); + + transform.zoom = 11; + expect(transform.coveringZoomLevel(options)).toBe(11); + + transform.zoom = 11.5; + expect(transform.coveringZoomLevel(options)).toBe(11); + + options.tileSize = 256; + + transform.zoom = 0; + expect(transform.coveringZoomLevel(options)).toBe(1); + + transform.zoom = 0.1; + expect(transform.coveringZoomLevel(options)).toBe(1); + + transform.zoom = 1; + expect(transform.coveringZoomLevel(options)).toBe(2); + + transform.zoom = 2.4; + expect(transform.coveringZoomLevel(options)).toBe(3); + + transform.zoom = 10; + expect(transform.coveringZoomLevel(options)).toBe(11); + + transform.zoom = 11; + expect(transform.coveringZoomLevel(options)).toBe(12); + + transform.zoom = 11.5; + expect(transform.coveringZoomLevel(options)).toBe(12); + + options.roundZoom = true; + + expect(transform.coveringZoomLevel(options)).toBe(13); + }); + + test('clamps latitude', () => { + const transform = new Transform(0, 22, 0, 60, true); + + expect(transform.project(new LngLat(0, -90))).toEqual( + transform.project(new LngLat(0, -MAX_VALID_LATITUDE)), + ); + expect(transform.project(new LngLat(0, 90))).toEqual( + transform.project(new LngLat(0, MAX_VALID_LATITUDE)), + ); + }); + + test('clamps pitch', () => { + const transform = new Transform(0, 22, 0, 60, true); + + transform.pitch = 45; + expect(transform.pitch).toBe(45); + + transform.pitch = -10; + expect(transform.pitch).toBe(0); + + transform.pitch = 90; + expect(transform.pitch).toBe(60); + }); + + test('maintains high float precision when calculating matrices', () => { + const transform = new Transform(0, 22, 0, 60, true); + transform.resize(200.25, 200.25); + transform.zoom = 20.25; + transform.pitch = 67.25; + transform.center = new LngLat(0.0, 0.0); + transform._calcMatrices(); + + expect(transform.customLayerMatrix()[0].toString().length).toBeGreaterThan(10); + expect(transform.glCoordMatrix[0].toString().length).toBeGreaterThan(10); + expect(transform.maxPitchScaleFactor()).toBeCloseTo(2.366025418080343, 5); + }); + + test('pointCoordinate with terrain when returning null should fall back to 2D', () => { + const transform = new Transform(0, 22, 0, 60, true); + transform.resize(500, 500); + const coordinate = transform.pointCoordinate(new Point(0, 0)); + + expect(coordinate).toBeDefined(); + }); + + test('horizon', () => { + const transform = new Transform(0, 22, 0, 85, true); + transform.resize(500, 500); + transform.pitch = 75; + const horizon = transform.getHorizon(); + + expect(horizon).toBeCloseTo(170.8176101748407, 10); + }); + + test('getBounds with horizon', () => { + const transform = new Transform(0, 22, 0, 85, true); + transform.resize(500, 500); + + transform.pitch = 60; + expect(transform.getBounds().getNorthWest().toArray()).toStrictEqual( + transform.pointLocation(new Point(0, 0)).toArray(), + ); + + transform.pitch = 75; + const top = Math.max(0, transform.height / 2 - transform.getHorizon()); + expect(top).toBeCloseTo(79.1823898251593, 10); + expect(transform.getBounds().getNorthWest().toArray()).toStrictEqual( + transform.pointLocation(new Point(0, top)).toArray(), + ); + }); + + test('lngLatToCameraDepth', () => { + const transform = new Transform(0, 22, 0, 85, true); + transform.resize(500, 500); + transform.center = new LngLat(10.0, 50.0); + + expect(transform.lngLatToCameraDepth(new LngLat(10, 50), 4)).toBeCloseTo(0.9997324396231673); + transform.pitch = 60; + expect(transform.lngLatToCameraDepth(new LngLat(10, 50), 4)).toBeCloseTo(0.9865782165762236); + }); +}); diff --git a/packages/map/__tests__/handler/box_zoom.spec.ts b/packages/map/__tests__/handler/box_zoom.spec.ts new file mode 100644 index 00000000000..35a9104f3ed --- /dev/null +++ b/packages/map/__tests__/handler/box_zoom.spec.ts @@ -0,0 +1,163 @@ +import { Map } from '../../src/map/map'; +import { DOM } from '../../src/map/util/dom'; +import simulate from '../libs/simulate_interaction'; +import { beforeMapTest } from '../libs/util'; + +function createMap(clickTolerance) { + return new Map({ container: DOM.create('div', '', window.document.body), clickTolerance }); +} + +beforeEach(() => { + beforeMapTest(); +}); + +describe('BoxZoomHandler', () => { + test('fires boxzoomstart and boxzoomend events at appropriate times', () => { + const map = createMap(undefined); + + const boxzoomstart = jest.fn(); + const boxzoomend = jest.fn(); + + map.on('boxzoomstart', boxzoomstart); + map.on('boxzoomend', boxzoomend); + + simulate.mousedown(map.getCanvasContainer(), { shiftKey: true, clientX: 0, clientY: 0 }); + map._renderTaskQueue.run(); + expect(boxzoomstart).not.toHaveBeenCalled(); + expect(boxzoomend).not.toHaveBeenCalled(); + + simulate.mousemove(map.getCanvasContainer(), { shiftKey: true, clientX: 5, clientY: 5 }); + map._renderTaskQueue.run(); + expect(boxzoomstart).toHaveBeenCalledTimes(1); + expect(boxzoomend).not.toHaveBeenCalled(); + + simulate.mouseup(map.getCanvasContainer(), { shiftKey: true, clientX: 5, clientY: 5 }); + map._renderTaskQueue.run(); + expect(boxzoomstart).toHaveBeenCalledTimes(1); + expect(boxzoomend).toHaveBeenCalledTimes(1); + + map.remove(); + }); + + test('avoids conflicts with DragPanHandler when disabled and reenabled (#2237)', () => { + const map = createMap(undefined); + + map.boxZoom.disable(); + map.boxZoom.enable(); + + const boxzoomstart = jest.fn(); + const boxzoomend = jest.fn(); + + map.on('boxzoomstart', boxzoomstart); + map.on('boxzoomend', boxzoomend); + + const dragstart = jest.fn(); + const drag = jest.fn(); + const dragend = jest.fn(); + + map.on('dragstart', dragstart); + map.on('drag', drag); + map.on('dragend', dragend); + + simulate.mousedown(map.getCanvasContainer(), { shiftKey: true, clientX: 0, clientY: 0 }); + map._renderTaskQueue.run(); + expect(boxzoomstart).not.toHaveBeenCalled(); + expect(boxzoomend).not.toHaveBeenCalled(); + + simulate.mousemove(map.getCanvasContainer(), { shiftKey: true, clientX: 5, clientY: 5 }); + map._renderTaskQueue.run(); + expect(boxzoomstart).toHaveBeenCalledTimes(1); + expect(boxzoomend).not.toHaveBeenCalled(); + + simulate.mouseup(map.getCanvasContainer(), { shiftKey: true, clientX: 5, clientY: 5 }); + map._renderTaskQueue.run(); + expect(boxzoomstart).toHaveBeenCalledTimes(1); + expect(boxzoomend).toHaveBeenCalledTimes(1); + + expect(dragstart).not.toHaveBeenCalled(); + expect(drag).not.toHaveBeenCalled(); + expect(dragend).not.toHaveBeenCalled(); + + map.remove(); + }); + + test('does not begin a box zoom if preventDefault is called on the mousedown event', () => { + const map = createMap(undefined); + + map.on('mousedown', (e) => e.preventDefault()); + + const boxzoomstart = jest.fn(); + const boxzoomend = jest.fn(); + + map.on('boxzoomstart', boxzoomstart); + map.on('boxzoomend', boxzoomend); + + simulate.mousedown(map.getCanvasContainer(), { shiftKey: true, clientX: 0, clientY: 0 }); + map._renderTaskQueue.run(); + + simulate.mousemove(map.getCanvasContainer(), { shiftKey: true, clientX: 5, clientY: 5 }); + map._renderTaskQueue.run(); + + simulate.mouseup(map.getCanvasContainer(), { shiftKey: true, clientX: 5, clientY: 5 }); + map._renderTaskQueue.run(); + + expect(boxzoomstart).not.toHaveBeenCalled(); + expect(boxzoomend).not.toHaveBeenCalled(); + + map.remove(); + }); + + test('does not begin a box zoom on spurious mousemove events', () => { + const map = createMap(undefined); + + const boxzoomstart = jest.fn(); + const boxzoomend = jest.fn(); + + map.on('boxzoomstart', boxzoomstart); + map.on('boxzoomend', boxzoomend); + + simulate.mousedown(map.getCanvasContainer(), { shiftKey: true, clientX: 0, clientY: 0 }); + map._renderTaskQueue.run(); + expect(boxzoomstart).not.toHaveBeenCalled(); + expect(boxzoomend).not.toHaveBeenCalled(); + + simulate.mousemove(map.getCanvasContainer(), { shiftKey: true, clientX: 0, clientY: 0 }); + map._renderTaskQueue.run(); + expect(boxzoomstart).not.toHaveBeenCalled(); + expect(boxzoomend).not.toHaveBeenCalled(); + + simulate.mouseup(map.getCanvasContainer(), { shiftKey: true, clientX: 0, clientY: 0 }); + map._renderTaskQueue.run(); + expect(boxzoomstart).not.toHaveBeenCalled(); + expect(boxzoomend).not.toHaveBeenCalled(); + + map.remove(); + }); + + test('does not begin a box zoom until mouse move is larger than click tolerance', () => { + const map = createMap(4); + + const boxzoomstart = jest.fn(); + const boxzoomend = jest.fn(); + + map.on('boxzoomstart', boxzoomstart); + map.on('boxzoomend', boxzoomend); + + simulate.mousedown(map.getCanvasContainer(), { shiftKey: true, clientX: 0, clientY: 0 }); + map._renderTaskQueue.run(); + expect(boxzoomstart).not.toHaveBeenCalled(); + expect(boxzoomend).not.toHaveBeenCalled(); + + simulate.mousemove(map.getCanvasContainer(), { shiftKey: true, clientX: 3, clientY: 0 }); + map._renderTaskQueue.run(); + expect(boxzoomstart).not.toHaveBeenCalled(); + expect(boxzoomend).not.toHaveBeenCalled(); + + simulate.mousemove(map.getCanvasContainer(), { shiftKey: true, clientX: 0, clientY: 4 }); + map._renderTaskQueue.run(); + expect(boxzoomstart).toHaveBeenCalledTimes(1); + expect(boxzoomend).not.toHaveBeenCalled(); + + map.remove(); + }); +}); diff --git a/packages/map/__tests__/handler/dblclick_zoom.spec.ts b/packages/map/__tests__/handler/dblclick_zoom.spec.ts new file mode 100644 index 00000000000..2fc108b0ce9 --- /dev/null +++ b/packages/map/__tests__/handler/dblclick_zoom.spec.ts @@ -0,0 +1,161 @@ +import type { MapOptions } from '../../src/map/map'; +import { Map } from '../../src/map/map'; +import simulate from '../libs/simulate_interaction'; +import { beforeMapTest } from '../libs/util'; + +function createMap() { + return new Map({ container: window.document.createElement('div') } as any as MapOptions); +} + +function simulateDoubleTap(map, delay = 100) { + const canvas = map.getCanvasContainer(); + return new Promise((resolve) => { + simulate.touchstart(canvas, { touches: [{ target: canvas, clientX: 0, clientY: 0 }] }); + simulate.touchend(canvas); + setTimeout(() => { + simulate.touchstart(canvas, { touches: [{ target: canvas, clientX: 0, clientY: 0 }] }); + simulate.touchend(canvas); + map._renderTaskQueue.run(); + resolve(undefined); + }, delay); + }); +} + +beforeEach(() => { + beforeMapTest(); +}); + +describe('dbclick_zoom', () => { + test('DoubleClickZoomHandler zooms on dblclick event', () => { + const map = createMap(); + + const zoom = jest.fn(); + map.on('zoomstart', zoom); + + simulate.dblclick(map.getCanvasContainer()); + map._renderTaskQueue.run(); + + expect(zoom).toHaveBeenCalled(); + + map.remove(); + }); + + test('DoubleClickZoomHandler does not zoom if preventDefault is called on the dblclick event', () => { + const map = createMap(); + + map.on('dblclick', (e) => e.preventDefault()); + + const zoom = jest.fn(); + map.on('zoomstart', zoom); + + simulate.dblclick(map.getCanvasContainer()); + map._renderTaskQueue.run(); + + expect(zoom).not.toHaveBeenCalled(); + + map.remove(); + }); + + test('DoubleClickZoomHandler zooms on double tap if touchstart events are < 300ms apart', async () => { + const map = createMap(); + + const zoom = jest.fn(); + map.on('zoomstart', zoom); + + await simulateDoubleTap(map, 100); + expect(zoom).toHaveBeenCalled(); + + map.remove(); + }); + + test('DoubleClickZoomHandler does not zoom on double tap if touchstart events are > 500ms apart', async () => { + const map = createMap(); + + const zoom = jest.fn(); + map.on('zoom', zoom); + + await simulateDoubleTap(map, 500); + + expect(zoom).not.toHaveBeenCalled(); + map.remove(); + }); + + test('DoubleClickZoomHandler does not zoom on double tap if touchstart events are in different locations', async () => { + const map = createMap(); + + const zoom = jest.fn(); + map.on('zoom', zoom); + + const canvas = map.getCanvasContainer(); + + await new Promise((resolve) => { + simulate.touchstart(canvas, { touches: [{ clientX: 0, clientY: 0 }] }); + simulate.touchend(canvas); + setTimeout(() => { + simulate.touchstart(canvas, { touches: [{ clientX: 30.5, clientY: 30.5 }] }); + simulate.touchend(canvas); + map._renderTaskQueue.run(); + resolve(undefined); + }, 100); + }); + + expect(zoom).not.toHaveBeenCalled(); + + map.remove(); + }); + + test('DoubleClickZoomHandler zooms on the second touchend event of a double tap', () => { + const map = createMap(); + + const zoom = jest.fn(); + map.on('zoomstart', zoom); + + const canvas = map.getCanvasContainer(); + const touchOptions = { touches: [{ target: canvas, clientX: 0.5, clientY: 0.5 }] }; + + simulate.touchstart(canvas, touchOptions); + simulate.touchend(canvas); + simulate.touchstart(canvas, touchOptions); + map._renderTaskQueue.run(); + map._renderTaskQueue.run(); + expect(zoom).not.toHaveBeenCalled(); + + simulate.touchcancel(canvas); + simulate.touchend(canvas); + map._renderTaskQueue.run(); + expect(zoom).not.toHaveBeenCalled(); + + simulate.touchstart(canvas, touchOptions); + simulate.touchend(canvas); + simulate.touchstart(canvas, touchOptions); + map._renderTaskQueue.run(); + expect(zoom).not.toHaveBeenCalled(); + + simulate.touchend(canvas); + map._renderTaskQueue.run(); + + expect(zoom).toHaveBeenCalled(); + }); + + test('DoubleClickZoomHandler does not zoom on double tap if second touchend is >300ms after first touchstart', async () => { + const map = createMap(); + + const zoom = jest.fn(); + map.on('zoom', zoom); + + const canvas = map.getCanvasContainer(); + + await new Promise((resolve) => { + simulate.touchstart(canvas); + simulate.touchend(canvas); + simulate.touchstart(canvas); + setTimeout(() => { + simulate.touchend(canvas); + map._renderTaskQueue.run(); + resolve(undefined); + }, 300); + }); + + expect(zoom).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/map/__tests__/handler/drag_pan.spec.ts b/packages/map/__tests__/handler/drag_pan.spec.ts new file mode 100644 index 00000000000..4edb1fa5c72 --- /dev/null +++ b/packages/map/__tests__/handler/drag_pan.spec.ts @@ -0,0 +1,471 @@ +import type { MapOptions } from '../../src/map/map'; +import { Map } from '../../src/map/map'; +import { DOM } from '../../src/map/util/dom'; +import simulate from '../libs/simulate_interaction'; +import { beforeMapTest } from '../libs/util'; + +function createMap(clickTolerance?, dragPan?) { + return new Map({ + container: DOM.create('div', '', window.document.body), + clickTolerance: clickTolerance || 0, + dragPan: dragPan || true, + } as any as MapOptions); +} + +beforeEach(() => { + beforeMapTest(); +}); + +// MouseEvent.buttons = 1 // left button +const buttons = 1; + +describe('drag_pan', () => { + test('DragPanHandler fires dragstart, drag, and dragend events at appropriate times in response to a mouse-triggered drag', () => { + const map = createMap(); + + const dragstart = jest.fn(); + const drag = jest.fn(); + const dragend = jest.fn(); + + map.on('dragstart', dragstart); + map.on('drag', drag); + map.on('dragend', dragend); + + simulate.mousedown(map.getCanvasContainer()); + map._renderTaskQueue.run(); + expect(dragstart).toHaveBeenCalledTimes(0); + expect(drag).toHaveBeenCalledTimes(0); + expect(dragend).toHaveBeenCalledTimes(0); + + simulate.mousemove(map.getCanvasContainer(), { buttons, clientX: 10, clientY: 10 }); + map._renderTaskQueue.run(); + expect(dragstart).toHaveBeenCalledTimes(1); + expect(drag).toHaveBeenCalledTimes(1); + expect(dragend).toHaveBeenCalledTimes(0); + + simulate.mouseup(map.getCanvasContainer()); + map._renderTaskQueue.run(); + expect(dragstart).toHaveBeenCalledTimes(1); + expect(drag).toHaveBeenCalledTimes(1); + expect(dragend).toHaveBeenCalledTimes(1); + + map.remove(); + }); + + test('DragPanHandler captures mousemove events during a mouse-triggered drag (receives them even if they occur outside the map)', () => { + const map = createMap(); + + const dragstart = jest.fn(); + const drag = jest.fn(); + const dragend = jest.fn(); + + map.on('dragstart', dragstart); + map.on('drag', drag); + map.on('dragend', dragend); + + simulate.mousedown(map.getCanvasContainer()); + map._renderTaskQueue.run(); + expect(dragstart).toHaveBeenCalledTimes(0); + expect(drag).toHaveBeenCalledTimes(0); + expect(dragend).toHaveBeenCalledTimes(0); + + simulate.mousemove(window.document.body, { buttons, clientX: 10, clientY: 10 }); + map._renderTaskQueue.run(); + expect(dragstart).toHaveBeenCalledTimes(1); + expect(drag).toHaveBeenCalledTimes(1); + expect(dragend).toHaveBeenCalledTimes(0); + + simulate.mouseup(map.getCanvasContainer()); + map._renderTaskQueue.run(); + expect(dragstart).toHaveBeenCalledTimes(1); + expect(drag).toHaveBeenCalledTimes(1); + expect(dragend).toHaveBeenCalledTimes(1); + + map.remove(); + }); + + test('DragPanHandler fires dragstart, drag, and dragend events at appropriate times in response to a touch-triggered drag', () => { + const map = createMap(); + const target = map.getCanvasContainer(); + + const dragstart = jest.fn(); + const drag = jest.fn(); + const dragend = jest.fn(); + + map.on('dragstart', dragstart); + map.on('drag', drag); + map.on('dragend', dragend); + + simulate.touchstart(map.getCanvasContainer(), { + touches: [{ target, clientX: 0, clientY: 0 }], + }); + map._renderTaskQueue.run(); + expect(dragstart).toHaveBeenCalledTimes(0); + expect(drag).toHaveBeenCalledTimes(0); + expect(dragend).toHaveBeenCalledTimes(0); + + simulate.touchmove(map.getCanvasContainer(), { + touches: [{ target, clientX: 10, clientY: 10 }], + }); + map._renderTaskQueue.run(); + expect(dragstart).toHaveBeenCalledTimes(1); + expect(drag).toHaveBeenCalledTimes(1); + expect(dragend).toHaveBeenCalledTimes(0); + + simulate.touchend(map.getCanvasContainer()); + map._renderTaskQueue.run(); + expect(dragstart).toHaveBeenCalledTimes(1); + expect(drag).toHaveBeenCalledTimes(1); + expect(dragend).toHaveBeenCalledTimes(1); + + map.remove(); + }); + + test('DragPanHandler prevents mousemove events from firing during a drag (#1555)', () => { + const map = createMap(); + + const mousemove = jest.fn(); + map.on('mousemove', mousemove); + + simulate.mousedown(map.getCanvasContainer()); + map._renderTaskQueue.run(); + + simulate.mousemove(map.getCanvasContainer(), { buttons, clientX: 10, clientY: 10 }); + map._renderTaskQueue.run(); + + simulate.mouseup(map.getCanvasContainer()); + map._renderTaskQueue.run(); + + expect(mousemove).not.toHaveBeenCalled(); + + map.remove(); + }); + + test('DragPanHandler ends a mouse-triggered drag if the window blurs', () => { + const map = createMap(); + + const dragend = jest.fn(); + map.on('dragend', dragend); + + simulate.mousedown(map.getCanvasContainer()); + map._renderTaskQueue.run(); + + simulate.mousemove(map.getCanvasContainer(), { buttons, clientX: 10, clientY: 10 }); + map._renderTaskQueue.run(); + + simulate.blur(window); + expect(dragend).toHaveBeenCalledTimes(1); + + map.remove(); + }); + + test('DragPanHandler ends a touch-triggered drag if the window blurs', () => { + const map = createMap(); + const target = map.getCanvasContainer(); + + const dragend = jest.fn(); + map.on('dragend', dragend); + + simulate.touchstart(map.getCanvasContainer(), { + touches: [{ target, clientX: 0, clientY: 0 }], + }); + map._renderTaskQueue.run(); + + simulate.touchmove(map.getCanvasContainer(), { + touches: [{ target, clientX: 10, clientY: 10 }], + }); + map._renderTaskQueue.run(); + + simulate.blur(window); + expect(dragend).toHaveBeenCalledTimes(1); + + map.remove(); + }); + + test('DragPanHandler requests a new render frame after each mousemove event', () => { + const map = createMap(); + const requestFrame = jest.spyOn(map.handlers, '_requestFrame'); + + simulate.mousedown(map.getCanvasContainer()); + simulate.mousemove(map.getCanvasContainer(), { buttons, clientX: 10, clientY: 10 }); + expect(requestFrame).toHaveBeenCalled(); + + map._renderTaskQueue.run(); + + // https://github.com/mapbox/mapbox-gl-js/issues/6063 + requestFrame.mockReset(); + simulate.mousemove(map.getCanvasContainer(), { buttons, clientX: 20, clientY: 20 }); + expect(requestFrame).toHaveBeenCalledTimes(1); + + map.remove(); + }); + + test('DragPanHandler can interleave with another handler', () => { + // https://github.com/mapbox/mapbox-gl-js/issues/6106 + const map = createMap(); + + const dragstart = jest.fn(); + const drag = jest.fn(); + const dragend = jest.fn(); + + map.on('dragstart', dragstart); + map.on('drag', drag); + map.on('dragend', dragend); + + simulate.mousedown(map.getCanvasContainer()); + map._renderTaskQueue.run(); + expect(dragstart).toHaveBeenCalledTimes(0); + expect(drag).toHaveBeenCalledTimes(0); + expect(dragend).toHaveBeenCalledTimes(0); + + simulate.mousemove(map.getCanvasContainer(), { buttons, clientX: 10, clientY: 10 }); + map._renderTaskQueue.run(); + expect(dragstart).toHaveBeenCalledTimes(1); + expect(drag).toHaveBeenCalledTimes(1); + expect(dragend).toHaveBeenCalledTimes(0); + + // simulate a scroll zoom + simulate.wheel(map.getCanvasContainer(), { + type: 'wheel', + deltaY: -simulate.magicWheelZoomDelta, + }); + map._renderTaskQueue.run(); + expect(dragstart).toHaveBeenCalledTimes(1); + expect(drag).toHaveBeenCalledTimes(1); + expect(dragend).toHaveBeenCalledTimes(0); + + simulate.mousemove(map.getCanvasContainer(), { buttons, clientX: 20, clientY: 20 }); + map._renderTaskQueue.run(); + expect(dragstart).toHaveBeenCalledTimes(1); + expect(drag).toHaveBeenCalledTimes(2); + expect(dragend).toHaveBeenCalledTimes(0); + + simulate.mouseup(map.getCanvasContainer()); + map._renderTaskQueue.run(); + expect(dragstart).toHaveBeenCalledTimes(1); + expect(drag).toHaveBeenCalledTimes(2); + expect(dragend).toHaveBeenCalledTimes(1); + + map.remove(); + }); + + ['ctrl', 'shift'].forEach((modifier) => { + test(`DragPanHandler does not begin a drag if the ${modifier} key is down on mousedown`, () => { + const map = createMap(); + expect(map.dragRotate.isEnabled()).toBeTruthy(); + + const dragstart = jest.fn(); + const drag = jest.fn(); + const dragend = jest.fn(); + + map.on('dragstart', dragstart); + map.on('drag', drag); + map.on('dragend', dragend); + + simulate.mousedown(map.getCanvasContainer(), { buttons, [`${modifier}Key`]: true }); + map._renderTaskQueue.run(); + expect(dragstart).toHaveBeenCalledTimes(0); + expect(drag).toHaveBeenCalledTimes(0); + expect(dragend).toHaveBeenCalledTimes(0); + + simulate.mousemove(map.getCanvasContainer(), { + buttons, + [`${modifier}Key`]: true, + clientX: 10, + clientY: 10, + }); + map._renderTaskQueue.run(); + expect(dragstart).toHaveBeenCalledTimes(0); + expect(drag).toHaveBeenCalledTimes(0); + expect(dragend).toHaveBeenCalledTimes(0); + + simulate.mouseup(map.getCanvasContainer(), { [`${modifier}Key`]: true }); + map._renderTaskQueue.run(); + expect(dragstart).toHaveBeenCalledTimes(0); + expect(drag).toHaveBeenCalledTimes(0); + expect(dragend).toHaveBeenCalledTimes(0); + + map.remove(); + }); + + test(`DragPanHandler still ends a drag if the ${modifier} key is down on mouseup`, () => { + const map = createMap(); + expect(map.dragRotate.isEnabled()).toBeTruthy(); + + const dragstart = jest.fn(); + const drag = jest.fn(); + const dragend = jest.fn(); + + map.on('dragstart', dragstart); + map.on('drag', drag); + map.on('dragend', dragend); + + simulate.mousedown(map.getCanvasContainer()); + map._renderTaskQueue.run(); + expect(dragstart).toHaveBeenCalledTimes(0); + expect(drag).toHaveBeenCalledTimes(0); + expect(dragend).toHaveBeenCalledTimes(0); + + simulate.mouseup(map.getCanvasContainer(), { [`${modifier}Key`]: true }); + map._renderTaskQueue.run(); + expect(dragstart).toHaveBeenCalledTimes(0); + expect(drag).toHaveBeenCalledTimes(0); + expect(dragend).toHaveBeenCalledTimes(0); + + simulate.mousemove(map.getCanvasContainer(), { buttons, clientX: 10, clientY: 10 }); + map._renderTaskQueue.run(); + expect(dragstart).toHaveBeenCalledTimes(0); + expect(drag).toHaveBeenCalledTimes(0); + expect(dragend).toHaveBeenCalledTimes(0); + + map.remove(); + }); + }); + + test('DragPanHandler does not begin a drag on right button mousedown', () => { + const map = createMap(); + map.dragRotate.disable(); + + const dragstart = jest.fn(); + const drag = jest.fn(); + const dragend = jest.fn(); + + map.on('dragstart', dragstart); + map.on('drag', drag); + map.on('dragend', dragend); + + simulate.mousedown(map.getCanvasContainer(), { buttons: 2, button: 2 }); + map._renderTaskQueue.run(); + expect(dragstart).toHaveBeenCalledTimes(0); + expect(drag).toHaveBeenCalledTimes(0); + expect(dragend).toHaveBeenCalledTimes(0); + + simulate.mousemove(map.getCanvasContainer(), { buttons: 2, clientX: 10, clientY: 10 }); + map._renderTaskQueue.run(); + expect(dragstart).toHaveBeenCalledTimes(0); + expect(drag).toHaveBeenCalledTimes(0); + expect(dragend).toHaveBeenCalledTimes(0); + + simulate.mouseup(map.getCanvasContainer(), { buttons: 0, button: 2 }); + map._renderTaskQueue.run(); + expect(dragstart).toHaveBeenCalledTimes(0); + expect(drag).toHaveBeenCalledTimes(0); + expect(dragend).toHaveBeenCalledTimes(0); + + map.remove(); + }); + + test('DragPanHandler does not end a drag on right button mouseup', () => { + const map = createMap(); + map.dragRotate.disable(); + + const dragstart = jest.fn(); + const drag = jest.fn(); + const dragend = jest.fn(); + + map.on('dragstart', dragstart); + map.on('drag', drag); + map.on('dragend', dragend); + + simulate.mousedown(map.getCanvasContainer()); + map._renderTaskQueue.run(); + expect(dragstart).toHaveBeenCalledTimes(0); + expect(drag).toHaveBeenCalledTimes(0); + expect(dragend).toHaveBeenCalledTimes(0); + + simulate.mousemove(map.getCanvasContainer(), { buttons, clientX: 10, clientY: 10 }); + map._renderTaskQueue.run(); + expect(dragstart).toHaveBeenCalledTimes(1); + expect(drag).toHaveBeenCalledTimes(1); + expect(dragend).toHaveBeenCalledTimes(0); + + simulate.mousedown(map.getCanvasContainer(), { buttons: buttons + 2, button: 2 }); + map._renderTaskQueue.run(); + expect(dragstart).toHaveBeenCalledTimes(1); + expect(drag).toHaveBeenCalledTimes(1); + expect(dragend).toHaveBeenCalledTimes(0); + + simulate.mouseup(map.getCanvasContainer(), { buttons, button: 2 }); + map._renderTaskQueue.run(); + expect(dragstart).toHaveBeenCalledTimes(1); + expect(drag).toHaveBeenCalledTimes(1); + expect(dragend).toHaveBeenCalledTimes(0); + + simulate.mousemove(map.getCanvasContainer(), { buttons, clientX: 20, clientY: 20 }); + map._renderTaskQueue.run(); + expect(dragstart).toHaveBeenCalledTimes(1); + expect(drag).toHaveBeenCalledTimes(2); + expect(dragend).toHaveBeenCalledTimes(0); + + simulate.mouseup(map.getCanvasContainer()); + map._renderTaskQueue.run(); + expect(dragstart).toHaveBeenCalledTimes(1); + expect(drag).toHaveBeenCalledTimes(2); + expect(dragend).toHaveBeenCalledTimes(1); + + map.remove(); + }); + + test('DragPanHandler does not begin a drag if preventDefault is called on the mousedown event', () => { + const map = createMap(); + + map.on('mousedown', (e) => e.preventDefault()); + + const dragstart = jest.fn(); + const drag = jest.fn(); + const dragend = jest.fn(); + + map.on('dragstart', dragstart); + map.on('drag', drag); + map.on('dragend', dragend); + + simulate.mousedown(map.getCanvasContainer()); + map._renderTaskQueue.run(); + + simulate.mousemove(map.getCanvasContainer(), { buttons, clientX: 10, clientY: 10 }); + map._renderTaskQueue.run(); + + simulate.mouseup(map.getCanvasContainer()); + map._renderTaskQueue.run(); + + expect(dragstart).toHaveBeenCalledTimes(0); + expect(drag).toHaveBeenCalledTimes(0); + expect(dragend).toHaveBeenCalledTimes(0); + + map.remove(); + }); + + test('DragPanHandler does not begin a drag if preventDefault is called on the touchstart event', () => { + const map = createMap(); + const target = map.getCanvasContainer(); + + map.on('touchstart', (e) => e.preventDefault()); + + const dragstart = jest.fn(); + const drag = jest.fn(); + const dragend = jest.fn(); + + map.on('dragstart', dragstart); + map.on('drag', drag); + map.on('dragend', dragend); + + simulate.touchstart(map.getCanvasContainer(), { + touches: [{ target, clientX: 0, clientY: 0 }], + }); + map._renderTaskQueue.run(); + + simulate.touchmove(map.getCanvasContainer(), { + touches: [{ target, clientX: 10, clientY: 10 }], + }); + map._renderTaskQueue.run(); + + simulate.touchend(map.getCanvasContainer()); + map._renderTaskQueue.run(); + + expect(dragstart).toHaveBeenCalledTimes(0); + expect(drag).toHaveBeenCalledTimes(0); + expect(dragend).toHaveBeenCalledTimes(0); + + map.remove(); + }); +}); diff --git a/packages/map/__tests__/handler/drag_rotate.spec.ts b/packages/map/__tests__/handler/drag_rotate.spec.ts new file mode 100644 index 00000000000..93fa8020f6c --- /dev/null +++ b/packages/map/__tests__/handler/drag_rotate.spec.ts @@ -0,0 +1,906 @@ +import { Map } from '../../src/map/map'; +import { browser } from '../../src/map/util/browser'; +import { DOM } from '../../src/map/util/dom'; +import { extend } from '../../src/map/util/util'; +import simulate from '../libs/simulate_interaction'; + +import { beforeMapTest } from '../libs/util'; + +function createMap(options?) { + return new Map(extend({ container: DOM.create('div', '', window.document.body) }, options)); +} + +beforeEach(() => { + beforeMapTest(); +}); + +describe('drag rotate', () => { + test('DragRotateHandler#isActive', () => { + const map = createMap(); + + // Prevent inertial rotation. + jest.spyOn(browser, 'now').mockReturnValue(0); + + expect(map.dragRotate.isActive()).toBe(false); + + simulate.mousedown(map.getCanvasContainer(), { buttons: 2, button: 2 }); + map._renderTaskQueue.run(); + expect(map.dragRotate.isActive()).toBe(false); + + simulate.mousemove(map.getCanvasContainer(), { buttons: 2, clientX: 10, clientY: 10 }); + map._renderTaskQueue.run(); + expect(map.dragRotate.isActive()).toBe(true); + + simulate.mouseup(map.getCanvasContainer(), { buttons: 0, button: 2 }); + map._renderTaskQueue.run(); + expect(map.dragRotate.isActive()).toBe(false); + + map.remove(); + }); + + test('DragRotateHandler fires rotatestart, rotate, and rotateend events at appropriate times in response to a right-click drag', () => { + const map = createMap(); + + // Prevent inertial rotation. + jest.spyOn(browser, 'now').mockReturnValue(0); + + const rotatestart = jest.fn(); + const rotate = jest.fn(); + const rotateend = jest.fn(); + + map.on('rotatestart', rotatestart); + map.on('rotate', rotate); + map.on('rotateend', rotateend); + + simulate.mousedown(map.getCanvasContainer(), { buttons: 2, button: 2 }); + map._renderTaskQueue.run(); + expect(rotatestart).toHaveBeenCalledTimes(0); + expect(rotate).toHaveBeenCalledTimes(0); + expect(rotateend).toHaveBeenCalledTimes(0); + + simulate.mousemove(map.getCanvasContainer(), { buttons: 2, clientX: 10, clientY: 10 }); + map._renderTaskQueue.run(); + expect(rotatestart).toHaveBeenCalledTimes(1); + expect(rotate).toHaveBeenCalledTimes(1); + expect(rotateend).toHaveBeenCalledTimes(0); + + simulate.mouseup(map.getCanvasContainer(), { buttons: 0, button: 2 }); + map._renderTaskQueue.run(); + expect(rotatestart).toHaveBeenCalledTimes(1); + expect(rotate).toHaveBeenCalledTimes(1); + expect(rotateend).toHaveBeenCalledTimes(1); + + map.remove(); + }); + + test('DragRotateHandler stops firing events after mouseup', () => { + const map = createMap(); + + // Prevent inertial rotation. + jest.spyOn(browser, 'now').mockReturnValue(0); + + const spy = jest.fn(); + map.on('rotatestart', spy); + map.on('rotate', spy); + map.on('rotateend', spy); + + simulate.mousedown(map.getCanvasContainer(), { buttons: 2, button: 2 }); + simulate.mousemove(map.getCanvasContainer(), { buttons: 2, clientX: 10, clientY: 10 }); + map._renderTaskQueue.run(); + simulate.mouseup(map.getCanvasContainer(), { buttons: 0, button: 2 }); + map._renderTaskQueue.run(); + expect(spy).toHaveBeenCalledTimes(3); + + spy.mockReset(); + simulate.mousemove(map.getCanvasContainer(), { buttons: 0, clientX: 20, clientY: 20 }); + map._renderTaskQueue.run(); + expect(spy).toHaveBeenCalledTimes(0); + + map.remove(); + }); + + test('DragRotateHandler fires rotatestart, rotate, and rotateend events at appropriate times in response to a control-left-click drag', () => { + const map = createMap(); + + // Prevent inertial rotation. + jest.spyOn(browser, 'now').mockReturnValue(0); + + const rotatestart = jest.fn(); + const rotate = jest.fn(); + const rotateend = jest.fn(); + + map.on('rotatestart', rotatestart); + map.on('rotate', rotate); + map.on('rotateend', rotateend); + + simulate.mousedown(map.getCanvasContainer(), { buttons: 1, button: 0, ctrlKey: true }); + map._renderTaskQueue.run(); + expect(rotatestart).toHaveBeenCalledTimes(0); + expect(rotate).toHaveBeenCalledTimes(0); + expect(rotateend).toHaveBeenCalledTimes(0); + + simulate.mousemove(map.getCanvasContainer(), { + buttons: 1, + ctrlKey: true, + clientX: 10, + clientY: 10, + }); + map._renderTaskQueue.run(); + expect(rotatestart).toHaveBeenCalledTimes(1); + expect(rotate).toHaveBeenCalledTimes(1); + expect(rotateend).toHaveBeenCalledTimes(0); + + simulate.mouseup(map.getCanvasContainer(), { buttons: 0, button: 0, ctrlKey: true }); + map._renderTaskQueue.run(); + expect(rotatestart).toHaveBeenCalledTimes(1); + expect(rotate).toHaveBeenCalledTimes(1); + expect(rotateend).toHaveBeenCalledTimes(1); + + map.remove(); + }); + + test('DragRotateHandler pitches in response to a right-click drag by default', () => { + const map = createMap(); + + // Prevent inertial rotation. + jest.spyOn(browser, 'now').mockReturnValue(0); + + const pitchstart = jest.fn(); + const pitch = jest.fn(); + const pitchend = jest.fn(); + + map.on('pitchstart', pitchstart); + map.on('pitch', pitch); + map.on('pitchend', pitchend); + + simulate.mousedown(map.getCanvasContainer(), { buttons: 2, button: 2 }); + simulate.mousemove(map.getCanvasContainer(), { buttons: 2, clientX: 10, clientY: -10 }); + map._renderTaskQueue.run(); + expect(pitchstart).toHaveBeenCalledTimes(1); + expect(pitch).toHaveBeenCalledTimes(1); + + simulate.mouseup(map.getCanvasContainer(), { buttons: 0, button: 2 }); + map._renderTaskQueue.run(); + expect(pitchend).toHaveBeenCalledTimes(1); + + map.remove(); + }); + + test("DragRotateHandler doesn't fire pitch event when rotating only", () => { + const map = createMap(); + + // Prevent inertial rotation. + jest.spyOn(browser, 'now').mockReturnValue(0); + + const pitchstart = jest.fn(); + const pitch = jest.fn(); + const pitchend = jest.fn(); + + map.on('pitchstart', pitchstart); + map.on('pitch', pitch); + map.on('pitchend', pitchend); + + simulate.mousedown(map.getCanvasContainer(), { + buttons: 2, + button: 2, + clientX: 0, + clientY: 10, + }); + simulate.mousemove(map.getCanvasContainer(), { buttons: 2, clientX: 10, clientY: 10 }); + map._renderTaskQueue.run(); + expect(pitchstart).toHaveBeenCalledTimes(0); + expect(pitch).toHaveBeenCalledTimes(0); + + simulate.mouseup(map.getCanvasContainer(), { buttons: 0, button: 2 }); + expect(pitchend).toHaveBeenCalledTimes(0); + + map.remove(); + }); + + test('DragRotateHandler pitches in response to a control-left-click drag', () => { + const map = createMap(); + + // Prevent inertial rotation. + jest.spyOn(browser, 'now').mockReturnValue(0); + + const pitchstart = jest.fn(); + const pitch = jest.fn(); + const pitchend = jest.fn(); + + map.on('pitchstart', pitchstart); + map.on('pitch', pitch); + map.on('pitchend', pitchend); + + simulate.mousedown(map.getCanvasContainer(), { buttons: 1, button: 0, ctrlKey: true }); + simulate.mousemove(map.getCanvasContainer(), { + buttons: 1, + ctrlKey: true, + clientX: 10, + clientY: -10, + }); + map._renderTaskQueue.run(); + expect(pitchstart).toHaveBeenCalledTimes(1); + expect(pitch).toHaveBeenCalledTimes(1); + + simulate.mouseup(map.getCanvasContainer(), { buttons: 0, button: 0, ctrlKey: true }); + map._renderTaskQueue.run(); + expect(pitchend).toHaveBeenCalledTimes(1); + + map.remove(); + }); + + test('DragRotateHandler does not pitch if given pitchWithRotate: false', () => { + const map = createMap({ pitchWithRotate: false }); + + const spy = jest.fn(); + + map.on('pitchstart', spy); + map.on('pitch', spy); + map.on('pitchend', spy); + + simulate.mousedown(map.getCanvasContainer(), { buttons: 2, button: 2 }); + simulate.mousemove(map.getCanvasContainer(), { buttons: 2, clientX: 10, clientY: 10 }); + map._renderTaskQueue.run(); + simulate.mouseup(map.getCanvasContainer(), { buttons: 0, button: 2 }); + + simulate.mousedown(map.getCanvasContainer(), { buttons: 1, button: 0, ctrlKey: true }); + simulate.mousemove(map.getCanvasContainer(), { + buttons: 1, + ctrlKey: true, + clientX: 10, + clientY: 10, + }); + map._renderTaskQueue.run(); + simulate.mouseup(map.getCanvasContainer(), { buttons: 0, button: 0, ctrlKey: true }); + + expect(spy).not.toHaveBeenCalled(); + + map.remove(); + }); + + test('DragRotateHandler does not rotate or pitch when disabled', () => { + const map = createMap(); + + map.dragRotate.disable(); + + const spy = jest.fn(); + + map.on('rotatestart', spy); + map.on('rotate', spy); + map.on('rotateend', spy); + map.on('pitchstart', spy); + map.on('pitch', spy); + map.on('pitchend', spy); + + simulate.mousedown(map.getCanvasContainer(), { buttons: 2, button: 2 }); + simulate.mousemove(map.getCanvasContainer(), { buttons: 2, clientX: 10, clientY: 10 }); + map._renderTaskQueue.run(); + simulate.mouseup(map.getCanvasContainer(), { buttons: 0, button: 2 }); + + expect(spy).not.toHaveBeenCalled(); + + map.remove(); + }); + + test('DragRotateHandler ensures that map.isMoving() returns true during drag', () => { + // The bearingSnap option here ensures that the moveend event is sent synchronously. + const map = createMap({ bearingSnap: 0 }); + + simulate.mousedown(map.getCanvasContainer(), { buttons: 2, button: 2 }); + simulate.mousemove(map.getCanvasContainer(), { buttons: 2, clientX: 10, clientY: 10 }); + map._renderTaskQueue.run(); + expect(map.isMoving()).toBeTruthy(); + + simulate.mouseup(map.getCanvasContainer(), { buttons: 0, button: 2 }); + map._renderTaskQueue.run(); + expect(!map.isMoving()).toBeTruthy(); + + map.remove(); + }); + + test('DragRotateHandler fires move events', () => { + // The bearingSnap option here ensures that the moveend event is sent synchronously. + const map = createMap({ bearingSnap: 0 }); + + // Prevent inertial rotation. + jest.spyOn(browser, 'now').mockReturnValue(0); + + const movestart = jest.fn(); + const move = jest.fn(); + const moveend = jest.fn(); + + map.on('movestart', movestart); + map.on('move', move); + map.on('moveend', moveend); + + simulate.mousedown(map.getCanvasContainer(), { buttons: 2, button: 2 }); + simulate.mousemove(map.getCanvasContainer(), { buttons: 2, clientX: 10, clientY: 10 }); + map._renderTaskQueue.run(); + expect(movestart).toHaveBeenCalledTimes(1); + expect(move).toHaveBeenCalledTimes(1); + + simulate.mouseup(map.getCanvasContainer(), { buttons: 0, button: 2 }); + map._renderTaskQueue.run(); + expect(moveend).toHaveBeenCalledTimes(1); + + map.remove(); + }); + + test("DragRotateHandler doesn't fire rotate event when pitching only", () => { + // The bearingSnap option here ensures that the moveend event is sent synchronously. + const map = createMap({ bearingSnap: 0 }); + + // Prevent inertial rotation. + jest.spyOn(browser, 'now').mockReturnValue(0); + + const rotatestart = jest.fn(); + const rotate = jest.fn(); + const pitch = jest.fn(); + const rotateend = jest.fn(); + + map.on('rotatestart', rotatestart); + map.on('rotate', rotate); + map.on('pitch', pitch); + map.on('rotateend', rotateend); + + simulate.mousedown(map.getCanvasContainer(), { buttons: 2, button: 2, clientX: 0, clientY: 0 }); + simulate.mousemove(map.getCanvasContainer(), { buttons: 2, clientX: 0, clientY: -10 }); + map._renderTaskQueue.run(); + expect(rotatestart).toHaveBeenCalledTimes(0); + expect(rotate).toHaveBeenCalledTimes(0); + expect(pitch).toHaveBeenCalledTimes(1); + + simulate.mouseup(map.getCanvasContainer(), { buttons: 0, button: 2 }); + expect(rotateend).toHaveBeenCalledTimes(0); + + map.remove(); + }); + + test('DragRotateHandler includes originalEvent property in triggered events', () => { + // The bearingSnap option here ensures that the moveend event is sent synchronously. + const map = createMap({ bearingSnap: 0 }); + + // Prevent inertial rotation. + jest.spyOn(browser, 'now').mockReturnValue(0); + + const rotatestart = jest.fn(); + const rotate = jest.fn(); + const rotateend = jest.fn(); + map.on('rotatestart', rotatestart); + map.on('rotate', rotate); + map.on('rotateend', rotateend); + + const pitchstart = jest.fn(); + const pitch = jest.fn(); + const pitchend = jest.fn(); + map.on('pitchstart', pitchstart); + map.on('pitch', pitch); + map.on('pitchend', pitchend); + + const movestart = jest.fn(); + const move = jest.fn(); + const moveend = jest.fn(); + map.on('movestart', movestart); + map.on('move', move); + map.on('moveend', moveend); + + simulate.mousedown(map.getCanvasContainer(), { buttons: 2, button: 2 }); + simulate.mousemove(map.getCanvasContainer(), { buttons: 2, clientX: 10, clientY: -10 }); + map._renderTaskQueue.run(); + simulate.mouseup(map.getCanvasContainer(), { buttons: 0, button: 2 }); + map._renderTaskQueue.run(); + + expect(rotatestart.mock.calls[0][0].originalEvent.type).toBeTruthy(); + expect(pitchstart.mock.calls[0][0].originalEvent.type).toBeTruthy(); + expect(movestart.mock.calls[0][0].originalEvent.type).toBeTruthy(); + + expect(rotate.mock.calls[0][0].originalEvent.type).toBeTruthy(); + expect(pitch.mock.calls[0][0].originalEvent.type).toBeTruthy(); + expect(move.mock.calls[0][0].originalEvent.type).toBeTruthy(); + + expect(rotateend.mock.calls[0][0].originalEvent.type).toBeTruthy(); + expect(pitchend.mock.calls[0][0].originalEvent.type).toBeTruthy(); + expect(moveend.mock.calls[0][0].originalEvent.type).toBeTruthy(); + + map.remove(); + }); + + test('DragRotateHandler responds to events on the canvas container (#1301)', () => { + const map = createMap(); + + // Prevent inertial rotation. + jest.spyOn(browser, 'now').mockReturnValue(0); + + const rotatestart = jest.fn(); + const rotate = jest.fn(); + const rotateend = jest.fn(); + + map.on('rotatestart', rotatestart); + map.on('rotate', rotate); + map.on('rotateend', rotateend); + + simulate.mousedown(map.getCanvasContainer(), { buttons: 2, button: 2 }); + simulate.mousemove(map.getCanvasContainer(), { buttons: 2, clientX: 10, clientY: 10 }); + map._renderTaskQueue.run(); + expect(rotatestart).toHaveBeenCalledTimes(1); + expect(rotate).toHaveBeenCalledTimes(1); + + simulate.mouseup(map.getCanvasContainer(), { buttons: 0, button: 2 }); + map._renderTaskQueue.run(); + expect(rotateend).toHaveBeenCalledTimes(1); + + map.remove(); + }); + + test('DragRotateHandler prevents mousemove events from firing during a drag (#1555)', () => { + const map = createMap(); + + // Prevent inertial rotation. + jest.spyOn(browser, 'now').mockReturnValue(0); + + const mousemove = jest.fn(); + map.on('mousemove', mousemove); + + simulate.mousedown(map.getCanvasContainer(), { buttons: 2, button: 2 }); + simulate.mousemove(map.getCanvasContainer(), { buttons: 2, clientX: 100, clientY: 100 }); + map._renderTaskQueue.run(); + simulate.mouseup(map.getCanvasContainer(), { buttons: 0, button: 2 }); + + expect(mousemove).not.toHaveBeenCalled(); + + map.remove(); + }); + + test('DragRotateHandler ends a control-left-click drag on mouseup even when the control key was previously released (#1888)', () => { + const map = createMap(); + + // Prevent inertial rotation. + jest.spyOn(browser, 'now').mockReturnValue(0); + + const rotatestart = jest.fn(); + const rotate = jest.fn(); + const rotateend = jest.fn(); + + map.on('rotatestart', rotatestart); + map.on('rotate', rotate); + map.on('rotateend', rotateend); + + simulate.mousedown(map.getCanvasContainer(), { buttons: 1, button: 0, ctrlKey: true }); + simulate.mousemove(map.getCanvasContainer(), { + buttons: 1, + ctrlKey: true, + clientX: 10, + clientY: 10, + }); + map._renderTaskQueue.run(); + expect(rotatestart).toHaveBeenCalledTimes(1); + expect(rotate).toHaveBeenCalledTimes(1); + + simulate.mouseup(map.getCanvasContainer(), { buttons: 0, button: 0, ctrlKey: false }); + map._renderTaskQueue.run(); + expect(rotateend).toHaveBeenCalledTimes(1); + + map.remove(); + }); + + test('DragRotateHandler ends rotation if the window blurs (#3389)', () => { + const map = createMap(); + + // Prevent inertial rotation. + jest.spyOn(browser, 'now').mockReturnValue(0); + + const rotatestart = jest.fn(); + const rotate = jest.fn(); + const rotateend = jest.fn(); + + map.on('rotatestart', rotatestart); + map.on('rotate', rotate); + map.on('rotateend', rotateend); + + simulate.mousedown(map.getCanvasContainer(), { buttons: 2, button: 2 }); + simulate.mousemove(map.getCanvasContainer(), { buttons: 2, clientX: 10, clientY: 10 }); + map._renderTaskQueue.run(); + expect(rotatestart).toHaveBeenCalledTimes(1); + expect(rotate).toHaveBeenCalledTimes(1); + + simulate.blur(window); + expect(rotateend).toHaveBeenCalledTimes(1); + + map.remove(); + }); + + test('DragRotateHandler requests a new render frame after each mousemove event', () => { + const map = createMap(); + const requestRenderFrame = jest.spyOn(map.handlers, '_requestFrame'); + + // Prevent inertial rotation. + jest.spyOn(browser, 'now').mockReturnValue(0); + + simulate.mousedown(map.getCanvasContainer(), { buttons: 2, button: 2 }); + simulate.mousemove(map.getCanvasContainer(), { buttons: 2, clientX: 10, clientY: 10 }); + expect(requestRenderFrame).toHaveBeenCalled(); + + map._renderTaskQueue.run(); + + // https://github.com/mapbox/mapbox-gl-js/issues/6063 + requestRenderFrame.mockReset(); + simulate.mousemove(map.getCanvasContainer(), { buttons: 2, clientX: 20, clientY: 20 }); + expect(requestRenderFrame).toHaveBeenCalledTimes(1); + + map.remove(); + }); + + test('DragRotateHandler can interleave with another handler', () => { + // https://github.com/mapbox/mapbox-gl-js/issues/6106 + const map = createMap(); + + // Prevent inertial rotation. + jest.spyOn(browser, 'now').mockReturnValue(0); + + const rotatestart = jest.fn(); + const rotate = jest.fn(); + const rotateend = jest.fn(); + + map.on('rotatestart', rotatestart); + map.on('rotate', rotate); + map.on('rotateend', rotateend); + + simulate.mousedown(map.getCanvasContainer(), { buttons: 2, button: 2 }); + map._renderTaskQueue.run(); + expect(rotatestart).toHaveBeenCalledTimes(0); + expect(rotate).toHaveBeenCalledTimes(0); + expect(rotateend).toHaveBeenCalledTimes(0); + + simulate.mousemove(map.getCanvasContainer(), { buttons: 2, clientX: 10, clientY: 10 }); + map._renderTaskQueue.run(); + expect(rotatestart).toHaveBeenCalledTimes(1); + expect(rotate).toHaveBeenCalledTimes(1); + expect(rotateend).toHaveBeenCalledTimes(0); + + // simulates another handler taking over + // simulate a scroll zoom + simulate.wheel(map.getCanvasContainer(), { + type: 'wheel', + deltaY: -simulate.magicWheelZoomDelta, + }); + map._renderTaskQueue.run(); + expect(rotatestart).toHaveBeenCalledTimes(1); + expect(rotate).toHaveBeenCalledTimes(1); + expect(rotateend).toHaveBeenCalledTimes(0); + + simulate.mousemove(map.getCanvasContainer(), { buttons: 2, clientX: 20, clientY: 20 }); + map._renderTaskQueue.run(); + expect(rotatestart).toHaveBeenCalledTimes(1); + expect(rotate).toHaveBeenCalledTimes(2); + expect(rotateend).toHaveBeenCalledTimes(0); + + simulate.mouseup(map.getCanvasContainer(), { buttons: 0, button: 2 }); + map._renderTaskQueue.run(); + // Ignore second rotatestart triggered by inertia + expect(rotate).toHaveBeenCalledTimes(2); + expect(rotateend).toHaveBeenCalledTimes(1); + + map.remove(); + }); + + test('DragRotateHandler does not begin a drag on left-button mousedown without the control key', () => { + const map = createMap(); + map.dragPan.disable(); + + const rotatestart = jest.fn(); + const rotate = jest.fn(); + const rotateend = jest.fn(); + + map.on('rotatestart', rotatestart); + map.on('rotate', rotate); + map.on('rotateend', rotateend); + + simulate.mousedown(map.getCanvasContainer()); + map._renderTaskQueue.run(); + expect(rotatestart).toHaveBeenCalledTimes(0); + expect(rotate).toHaveBeenCalledTimes(0); + expect(rotateend).toHaveBeenCalledTimes(0); + + simulate.mousemove(map.getCanvasContainer(), { clientX: 10, clientY: 10 }); + map._renderTaskQueue.run(); + expect(rotatestart).toHaveBeenCalledTimes(0); + expect(rotate).toHaveBeenCalledTimes(0); + expect(rotateend).toHaveBeenCalledTimes(0); + + simulate.mouseup(map.getCanvasContainer()); + map._renderTaskQueue.run(); + expect(rotatestart).toHaveBeenCalledTimes(0); + expect(rotate).toHaveBeenCalledTimes(0); + expect(rotateend).toHaveBeenCalledTimes(0); + + map.remove(); + }); + + test('DragRotateHandler does not end a right-button drag on left-button mouseup', () => { + const map = createMap(); + map.dragPan.disable(); + + // Prevent inertial rotation. + jest.spyOn(browser, 'now').mockReturnValue(0); + + const rotatestart = jest.fn(); + const rotate = jest.fn(); + const rotateend = jest.fn(); + + map.on('rotatestart', rotatestart); + map.on('rotate', rotate); + map.on('rotateend', rotateend); + + simulate.mousedown(map.getCanvasContainer(), { buttons: 2, button: 2 }); + map._renderTaskQueue.run(); + expect(rotatestart).toHaveBeenCalledTimes(0); + expect(rotate).toHaveBeenCalledTimes(0); + expect(rotateend).toHaveBeenCalledTimes(0); + + simulate.mousemove(map.getCanvasContainer(), { buttons: 2, clientX: 10, clientY: 10 }); + map._renderTaskQueue.run(); + expect(rotatestart).toHaveBeenCalledTimes(1); + expect(rotate).toHaveBeenCalledTimes(1); + expect(rotateend).toHaveBeenCalledTimes(0); + + simulate.mousedown(map.getCanvasContainer(), { buttons: 3, button: 0 }); + map._renderTaskQueue.run(); + expect(rotatestart).toHaveBeenCalledTimes(1); + expect(rotate).toHaveBeenCalledTimes(1); + expect(rotateend).toHaveBeenCalledTimes(0); + + simulate.mouseup(map.getCanvasContainer(), { buttons: 2, button: 0 }); + map._renderTaskQueue.run(); + expect(rotatestart).toHaveBeenCalledTimes(1); + expect(rotate).toHaveBeenCalledTimes(1); + expect(rotateend).toHaveBeenCalledTimes(0); + + simulate.mousemove(map.getCanvasContainer(), { buttons: 2, clientX: 20, clientY: 20 }); + map._renderTaskQueue.run(); + expect(rotatestart).toHaveBeenCalledTimes(1); + expect(rotate).toHaveBeenCalledTimes(2); + expect(rotateend).toHaveBeenCalledTimes(0); + + simulate.mouseup(map.getCanvasContainer(), { buttons: 0, button: 2 }); + map._renderTaskQueue.run(); + // Ignore second rotatestart triggered by inertia + expect(rotate).toHaveBeenCalledTimes(2); + expect(rotateend).toHaveBeenCalledTimes(1); + + map.remove(); + }); + + test('DragRotateHandler does not end a control-left-button drag on right-button mouseup', () => { + const map = createMap(); + map.dragPan.disable(); + + // Prevent inertial rotation. + jest.spyOn(browser, 'now').mockReturnValue(0); + + const rotatestart = jest.fn(); + const rotate = jest.fn(); + const rotateend = jest.fn(); + + map.on('rotatestart', rotatestart); + map.on('rotate', rotate); + map.on('rotateend', rotateend); + + simulate.mousedown(map.getCanvasContainer(), { buttons: 1, button: 0, ctrlKey: true }); + map._renderTaskQueue.run(); + expect(rotatestart).toHaveBeenCalledTimes(0); + expect(rotate).toHaveBeenCalledTimes(0); + expect(rotateend).toHaveBeenCalledTimes(0); + + simulate.mousemove(map.getCanvasContainer(), { + buttons: 1, + ctrlKey: true, + clientX: 10, + clientY: 10, + }); + map._renderTaskQueue.run(); + expect(rotatestart).toHaveBeenCalledTimes(1); + expect(rotate).toHaveBeenCalledTimes(1); + expect(rotateend).toHaveBeenCalledTimes(0); + + simulate.mousedown(map.getCanvasContainer(), { buttons: 3, button: 2, ctrlKey: true }); + map._renderTaskQueue.run(); + expect(rotatestart).toHaveBeenCalledTimes(1); + expect(rotate).toHaveBeenCalledTimes(1); + expect(rotateend).toHaveBeenCalledTimes(0); + + simulate.mouseup(map.getCanvasContainer(), { buttons: 1, button: 2, ctrlKey: true }); + map._renderTaskQueue.run(); + expect(rotatestart).toHaveBeenCalledTimes(1); + expect(rotate).toHaveBeenCalledTimes(1); + expect(rotateend).toHaveBeenCalledTimes(0); + + simulate.mousemove(map.getCanvasContainer(), { + buttons: 1, + ctrlKey: true, + clientX: 20, + clientY: 20, + }); + map._renderTaskQueue.run(); + expect(rotatestart).toHaveBeenCalledTimes(1); + expect(rotate).toHaveBeenCalledTimes(2); + expect(rotateend).toHaveBeenCalledTimes(0); + + simulate.mouseup(map.getCanvasContainer(), { buttons: 0, button: 0, ctrlKey: true }); + map._renderTaskQueue.run(); + // Ignore second rotatestart triggered by inertia + expect(rotate).toHaveBeenCalledTimes(2); + expect(rotateend).toHaveBeenCalledTimes(1); + + map.remove(); + }); + + test('DragRotateHandler does not begin a drag if preventDefault is called on the mousedown event', () => { + const map = createMap(); + + map.on('mousedown', (e) => e.preventDefault()); + + const rotatestart = jest.fn(); + const rotate = jest.fn(); + const rotateend = jest.fn(); + + map.on('rotatestart', rotatestart); + map.on('rotate', rotate); + map.on('rotateend', rotateend); + + simulate.mousedown(map.getCanvasContainer(), { buttons: 2, button: 2 }); + map._renderTaskQueue.run(); + + simulate.mousemove(map.getCanvasContainer(), { buttons: 2, clientX: 10, clientY: 10 }); + map._renderTaskQueue.run(); + + simulate.mouseup(map.getCanvasContainer(), { buttons: 0, button: 2 }); + map._renderTaskQueue.run(); + + expect(rotatestart).toHaveBeenCalledTimes(0); + expect(rotate).toHaveBeenCalledTimes(0); + expect(rotateend).toHaveBeenCalledTimes(0); + + map.remove(); + }); + + test('DragRotateHandler can be disabled after mousedown (#2419)', () => { + const map = createMap(); + + // Prevent inertial rotation. + jest.spyOn(browser, 'now').mockReturnValue(0); + + const rotatestart = jest.fn(); + const rotate = jest.fn(); + const rotateend = jest.fn(); + + map.on('rotatestart', rotatestart); + map.on('rotate', rotate); + map.on('rotateend', rotateend); + + simulate.mousedown(map.getCanvasContainer(), { buttons: 2, button: 2 }); + map._renderTaskQueue.run(); + + map.dragRotate.disable(); + + simulate.mousemove(map.getCanvasContainer(), { buttons: 2, clientX: 10, clientY: 10 }); + map._renderTaskQueue.run(); + + expect(rotatestart).toHaveBeenCalledTimes(0); + expect(rotate).toHaveBeenCalledTimes(0); + expect(rotateend).toHaveBeenCalledTimes(0); + expect(map.isMoving()).toBe(false); + expect(map.dragRotate.isEnabled()).toBe(false); + + simulate.mouseup(map.getCanvasContainer(), { buttons: 0, button: 2 }); + map._renderTaskQueue.run(); + + expect(rotatestart).toHaveBeenCalledTimes(0); + expect(rotate).toHaveBeenCalledTimes(0); + expect(rotateend).toHaveBeenCalledTimes(0); + expect(map.isMoving()).toBe(false); + expect(map.dragRotate.isEnabled()).toBe(false); + + map.remove(); + }); + + test('DragRotateHandler does not begin rotation on spurious mousemove events', () => { + const map = createMap(); + + const rotatestart = jest.fn(); + const rotate = jest.fn(); + const rotateend = jest.fn(); + + map.on('rotatestart', rotatestart); + map.on('rotate', rotate); + map.on('rotateend', rotateend); + + simulate.mousedown(map.getCanvasContainer(), { + buttons: 2, + button: 2, + clientX: 10, + clientY: 10, + }); + map._renderTaskQueue.run(); + expect(rotatestart).toHaveBeenCalledTimes(0); + expect(rotate).toHaveBeenCalledTimes(0); + expect(rotateend).toHaveBeenCalledTimes(0); + + simulate.mousemove(map.getCanvasContainer(), { buttons: 2, clientX: 10, clientY: 10 }); + map._renderTaskQueue.run(); + expect(rotatestart).toHaveBeenCalledTimes(0); + expect(rotate).toHaveBeenCalledTimes(0); + expect(rotateend).toHaveBeenCalledTimes(0); + + simulate.mouseup(map.getCanvasContainer(), { buttons: 0, button: 2, clientX: 10, clientY: 10 }); + map._renderTaskQueue.run(); + expect(rotatestart).toHaveBeenCalledTimes(0); + expect(rotate).toHaveBeenCalledTimes(0); + expect(rotateend).toHaveBeenCalledTimes(0); + + map.remove(); + }); + + test('DragRotateHandler does not begin a mouse drag if moved less than click tolerance', () => { + const map = createMap({ clickTolerance: 4 }); + + // Prevent inertial rotation. + jest.spyOn(browser, 'now').mockReturnValue(0); + + const rotatestart = jest.fn(); + const rotate = jest.fn(); + const rotateend = jest.fn(); + const pitchstart = jest.fn(); + const pitch = jest.fn(); + const pitchend = jest.fn(); + + map.on('rotatestart', rotatestart); + map.on('rotate', rotate); + map.on('rotateend', rotateend); + map.on('pitchstart', pitchstart); + map.on('pitch', pitch); + map.on('pitchend', pitchend); + + simulate.mousedown(map.getCanvasContainer(), { + buttons: 2, + button: 2, + clientX: 10, + clientY: 10, + }); + map._renderTaskQueue.run(); + expect(rotatestart).toHaveBeenCalledTimes(0); + expect(rotate).toHaveBeenCalledTimes(0); + expect(rotateend).toHaveBeenCalledTimes(0); + expect(pitchstart).toHaveBeenCalledTimes(0); + expect(pitch).toHaveBeenCalledTimes(0); + expect(pitchend).toHaveBeenCalledTimes(0); + + simulate.mousemove(map.getCanvasContainer(), { buttons: 2, clientX: 13, clientY: 10 }); + map._renderTaskQueue.run(); + expect(rotatestart).toHaveBeenCalledTimes(0); + expect(rotate).toHaveBeenCalledTimes(0); + expect(rotateend).toHaveBeenCalledTimes(0); + expect(pitchstart).toHaveBeenCalledTimes(0); + expect(pitch).toHaveBeenCalledTimes(0); + expect(pitchend).toHaveBeenCalledTimes(0); + + simulate.mousemove(map.getCanvasContainer(), { buttons: 2, clientX: 10, clientY: 13 }); + map._renderTaskQueue.run(); + expect(rotatestart).toHaveBeenCalledTimes(0); + expect(rotate).toHaveBeenCalledTimes(0); + expect(rotateend).toHaveBeenCalledTimes(0); + expect(pitchstart).toHaveBeenCalledTimes(0); + expect(pitch).toHaveBeenCalledTimes(0); + expect(pitchend).toHaveBeenCalledTimes(0); + + simulate.mousemove(map.getCanvasContainer(), { buttons: 2, clientX: 14, clientY: 10 - 4 }); + map._renderTaskQueue.run(); + expect(rotatestart).toHaveBeenCalledTimes(1); + expect(rotate).toHaveBeenCalledTimes(1); + expect(rotateend).toHaveBeenCalledTimes(0); + expect(pitchstart).toHaveBeenCalledTimes(1); + expect(pitch).toHaveBeenCalledTimes(1); + expect(pitchend).toHaveBeenCalledTimes(0); + + map.remove(); + }); +}); diff --git a/packages/map/__tests__/handler/keyboard.spec.ts b/packages/map/__tests__/handler/keyboard.spec.ts new file mode 100644 index 00000000000..3184bf6e458 --- /dev/null +++ b/packages/map/__tests__/handler/keyboard.spec.ts @@ -0,0 +1,232 @@ +import { Map } from '../../src/map/map'; +import { DOM } from '../../src/map/util/dom'; +import { extend } from '../../src/map/util/util'; +import simulate from '../libs/simulate_interaction'; +import { beforeMapTest } from '../libs/util'; + +function createMap(options?) { + return new Map( + extend( + { + container: DOM.create('div', '', window.document.body), + }, + options, + ), + ); +} + +beforeEach(() => { + beforeMapTest(); +}); + +describe('keyboard', () => { + test('KeyboardHandler responds to keydown events', () => { + const map = createMap(); + const h = map.keyboard; + const spy = jest.spyOn(h, 'keydown'); + + simulate.keydown(map.getCanvasContainer(), { keyCode: 32, key: ' ' }); + expect(h.keydown).toHaveBeenCalled(); + expect(spy.mock.calls[0][0].keyCode).toBe(32); + }); + + test('KeyboardHandler pans map in response to arrow keys', () => { + const map = createMap({ zoom: 10, center: [0, 0] }); + const spy = jest.spyOn(map, 'easeTo'); + + simulate.keydown(map.getCanvasContainer(), { keyCode: 32, key: ' ' }); + expect(map.easeTo).not.toHaveBeenCalled(); + + simulate.keydown(map.getCanvasContainer(), { keyCode: 37, key: 'ArrowLeft' }); + expect(map.easeTo).toHaveBeenCalled(); + let easeToArgs = spy.mock.calls[0][0]; + expect(easeToArgs.offset[0]).toBe(100); + expect(easeToArgs.offset[1]).toBe(-0); + + simulate.keydown(map.getCanvasContainer(), { keyCode: 39, key: 'ArrowRight' }); + expect(spy).toHaveBeenCalledTimes(2); + easeToArgs = spy.mock.calls[1][0]; + expect(easeToArgs.offset[0]).toBe(-100); + expect(easeToArgs.offset[1]).toBe(-0); + + simulate.keydown(map.getCanvasContainer(), { keyCode: 40, key: 'ArrowDown' }); + expect(spy).toHaveBeenCalledTimes(3); + easeToArgs = spy.mock.calls[2][0]; + expect(easeToArgs.offset[0]).toBe(-0); + expect(easeToArgs.offset[1]).toBe(-100); + + simulate.keydown(map.getCanvasContainer(), { keyCode: 38, key: 'ArrowUp' }); + expect(spy).toHaveBeenCalledTimes(4); + easeToArgs = spy.mock.calls[3][0]; + expect(easeToArgs.offset[0]).toBe(-0); + expect(easeToArgs.offset[1]).toBe(100); + }); + + test('KeyboardHandler pans map in response to arrow keys when disableRotation has been called', () => { + const map = createMap({ zoom: 10, center: [0, 0] }); + const spy = jest.spyOn(map, 'easeTo'); + map.keyboard.disableRotation(); + + simulate.keydown(map.getCanvasContainer(), { keyCode: 32, key: ' ' }); + expect(map.easeTo).not.toHaveBeenCalled(); + + simulate.keydown(map.getCanvasContainer(), { keyCode: 37, key: 'ArrowLeft' }); + expect(map.easeTo).toHaveBeenCalled(); + let easeToArgs = spy.mock.calls[0][0]; + expect(easeToArgs.offset[0]).toBe(100); + expect(easeToArgs.offset[1]).toBe(-0); + + simulate.keydown(map.getCanvasContainer(), { keyCode: 39, key: 'ArrowRight' }); + expect(spy).toHaveBeenCalledTimes(2); + easeToArgs = spy.mock.calls[1][0]; + expect(easeToArgs.offset[0]).toBe(-100); + expect(easeToArgs.offset[1]).toBe(-0); + + simulate.keydown(map.getCanvasContainer(), { keyCode: 40, key: 'ArrowDown' }); + expect(spy).toHaveBeenCalledTimes(3); + easeToArgs = spy.mock.calls[2][0]; + expect(easeToArgs.offset[0]).toBe(-0); + expect(easeToArgs.offset[1]).toBe(-100); + + simulate.keydown(map.getCanvasContainer(), { keyCode: 38, key: 'ArrowUp' }); + expect(spy).toHaveBeenCalledTimes(4); + easeToArgs = spy.mock.calls[3][0]; + expect(easeToArgs.offset[0]).toBe(-0); + expect(easeToArgs.offset[1]).toBe(100); + }); + + test('KeyboardHandler rotates map in response to Shift+left/right arrow keys', async () => { + const map = createMap({ zoom: 10, center: [0, 0], bearing: 0 }); + const spy = jest.spyOn(map, 'easeTo'); + + simulate.keydown(map.getCanvasContainer(), { keyCode: 32, key: ' ' }); + expect(map.easeTo).not.toHaveBeenCalled(); + + simulate.keydown(map.getCanvasContainer(), { keyCode: 37, key: 'ArrowLeft', shiftKey: true }); + expect(map.easeTo).toHaveBeenCalled(); + let easeToArgs = spy.mock.calls[0][0]; + expect(easeToArgs.bearing).toBe(-15); + expect(easeToArgs.offset[0]).toBe(-0); + + map.setBearing(0); + simulate.keydown(map.getCanvasContainer(), { keyCode: 39, key: 'ArrowRight', shiftKey: true }); + expect(spy).toHaveBeenCalledTimes(2); + easeToArgs = spy.mock.calls[1][0]; + expect(easeToArgs.bearing).toBe(15); + expect(easeToArgs.offset[0]).toBe(-0); + }); + + test('KeyboardHandler does not rotate map in response to Shift+left/right arrow keys when disableRotation has been called', async () => { + const map = createMap({ zoom: 10, center: [0, 0], bearing: 0 }); + const spy = jest.spyOn(map, 'easeTo'); + map.keyboard.disableRotation(); + + simulate.keydown(map.getCanvasContainer(), { keyCode: 32, key: ' ' }); + expect(map.easeTo).not.toHaveBeenCalled(); + + simulate.keydown(map.getCanvasContainer(), { keyCode: 37, key: 'ArrowLeft', shiftKey: true }); + expect(map.easeTo).toHaveBeenCalled(); + let easeToArgs = spy.mock.calls[0][0]; + expect(easeToArgs.bearing).toBe(0); + expect(easeToArgs.offset[0]).toBe(-0); + + map.setBearing(0); + simulate.keydown(map.getCanvasContainer(), { keyCode: 39, key: 'ArrowRight', shiftKey: true }); + expect(spy).toHaveBeenCalledTimes(2); + easeToArgs = spy.mock.calls[1][0]; + expect(easeToArgs.bearing).toBe(0); + expect(easeToArgs.offset[0]).toBe(-0); + }); + + test('KeyboardHandler pitches map in response to Shift+up/down arrow keys', async () => { + const map = createMap({ zoom: 10, center: [0, 0], pitch: 30 }); + const spy = jest.spyOn(map, 'easeTo'); + + simulate.keydown(map.getCanvasContainer(), { keyCode: 32, key: ' ' }); + expect(map.easeTo).not.toHaveBeenCalled(); + + simulate.keydown(map.getCanvasContainer(), { keyCode: 40, key: 'ArrowDown', shiftKey: true }); + expect(map.easeTo).toHaveBeenCalled(); + let easeToArgs = spy.mock.calls[0][0]; + expect(easeToArgs.pitch).toBe(20); + expect(easeToArgs.offset[1]).toBe(-0); + + map.setPitch(30); + simulate.keydown(map.getCanvasContainer(), { keyCode: 38, key: 'ArrowUp', shiftKey: true }); + expect(spy).toHaveBeenCalledTimes(2); + easeToArgs = spy.mock.calls[1][0]; + expect(easeToArgs.pitch).toBe(40); + expect(easeToArgs.offset[1]).toBe(-0); + }); + + test('KeyboardHandler does not pitch map in response to Shift+up/down arrow keys when disableRotation has been called', async () => { + const map = createMap({ zoom: 10, center: [0, 0], pitch: 30 }); + const spy = jest.spyOn(map, 'easeTo'); + map.keyboard.disableRotation(); + + simulate.keydown(map.getCanvasContainer(), { keyCode: 32, key: ' ' }); + expect(map.easeTo).not.toHaveBeenCalled(); + + simulate.keydown(map.getCanvasContainer(), { keyCode: 40, key: 'ArrowDown', shiftKey: true }); + expect(map.easeTo).toHaveBeenCalled(); + let easeToArgs = spy.mock.calls[0][0]; + expect(easeToArgs.pitch).toBe(30); + expect(easeToArgs.offset[1]).toBe(-0); + + map.setPitch(30); + simulate.keydown(map.getCanvasContainer(), { keyCode: 38, key: 'ArrowUp', shiftKey: true }); + expect(spy).toHaveBeenCalledTimes(2); + easeToArgs = spy.mock.calls[1][0]; + expect(easeToArgs.pitch).toBe(30); + expect(easeToArgs.offset[1]).toBe(-0); + }); + + test('KeyboardHandler zooms map in response to -/+ keys', () => { + const map = createMap({ zoom: 10, center: [0, 0] }); + const spy = jest.spyOn(map, 'easeTo'); + + simulate.keydown(map.getCanvasContainer(), { keyCode: 187, key: 'Equal' }); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][0].zoom).toBe(11); + + map.setZoom(10); + simulate.keydown(map.getCanvasContainer(), { keyCode: 187, key: 'Equal', shiftKey: true }); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy.mock.calls[1][0].zoom).toBe(12); + + map.setZoom(10); + simulate.keydown(map.getCanvasContainer(), { keyCode: 189, key: 'Minus' }); + expect(spy).toHaveBeenCalledTimes(3); + expect(spy.mock.calls[2][0].zoom).toBe(9); + + map.setZoom(10); + simulate.keydown(map.getCanvasContainer(), { keyCode: 189, key: 'Minus', shiftKey: true }); + expect(spy).toHaveBeenCalledTimes(4); + expect(spy.mock.calls[3][0].zoom).toBe(8); + }); + + test('KeyboardHandler zooms map in response to -/+ keys when disableRotation has been called', () => { + const map = createMap({ zoom: 10, center: [0, 0] }); + const spy = jest.spyOn(map, 'easeTo'); + map.keyboard.disableRotation(); + + simulate.keydown(map.getCanvasContainer(), { keyCode: 187, key: 'Equal' }); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][0].zoom).toBe(11); + + map.setZoom(10); + simulate.keydown(map.getCanvasContainer(), { keyCode: 187, key: 'Equal', shiftKey: true }); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy.mock.calls[1][0].zoom).toBe(12); + + map.setZoom(10); + simulate.keydown(map.getCanvasContainer(), { keyCode: 189, key: 'Minus' }); + expect(spy).toHaveBeenCalledTimes(3); + expect(spy.mock.calls[2][0].zoom).toBe(9); + + map.setZoom(10); + simulate.keydown(map.getCanvasContainer(), { keyCode: 189, key: 'Minus', shiftKey: true }); + expect(spy).toHaveBeenCalledTimes(4); + expect(spy.mock.calls[3][0].zoom).toBe(8); + }); +}); diff --git a/packages/map/__tests__/handler/map_event.spec.ts b/packages/map/__tests__/handler/map_event.spec.ts new file mode 100644 index 00000000000..6404bac1ed7 --- /dev/null +++ b/packages/map/__tests__/handler/map_event.spec.ts @@ -0,0 +1,182 @@ +import type { MapOptions } from '../../src/map/map'; +import { Map } from '../../src/map/map'; +import { DOM } from '../../src/map/util/dom'; +import simulate from '../libs/simulate_interaction'; +import { beforeMapTest } from '../libs/util'; + +function createMap() { + return new Map({ + interactive: true, + container: DOM.create('div', '', window.document.body), + } as any as MapOptions); +} + +beforeEach(() => { + beforeMapTest(); +}); + +describe('map events', () => { + test('MapEvent handler fires touch events with correct values', () => { + const map = createMap(); + const target = map.getCanvasContainer(); + + const touchstart = jest.fn(); + const touchmove = jest.fn(); + const touchend = jest.fn(); + + map.on('touchstart', touchstart); + map.on('touchmove', touchmove); + map.on('touchend', touchend); + + const touchesStart = [{ target, identifier: 1, clientX: 0, clientY: 50 }]; + const touchesMove = [{ target, identifier: 1, clientX: 0, clientY: 60 }]; + const touchesEnd = [{ target, identifier: 1, clientX: 0, clientY: 60 }]; + + simulate.touchstart(map.getCanvasContainer(), { + touches: touchesStart, + targetTouches: touchesStart, + }); + expect(touchstart).toHaveBeenCalledTimes(1); + expect(touchstart.mock.calls[0][0].point).toEqual({ x: 0, y: 50 }); + expect(touchmove).toHaveBeenCalledTimes(0); + expect(touchend).toHaveBeenCalledTimes(0); + + simulate.touchmove(map.getCanvasContainer(), { + touches: touchesMove, + targetTouches: touchesMove, + }); + expect(touchstart).toHaveBeenCalledTimes(1); + expect(touchmove).toHaveBeenCalledTimes(1); + expect(touchmove.mock.calls[0][0].point).toEqual({ x: 0, y: 60 }); + expect(touchend).toHaveBeenCalledTimes(0); + + simulate.touchend(map.getCanvasContainer(), { + touches: [], + targetTouches: [], + changedTouches: touchesEnd, + }); + expect(touchstart).toHaveBeenCalledTimes(1); + expect(touchmove).toHaveBeenCalledTimes(1); + expect(touchend).toHaveBeenCalledTimes(1); + expect(touchend.mock.calls[0][0].point).toEqual({ x: 0, y: 60 }); + + map.remove(); + }); + + test('MapEvent handler fires touchmove even while drag handler is active', () => { + const map = createMap(); + const target = map.getCanvasContainer(); + map.dragPan.enable(); + + const touchstart = jest.fn(); + const touchmove = jest.fn(); + const touchend = jest.fn(); + const drag = jest.fn(); + + map.on('touchstart', touchstart); + map.on('touchmove', touchmove); + map.on('touchend', touchend); + map.on('drag', drag); + + const touchesStart = [{ target, identifier: 1, clientX: 0, clientY: 50 }]; + const touchesMove = [{ target, identifier: 1, clientX: 0, clientY: 60 }]; + const touchesEnd = [{ target, identifier: 1, clientX: 0, clientY: 60 }]; + + simulate.touchstart(map.getCanvasContainer(), { + touches: touchesStart, + targetTouches: touchesStart, + }); + expect(touchstart).toHaveBeenCalledTimes(1); + expect(touchstart.mock.calls[0][0].point).toEqual({ x: 0, y: 50 }); + expect(touchmove).toHaveBeenCalledTimes(0); + expect(touchend).toHaveBeenCalledTimes(0); + + simulate.touchmove(map.getCanvasContainer(), { + touches: touchesMove, + targetTouches: touchesMove, + }); + expect(touchstart).toHaveBeenCalledTimes(1); + expect(touchmove).toHaveBeenCalledTimes(1); + expect(touchmove.mock.calls[0][0].point).toEqual({ x: 0, y: 60 }); + expect(touchend).toHaveBeenCalledTimes(0); + + simulate.touchend(map.getCanvasContainer(), { + touches: [], + targetTouches: [], + changedTouches: touchesEnd, + }); + expect(touchstart).toHaveBeenCalledTimes(1); + expect(touchmove).toHaveBeenCalledTimes(1); + expect(touchend).toHaveBeenCalledTimes(1); + expect(touchend.mock.calls[0][0].point).toEqual({ x: 0, y: 60 }); + + map._renderTaskQueue.run(); + expect(drag).toHaveBeenCalledTimes(1); + + map.remove(); + }); + + test('MapEvent handler fires contextmenu on MacOS/Linux, but only at mouseup', () => { + const map = createMap(); + const target = map.getCanvasContainer(); + map.dragPan.enable(); + + const contextmenu = jest.fn(); + + map.on('contextmenu', contextmenu); + + simulate.mousedown(map.getCanvasContainer(), { target, button: 2, clientX: 10, clientY: 10 }); + simulate.contextmenu(map.getCanvasContainer(), { target }); // triggered immediately after mousedown + expect(contextmenu).toHaveBeenCalledTimes(0); + simulate.mouseup(map.getCanvasContainer(), { target, button: 2, clientX: 10, clientY: 10 }); + expect(contextmenu).toHaveBeenCalledTimes(1); + }); + + test('MapEvent handler does not fire contextmenu on MacOS/Linux, when moved', () => { + const map = createMap(); + const target = map.getCanvasContainer(); + map.dragPan.enable(); + + const contextmenu = jest.fn(); + + map.on('contextmenu', contextmenu); + + simulate.mousedown(map.getCanvasContainer(), { target, button: 2, clientX: 10, clientY: 10 }); + simulate.contextmenu(map.getCanvasContainer(), { target }); // triggered immediately after mousedown + simulate.mousemove(map.getCanvasContainer(), { target, buttons: 2, clientX: 50, clientY: 10 }); + simulate.mouseup(map.getCanvasContainer(), { target, button: 2, clientX: 70, clientY: 10 }); + expect(contextmenu).toHaveBeenCalledTimes(0); + }); + + test('MapEvent handler fires contextmenu on Windows', () => { + const map = createMap(); + const target = map.getCanvasContainer(); + map.dragPan.enable(); + + const contextmenu = jest.fn(); + + map.on('contextmenu', contextmenu); + + simulate.mousedown(map.getCanvasContainer(), { target, button: 2, clientX: 10, clientY: 10 }); + simulate.mouseup(map.getCanvasContainer(), { target, button: 2, clientX: 10, clientY: 10 }); + expect(contextmenu).toHaveBeenCalledTimes(0); + simulate.contextmenu(map.getCanvasContainer(), { target, button: 2, clientX: 10, clientY: 10 }); // triggered only after mouseup + expect(contextmenu).toHaveBeenCalledTimes(1); + }); + + test('MapEvent handler does not fire contextmenu on Windows, when moved', () => { + const map = createMap(); + const target = map.getCanvasContainer(); + map.dragPan.enable(); + + const contextmenu = jest.fn(); + + map.on('contextmenu', contextmenu); + + simulate.mousedown(map.getCanvasContainer(), { target, button: 2, clientX: 10, clientY: 10 }); + simulate.mousemove(map.getCanvasContainer(), { target, buttons: 2, clientX: 50, clientY: 10 }); + simulate.mouseup(map.getCanvasContainer(), { target, button: 2, clientX: 50, clientY: 10 }); + simulate.contextmenu(map.getCanvasContainer(), { target, button: 2, clientX: 10, clientY: 10 }); // triggered only after mouseup + expect(contextmenu).toHaveBeenCalledTimes(0); + }); +}); diff --git a/packages/map/__tests__/handler/mouse_handler_interface.spec.ts b/packages/map/__tests__/handler/mouse_handler_interface.spec.ts new file mode 100644 index 00000000000..7b1673d7b25 --- /dev/null +++ b/packages/map/__tests__/handler/mouse_handler_interface.spec.ts @@ -0,0 +1,117 @@ +import Point from '@mapbox/point-geometry'; + +import { + generateMousePanHandler, + generateMousePitchHandler, + generateMouseRotationHandler, +} from '../../src/map/handler/mouse'; + +describe('mouse handler tests', () => { + test('MouseRotateHandler', () => { + const mouseRotate = generateMouseRotationHandler({ clickTolerance: 2 }); + + expect(mouseRotate.isActive()).toBe(false); + expect(mouseRotate.isEnabled()).toBe(false); + mouseRotate.enable(); + expect(mouseRotate.isEnabled()).toBe(true); + + mouseRotate.dragStart(new MouseEvent('mousedown', { buttons: 2, button: 2 }), new Point(0, 0)); + expect(mouseRotate.isActive()).toBe(false); + + const underToleranceMove = new MouseEvent('mousemove', { buttons: 2, clientX: 1, clientY: 1 }); + expect(mouseRotate.dragMove(underToleranceMove, new Point(1, 1))).toBeUndefined(); + expect(mouseRotate.isActive()).toBe(false); + + const overToleranceMove = new MouseEvent('mousemove', { buttons: 2, clientX: 10, clientY: 10 }); + expect(mouseRotate.dragMove(overToleranceMove, new Point(10, 10))).toEqual({ bearingDelta: 8 }); + expect(mouseRotate.isActive()).toBe(true); + + mouseRotate.dragEnd(new MouseEvent('mouseup', { buttons: 0, button: 2 })); + expect(mouseRotate.isActive()).toBe(false); + + mouseRotate.disable(); + expect(mouseRotate.isEnabled()).toBe(false); + + mouseRotate.dragStart(new MouseEvent('mousedown', { buttons: 2, button: 2 }), new Point(0, 0)); + expect(mouseRotate.isActive()).toBe(false); + + expect(mouseRotate.dragMove(underToleranceMove, new Point(1, 1))).toBeUndefined(); + expect(mouseRotate.isActive()).toBe(false); + + expect(mouseRotate.dragMove(overToleranceMove, new Point(10, 10))).toBeUndefined(); + expect(mouseRotate.isActive()).toBe(false); + }); + + test('MousePitchHandler', () => { + const mousePitch = generateMousePitchHandler({ clickTolerance: 2 }); + + expect(mousePitch.isActive()).toBe(false); + expect(mousePitch.isEnabled()).toBe(false); + mousePitch.enable(); + expect(mousePitch.isEnabled()).toBe(true); + + mousePitch.dragStart(new MouseEvent('mousedown', { buttons: 2, button: 2 }), new Point(0, 0)); + expect(mousePitch.isActive()).toBe(false); + + const underToleranceMove = new MouseEvent('mousemove', { buttons: 2, clientX: 1, clientY: 1 }); + expect(mousePitch.dragMove(underToleranceMove, new Point(1, 1))).toBeUndefined(); + expect(mousePitch.isActive()).toBe(false); + + const overToleranceMove = new MouseEvent('mousemove', { buttons: 2, clientX: 10, clientY: 10 }); + expect(mousePitch.dragMove(overToleranceMove, new Point(10, 10))).toEqual({ pitchDelta: -5 }); + expect(mousePitch.isActive()).toBe(true); + + mousePitch.dragEnd(new MouseEvent('mouseup', { buttons: 0, button: 2 })); + expect(mousePitch.isActive()).toBe(false); + + mousePitch.disable(); + expect(mousePitch.isEnabled()).toBe(false); + + mousePitch.dragStart(new MouseEvent('mousedown', { buttons: 2, button: 2 }), new Point(0, 0)); + expect(mousePitch.isActive()).toBe(false); + + expect(mousePitch.dragMove(underToleranceMove, new Point(1, 1))).toBeUndefined(); + expect(mousePitch.isActive()).toBe(false); + + expect(mousePitch.dragMove(overToleranceMove, new Point(10, 10))).toBeUndefined(); + expect(mousePitch.isActive()).toBe(false); + }); + + test('MousePanHandler', () => { + const mousePan = generateMousePanHandler({ clickTolerance: 2 }); + + expect(mousePan.isActive()).toBe(false); + expect(mousePan.isEnabled()).toBe(false); + mousePan.enable(); + expect(mousePan.isEnabled()).toBe(true); + + mousePan.dragStart(new MouseEvent('mousedown', { buttons: 1, button: 0 }), new Point(0, 0)); + expect(mousePan.isActive()).toBe(true); + + const underToleranceMove = new MouseEvent('mousemove', { buttons: 1, clientX: 1, clientY: 1 }); + expect(mousePan.dragMove(underToleranceMove, new Point(1, 1))).toBeUndefined(); + expect(mousePan.isActive()).toBe(true); + + const overToleranceMove = new MouseEvent('mousemove', { buttons: 1, clientX: 10, clientY: 10 }); + expect(mousePan.dragMove(overToleranceMove, new Point(10, 10))).toEqual({ + around: { x: 10, y: 10 }, + panDelta: { x: 10, y: 10 }, + }); + expect(mousePan.isActive()).toBe(true); + + mousePan.dragEnd(new MouseEvent('mouseup', { buttons: 0, button: 0 })); + expect(mousePan.isActive()).toBe(false); + + mousePan.disable(); + expect(mousePan.isEnabled()).toBe(false); + + mousePan.dragStart(new MouseEvent('mousedown', { buttons: 2, button: 2 }), new Point(0, 0)); + expect(mousePan.isActive()).toBe(false); + + expect(mousePan.dragMove(underToleranceMove, new Point(1, 1))).toBeUndefined(); + expect(mousePan.isActive()).toBe(false); + + expect(mousePan.dragMove(overToleranceMove, new Point(10, 10))).toBeUndefined(); + expect(mousePan.isActive()).toBe(false); + }); +}); diff --git a/packages/map/__tests__/handler/mouse_rotate.spec.ts b/packages/map/__tests__/handler/mouse_rotate.spec.ts new file mode 100644 index 00000000000..ff7d642e9a6 --- /dev/null +++ b/packages/map/__tests__/handler/mouse_rotate.spec.ts @@ -0,0 +1,62 @@ +import { Map } from '../../src/map/map'; +import { browser } from '../../src/map/util/browser'; +import { DOM } from '../../src/map/util/dom'; +import { extend } from '../../src/map/util/util'; +import simulate from '../libs/simulate_interaction'; +import { beforeMapTest } from '../libs/util'; + +function createMap(options?) { + return new Map(extend({ container: DOM.create('div', '', window.document.body) }, options)); +} + +beforeEach(() => { + beforeMapTest(); +}); + +describe('mouse rotate', () => { + test('MouseRotateHandler#isActive', () => { + const map = createMap({ interactive: true }); + const mouseRotate = map.handlers._handlersById.mouseRotate; + + // Prevent inertial rotation. + jest.spyOn(browser, 'now').mockReturnValue(0); + expect(mouseRotate.isActive()).toBe(false); + + simulate.mousedown(map.getCanvasContainer(), { buttons: 2, button: 2, clientX: 0, clientY: 0 }); + map._renderTaskQueue.run(); + expect(mouseRotate.isActive()).toBe(false); + + simulate.mousemove(map.getCanvasContainer(), { buttons: 2, clientX: 10, clientY: 10 }); + map._renderTaskQueue.run(); + expect(mouseRotate.isActive()).toBe(true); + + simulate.mouseup(map.getCanvasContainer(), { buttons: 0, button: 2 }); + map._renderTaskQueue.run(); + expect(mouseRotate.isActive()).toBe(false); + + map.remove(); + }); + + test('MouseRotateHandler#isActive #4622 regression test', () => { + const map = createMap({ interactive: true }); + const mouseRotate = map.handlers._handlersById.mouseRotate; + + // Prevent inertial rotation. + simulate.mousedown(map.getCanvasContainer(), { buttons: 2, button: 2 }); + map._renderTaskQueue.run(); + expect(mouseRotate.isActive()).toBe(false); + + simulate.mousemove(map.getCanvasContainer(), { buttons: 2, clientX: 10, clientY: 10 }); + map._renderTaskQueue.run(); + expect(mouseRotate.isActive()).toBe(true); + + // Some browsers don't fire mouseup when it happens outside the window. + // Make the handler in active when it encounters a mousemove without the button pressed. + + simulate.mousemove(map.getCanvasContainer(), { buttons: 0, clientX: 10, clientY: 10 }); + map._renderTaskQueue.run(); + expect(mouseRotate.isActive()).toBe(false); + + map.remove(); + }); +}); diff --git a/packages/map/__tests__/handler/one_finger_touch_drag_handler_interface.spec.ts b/packages/map/__tests__/handler/one_finger_touch_drag_handler_interface.spec.ts new file mode 100644 index 00000000000..b6c0873097f --- /dev/null +++ b/packages/map/__tests__/handler/one_finger_touch_drag_handler_interface.spec.ts @@ -0,0 +1,96 @@ +import Point from '@mapbox/point-geometry'; + +import { + generateOneFingerTouchPitchHandler, + generateOneFingerTouchRotationHandler, +} from '../../src/map/handler/one_finger_touch_drag'; + +const testTouch = { identifier: 0 } as Touch; + +describe('one touch drag handler tests', () => { + test('OneFingerTouchRotateHandler', () => { + const oneTouchRotate = generateOneFingerTouchRotationHandler({ clickTolerance: 2 }); + + expect(oneTouchRotate.isActive()).toBe(false); + expect(oneTouchRotate.isEnabled()).toBe(false); + oneTouchRotate.enable(); + expect(oneTouchRotate.isEnabled()).toBe(true); + + oneTouchRotate.dragStart( + new TouchEvent('touchstart', { targetTouches: [testTouch] }), + new Point(0, 0), + ); + expect(oneTouchRotate.isActive()).toBe(false); + + const underToleranceMove = new TouchEvent('touchmove', { targetTouches: [testTouch] }); + expect(oneTouchRotate.dragMove(underToleranceMove, new Point(1, 1))).toBeUndefined(); + expect(oneTouchRotate.isActive()).toBe(false); + + const overToleranceMove = new TouchEvent('touchmove', { targetTouches: [testTouch] }); + expect(oneTouchRotate.dragMove(overToleranceMove, new Point(10, 10))).toEqual({ + bearingDelta: 8, + }); + expect(oneTouchRotate.isActive()).toBe(true); + + oneTouchRotate.dragEnd(new TouchEvent('touchend', { targetTouches: [testTouch] })); + expect(oneTouchRotate.isActive()).toBe(false); + + oneTouchRotate.disable(); + expect(oneTouchRotate.isEnabled()).toBe(false); + + oneTouchRotate.dragStart( + new TouchEvent('touchstart', { targetTouches: [testTouch] }), + new Point(0, 0), + ); + expect(oneTouchRotate.isActive()).toBe(false); + + expect(oneTouchRotate.dragMove(underToleranceMove, new Point(1, 1))).toBeUndefined(); + expect(oneTouchRotate.isActive()).toBe(false); + + expect(oneTouchRotate.dragMove(overToleranceMove, new Point(10, 10))).toBeUndefined(); + expect(oneTouchRotate.isActive()).toBe(false); + }); + + test('OneFingerTouchPitchHandler', () => { + const oneTouchPitch = generateOneFingerTouchPitchHandler({ clickTolerance: 2 }); + + expect(oneTouchPitch.isActive()).toBe(false); + expect(oneTouchPitch.isEnabled()).toBe(false); + oneTouchPitch.enable(); + expect(oneTouchPitch.isEnabled()).toBe(true); + + oneTouchPitch.dragStart( + new TouchEvent('touchstart', { targetTouches: [testTouch] }), + new Point(0, 0), + ); + expect(oneTouchPitch.isActive()).toBe(false); + + const underToleranceMove = new TouchEvent('touchmove', { targetTouches: [testTouch] }); + expect(oneTouchPitch.dragMove(underToleranceMove, new Point(1, 1))).toBeUndefined(); + expect(oneTouchPitch.isActive()).toBe(false); + + const overToleranceMove = new TouchEvent('touchmove', { targetTouches: [testTouch] }); + expect(oneTouchPitch.dragMove(overToleranceMove, new Point(10, 10))).toEqual({ + pitchDelta: -5, + }); + expect(oneTouchPitch.isActive()).toBe(true); + + oneTouchPitch.dragEnd(new TouchEvent('touchend', { targetTouches: [testTouch] })); + expect(oneTouchPitch.isActive()).toBe(false); + + oneTouchPitch.disable(); + expect(oneTouchPitch.isEnabled()).toBe(false); + + oneTouchPitch.dragStart( + new TouchEvent('touchstart', { targetTouches: [testTouch] }), + new Point(0, 0), + ); + expect(oneTouchPitch.isActive()).toBe(false); + + expect(oneTouchPitch.dragMove(underToleranceMove, new Point(1, 1))).toBeUndefined(); + expect(oneTouchPitch.isActive()).toBe(false); + + expect(oneTouchPitch.dragMove(overToleranceMove, new Point(10, 10))).toBeUndefined(); + expect(oneTouchPitch.isActive()).toBe(false); + }); +}); diff --git a/packages/map/__tests__/handler/scroll_zoom.spec.ts b/packages/map/__tests__/handler/scroll_zoom.spec.ts index da5779af1d9..a488fa69ea7 100644 --- a/packages/map/__tests__/handler/scroll_zoom.spec.ts +++ b/packages/map/__tests__/handler/scroll_zoom.spec.ts @@ -1,82 +1,296 @@ -import Point from '../../src/geo/point'; -import type HandlerManager from '../../src/handler/handler_manager'; -import ScrollZoomHandler from '../../src/handler/scroll_zoom'; -import { Map } from '../../src/map'; - -describe('Map', () => { - const el = document.createElement('div'); - el.id = 'test-div-id'; - // el.style.width = '500px'; - // el.style.height = '500px'; - el.style.background = '#aaa'; - let map: Map; - let handlerManager: HandlerManager; - let scrollZoomHandler: ScrollZoomHandler; - document.querySelector('body')?.appendChild(el); - beforeEach(() => { - map = new Map({ - container: el, - zoom: 10, +import { Map } from '../../src/map/map'; +import { browser } from '../../src/map/util/browser'; +import { DOM } from '../../src/map/util/dom'; +import simulate from '../libs/simulate_interaction'; +import { beforeMapTest, setPerformance } from '../libs/util'; + +function createMap() { + return new Map({ + container: DOM.create('div', '', window.document.body), + }); +} + +beforeEach(() => { + beforeMapTest(); +}); + +describe('ScrollZoomHandler', () => { + test('Zooms for single mouse wheel tick', () => { + const browserNow = jest.spyOn(browser, 'now'); + let now = 1555555555555; + browserNow.mockReturnValue(now); + + const map = createMap(); + map._renderTaskQueue.run(); + + // simulate a single 'wheel' event + const startZoom = map.getZoom(); + + simulate.wheel(map.getCanvasContainer(), { + type: 'wheel', + deltaY: -simulate.magicWheelZoomDelta, }); - handlerManager = map.handlers; - // @ts-ignore - scrollZoomHandler = handlerManager.handlersById['scrollZoom']; + map._renderTaskQueue.run(); + + now += 400; + browserNow.mockReturnValue(now); + map._renderTaskQueue.run(); + + expect(map.getZoom() - startZoom).toBeCloseTo(0.0285, 3); + + map.remove(); }); - // handlerManager isactive - it('handlerManager is active', () => { - expect(handlerManager.isActive()).toEqual(false); + test('Zooms for single mouse wheel tick with non-magical deltaY', (done) => { + const browserNow = jest.spyOn(browser, 'now'); + const now = 1555555555555; + browserNow.mockReturnValue(now); + + const map = createMap(); + map._renderTaskQueue.run(); + + // Simulate a single 'wheel' event without the magical deltaY value. + // This requires the handler to briefly wait to see if a subsequent + // event is coming in order to guess trackpad vs. mouse wheel + simulate.wheel(map.getCanvasContainer(), { type: 'wheel', deltaY: -20 }); + map.on('zoomstart', () => { + map.remove(); + done(); + }); }); - it('scrollZoomHandler', () => { - expect(scrollZoomHandler).toBeInstanceOf(ScrollZoomHandler); + test('Zooms for multiple mouse wheel ticks', () => { + const browserNow = jest.spyOn(browser, 'now'); + let now = 1555555555555; + browserNow.mockReturnValue(now); + + const map = createMap(); + + map._renderTaskQueue.run(); + const startZoom = map.getZoom(); + + const events = [ + [2, { type: 'wheel', deltaY: -simulate.magicWheelZoomDelta }], + [7, { type: 'wheel', deltaY: -41 }], + [30, { type: 'wheel', deltaY: -169 }], + [1, { type: 'wheel', deltaY: -801 }], + [5, { type: 'wheel', deltaY: -326 }], + [20, { type: 'wheel', deltaY: -345 }], + [22, { type: 'wheel', deltaY: -376 }], + ] as [number, any][]; + + const end = now + 500; + let lastWheelEvent = now; + + // simulate the above sequence of wheel events, with render frames + // interspersed every 20ms + while (now < end) { + now += 1; + browserNow.mockReturnValue(now); + if (events.length && lastWheelEvent + events[0][0] === now) { + const [, event] = events.shift(); + simulate.wheel(map.getCanvasContainer(), event); + lastWheelEvent = now; + } + if (now % 20 === 0) { + map._renderTaskQueue.run(); + } + } + + expect(map.getZoom() - startZoom).toBeCloseTo(1.944, 3); + + map.remove(); + }); + + test('Gracefully ignores wheel events with deltaY: 0', () => { + const browserNow = jest.spyOn(browser, 'now'); + let now = 1555555555555; + browserNow.mockReturnValue(now); + + const map = createMap(); + map._renderTaskQueue.run(); + + const startZoom = map.getZoom(); + // simulate shift+'wheel' events + simulate.wheel(map.getCanvasContainer(), { type: 'wheel', deltaY: -0, shiftKey: true }); + simulate.wheel(map.getCanvasContainer(), { type: 'wheel', deltaY: -0, shiftKey: true }); + simulate.wheel(map.getCanvasContainer(), { type: 'wheel', deltaY: -0, shiftKey: true }); + simulate.wheel(map.getCanvasContainer(), { type: 'wheel', deltaY: -0, shiftKey: true }); + map._renderTaskQueue.run(); + + now += 400; + browserNow.mockReturnValue(now); + map._renderTaskQueue.run(); + + expect(map.getZoom() - startZoom).toBe(0.0); }); - it('boxZoomHandler box select', () => { - // @ts-ignore - const boxZoom = handlerManager.handlersById['boxZoom']; - boxZoom.disable(); - boxZoom.enable(); - - // 模拟鼠标按下事件 - let e = new MouseEvent('mousedown', { - shiftKey: true, - button: 0, - clientX: 100, - clientY: 100, + + test('Gracefully handle wheel events that cancel each other out before the first scroll frame', () => { + // See also https://github.com/mapbox/mapbox-gl-js/issues/6782 + const browserNow = jest.spyOn(browser, 'now'); + let now = 1555555555555; + browserNow.mockReturnValue(now); + + const map = createMap(); + map._renderTaskQueue.run(); + + simulate.wheel(map.getCanvasContainer(), { type: 'wheel', deltaY: -1 }); + simulate.wheel(map.getCanvasContainer(), { type: 'wheel', deltaY: -1 }); + now += 1; + browserNow.mockReturnValue(now); + simulate.wheel(map.getCanvasContainer(), { type: 'wheel', deltaY: 2 }); + + map._renderTaskQueue.run(); + + now += 400; + browserNow.mockReturnValue(now); + map._renderTaskQueue.run(); + }); + + test('does not zoom if preventDefault is called on the wheel event', () => { + const browserNow = jest.spyOn(browser, 'now'); + let now = 1555555555555; + browserNow.mockReturnValue(now); + + const map = createMap(); + + map.on('wheel', (e) => e.preventDefault()); + + simulate.wheel(map.getCanvasContainer(), { + type: 'wheel', + deltaY: -simulate.magicWheelZoomDelta, + }); + map._renderTaskQueue.run(); + + now += 400; + browserNow.mockReturnValue(now); + map._renderTaskQueue.run(); + + expect(map.getZoom()).toBe(0); + + map.remove(); + }); + + test('emits one movestart event and one moveend event while zooming', () => { + const browserNow = jest.spyOn(browser, 'now'); + let now = 1555555555555; + browserNow.mockReturnValue(now); + jest.useFakeTimers(); + setPerformance(); + const map = createMap(); + + let startCount = 0; + map.on('movestart', () => { + startCount += 1; }); - // 创建一个 Point 对象 - const point1 = new Point(0, 0); - //@ts-ignore - boxZoom.mousedown(e, point1); - - // 模拟鼠标移动事件 - const point2 = new Point(200, 200); - e = new MouseEvent('mousemove', { clientX: 200, clientY: 200 }); - //@ts-ignore - boxZoom.mousemoveWindow(e, point2); - - // 模拟鼠标释放事件 - const point3 = new Point(200, 200); - e = new MouseEvent('mouseup', { clientX: 200, clientY: 200 }); - //@ts-ignore - boxZoom.mouseupWindow(e, point3); - expect(map.getZoom()).toEqual(10); - - // 验证结果 - // 这将取决于你的boxZoomHandler如何处理这些事件 - // 例如,你可能会检查地图的缩放级别或视口是否已经改变 + + let endCount = 0; + map.on('moveend', () => { + endCount += 1; + }); + + const events = [ + [2, { type: 'trackpad', deltaY: -1 }], + [7, { type: 'trackpad', deltaY: -2 }], + [30, { type: 'wheel', deltaY: -5 }], + ] as [number, any][]; + + const end = now + 50; + let lastWheelEvent = now; + + while (now < end) { + now += 1; + browserNow.mockReturnValue(now); + if (events.length && lastWheelEvent + events[0][0] === now) { + const [, event] = events.shift(); + simulate.wheel(map.getCanvasContainer(), event); + lastWheelEvent = now; + } + if (now % 20 === 0) { + map._renderTaskQueue.run(); + } + } + + jest.advanceTimersByTime(200); + + map._renderTaskQueue.run(); + + expect(startCount).toBe(1); + expect(endCount).toBe(1); }); - // wheel - it('scrollZoomHandler wheel', () => { - const e = new WheelEvent('wheel', { deltaY: -500 }); - scrollZoomHandler.wheel(e); - scrollZoomHandler.renderFrame(); - expect(map.getZoom()).toEqual(10); + + test('emits one zoomstart event and one zoomend event while zooming', () => { + const browserNow = jest.spyOn(browser, 'now'); + let now = 1555555555555; + browserNow.mockReturnValue(now); + + jest.useFakeTimers(); + setPerformance(); + const map = createMap(); + + let startCount = 0; + map.on('zoomstart', () => { + startCount += 1; + }); + + let endCount = 0; + map.on('zoomend', () => { + endCount += 1; + }); + + const events = [ + [2, { type: 'trackpad', deltaY: -1 }], + [7, { type: 'trackpad', deltaY: -2 }], + [30, { type: 'wheel', deltaY: -5 }], + ] as [number, any][]; + + const end = now + 50; + let lastWheelEvent = now; + + while (now < end) { + now += 1; + browserNow.mockReturnValue(now); + if (events.length && lastWheelEvent + events[0][0] === now) { + const [, event] = events.shift(); + simulate.wheel(map.getCanvasContainer(), event); + lastWheelEvent = now; + } + if (now % 20 === 0) { + map._renderTaskQueue.run(); + } + } + + jest.advanceTimersByTime(200); + map._renderTaskQueue.run(); + + expect(startCount).toBe(1); + expect(endCount).toBe(1); }); - // disable - it('scrollZoomHandler disable', () => { - scrollZoomHandler.disable(); - expect(scrollZoomHandler.isEnabled()).toEqual(false); + test('Zooms for single mouse wheel tick while not in the center of the map and terrain is on, should zoom according to mouse position', () => { + const browserNow = jest.spyOn(browser, 'now'); + let now = 1555555555555; + browserNow.mockReturnValue(now); + + const map = createMap(); + map._renderTaskQueue.run(); + + // simulate a single 'wheel' event + simulate.wheel(map.getCanvasContainer(), { + type: 'wheel', + deltaY: -simulate.magicWheelZoomDelta, + clientX: 1000, + clientY: 1000, + }); + map._renderTaskQueue.run(); + + now += 400; + browserNow.mockReturnValue(now); + map._renderTaskQueue.run(); + + expect(map.getCenter().lat).toBeCloseTo(-11.6371, 3); + expect(map.getCenter().lng).toBeCloseTo(11.0286, 3); + + map.remove(); }); }); diff --git a/packages/map/__tests__/handler/tap_drag_zoom.spec.ts b/packages/map/__tests__/handler/tap_drag_zoom.spec.ts new file mode 100644 index 00000000000..63ad3dd8f53 --- /dev/null +++ b/packages/map/__tests__/handler/tap_drag_zoom.spec.ts @@ -0,0 +1,113 @@ +import type { MapOptions } from '../../src/map/map'; +import { Map } from '../../src/map/map'; +import simulate from '../libs/simulate_interaction'; +import { beforeMapTest } from '../libs/util'; + +function createMap() { + return new Map({ container: window.document.createElement('div') } as any as MapOptions); +} + +function setupEvents(map: Map) { + const zoomstart = jest.fn(); + map.on('zoomstart', zoomstart); + + const zoom = jest.fn(); + map.on('zoom', zoom); + + const zoomend = jest.fn(); + map.on('zoomend', zoomend); + + return { + zoomstart, + zoom, + zoomend, + }; +} + +beforeEach(() => { + beforeMapTest(); +}); + +describe('tap_drag_zoom', () => { + test('TapDragZoomHandler fires zoomstart, zoom, and zoomend at appropriate times in response to a double-tap and drag gesture', () => { + const map = createMap(); + const target = map.getCanvasContainer(); + + const { zoomstart, zoom, zoomend } = setupEvents(map); + + const pointTouchOptions = { + touches: [{ target, clientX: 100, clientY: 100 }], + }; + + simulate.touchstart(target, pointTouchOptions); + simulate.touchend(target); + simulate.touchstart(target, pointTouchOptions); + map._renderTaskQueue.run(); + + expect(zoomstart).not.toHaveBeenCalled(); + expect(zoom).not.toHaveBeenCalled(); + expect(zoomend).not.toHaveBeenCalled(); + + simulate.touchmove(target, { + touches: [{ target, clientX: 100, clientY: 110 }], + }); + map._renderTaskQueue.run(); + + expect(zoomstart).toHaveBeenCalled(); + expect(zoom).toHaveBeenCalled(); + expect(zoomend).not.toHaveBeenCalled(); + + simulate.touchend(target); + map._renderTaskQueue.run(); + expect(zoomend).toHaveBeenCalled(); + }); + + test('TapDragZoomHandler does not fire zoom on tap and drag if touchstart events are > 500ms apart', (done) => { + const map = createMap(); + const target = map.getCanvasContainer(); + + const { zoomstart, zoom, zoomend } = setupEvents(map); + + const pointTouchOptions = { + touches: [{ target, clientX: 100, clientY: 100 }], + }; + + simulate.touchstart(target, pointTouchOptions); + simulate.touchend(target); + setTimeout(() => { + simulate.touchstart(target, pointTouchOptions); + simulate.touchmove(target, { + touches: [{ target, clientX: 100, clientY: 110 }], + }); + map._renderTaskQueue.run(); + + expect(zoomstart).not.toHaveBeenCalled(); + expect(zoom).not.toHaveBeenCalled(); + expect(zoomend).not.toHaveBeenCalled(); + done(); + }, 510); + }); + + test('TapDragZoomHandler does not zoom on double-tap and drag if touchstart events are in different locations (>30px apart)', () => { + const map = createMap(); + const target = map.getCanvasContainer(); + + const { zoomstart, zoom, zoomend } = setupEvents(map); + + simulate.touchstart(target, { + touches: [{ target, clientX: 100, clientY: 100 }], + }); + simulate.touchend(target); + simulate.touchstart(target, { + touches: [{ target, clientX: 140, clientY: 100 }], + }); + simulate.touchmove(target, { + touches: [{ target, clientX: 140, clientY: 110 }], + }); + map._renderTaskQueue.run(); + + expect(zoomstart).not.toHaveBeenCalled(); + expect(zoom).not.toHaveBeenCalled(); + expect(zoomend).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/map/__tests__/handler/two_fingers_touch.spec.ts b/packages/map/__tests__/handler/two_fingers_touch.spec.ts new file mode 100644 index 00000000000..d50413b9cbc --- /dev/null +++ b/packages/map/__tests__/handler/two_fingers_touch.spec.ts @@ -0,0 +1,230 @@ +import type { MapOptions } from '../../src/map/map'; +import { Map } from '../../src/map/map'; +import { DOM } from '../../src/map/util/dom'; +import simulate from '../libs/simulate_interaction'; +import { beforeMapTest } from '../libs/util'; + +function createMap() { + return new Map({ container: DOM.create('div', '', window.document.body) } as any as MapOptions); +} + +beforeEach(() => { + beforeMapTest(); +}); + +describe('touch zoom rotate', () => { + test('TwoFingersTouchZoomRotateHandler fires zoomstart, zoom, and zoomend events at appropriate times in response to a pinch-zoom gesture', () => { + const map = createMap(); + const target = map.getCanvasContainer(); + + const zoomstart = jest.fn(); + const zoom = jest.fn(); + const zoomend = jest.fn(); + + map.handlers._handlersById.tapZoom.disable(); + map.touchPitch.disable(); + map.on('zoomstart', zoomstart); + map.on('zoom', zoom); + map.on('zoomend', zoomend); + + simulate.touchstart(map.getCanvasContainer(), { + touches: [ + { target, identifier: 1, clientX: 0, clientY: -50 }, + { target, identifier: 2, clientX: 0, clientY: 50 }, + ], + }); + map._renderTaskQueue.run(); + expect(zoomstart).toHaveBeenCalledTimes(0); + expect(zoom).toHaveBeenCalledTimes(0); + expect(zoomend).toHaveBeenCalledTimes(0); + + simulate.touchmove(map.getCanvasContainer(), { + touches: [ + { target, identifier: 1, clientX: 0, clientY: -100 }, + { target, identifier: 2, clientX: 0, clientY: 100 }, + ], + }); + map._renderTaskQueue.run(); + expect(zoomstart).toHaveBeenCalledTimes(1); + expect(zoom).toHaveBeenCalledTimes(1); + expect(zoomend).toHaveBeenCalledTimes(0); + + simulate.touchmove(map.getCanvasContainer(), { + touches: [ + { target, identifier: 1, clientX: 0, clientY: -60 }, + { target, identifier: 2, clientX: 0, clientY: 60 }, + ], + }); + map._renderTaskQueue.run(); + expect(zoomstart).toHaveBeenCalledTimes(1); + expect(zoom).toHaveBeenCalledTimes(2); + expect(zoomend).toHaveBeenCalledTimes(0); + + simulate.touchend(map.getCanvasContainer(), { touches: [] }); + map._renderTaskQueue.run(); + + // incremented because inertia starts a second zoom + expect(zoomstart).toHaveBeenCalledTimes(2); + map._renderTaskQueue.run(); + expect(zoom).toHaveBeenCalledTimes(3); + expect(zoomend).toHaveBeenCalledTimes(1); + + map.remove(); + }); + + test('TwoFingersTouchZoomRotateHandler fires rotatestart, rotate, and rotateend events at appropriate times in response to a pinch-rotate gesture', () => { + const map = createMap(); + const target = map.getCanvasContainer(); + + const rotatestart = jest.fn(); + const rotate = jest.fn(); + const rotateend = jest.fn(); + + map.on('rotatestart', rotatestart); + map.on('rotate', rotate); + map.on('rotateend', rotateend); + + simulate.touchstart(map.getCanvasContainer(), { + touches: [ + { target, identifier: 0, clientX: 0, clientY: -50 }, + { target, identifier: 1, clientX: 0, clientY: 50 }, + ], + }); + map._renderTaskQueue.run(); + expect(rotatestart).toHaveBeenCalledTimes(0); + expect(rotate).toHaveBeenCalledTimes(0); + expect(rotateend).toHaveBeenCalledTimes(0); + + simulate.touchmove(map.getCanvasContainer(), { + touches: [ + { target, identifier: 0, clientX: -50, clientY: 0 }, + { target, identifier: 1, clientX: 50, clientY: 0 }, + ], + }); + map._renderTaskQueue.run(); + expect(rotatestart).toHaveBeenCalledTimes(1); + expect(rotate).toHaveBeenCalledTimes(1); + expect(rotateend).toHaveBeenCalledTimes(0); + + simulate.touchmove(map.getCanvasContainer(), { + touches: [ + { target, identifier: 0, clientX: 0, clientY: -50 }, + { target, identifier: 1, clientX: 0, clientY: 50 }, + ], + }); + map._renderTaskQueue.run(); + expect(rotatestart).toHaveBeenCalledTimes(1); + expect(rotate).toHaveBeenCalledTimes(2); + expect(rotateend).toHaveBeenCalledTimes(0); + + simulate.touchend(map.getCanvasContainer(), { touches: [] }); + map._renderTaskQueue.run(); + expect(rotatestart).toHaveBeenCalledTimes(1); + expect(rotate).toHaveBeenCalledTimes(2); + expect(rotateend).toHaveBeenCalledTimes(1); + + map.remove(); + }); + + test('TwoFingersTouchZoomRotateHandler does not begin a gesture if preventDefault is called on the touchstart event', () => { + const map = createMap(); + const target = map.getCanvasContainer(); + + map.on('touchstart', (e) => e.preventDefault()); + + const move = jest.fn(); + map.on('move', move); + + simulate.touchstart(map.getCanvasContainer(), { + touches: [ + { target, clientX: 0, clientY: 0 }, + { target, clientX: 5, clientY: 0 }, + ], + }); + map._renderTaskQueue.run(); + + simulate.touchmove(map.getCanvasContainer(), { + touches: [ + { target, clientX: 0, clientY: 0 }, + { target, clientX: 0, clientY: 5 }, + ], + }); + map._renderTaskQueue.run(); + + simulate.touchend(map.getCanvasContainer(), { touches: [] }); + map._renderTaskQueue.run(); + + expect(move).toHaveBeenCalledTimes(0); + + map.remove(); + }); + + test('TwoFingersTouchZoomRotateHandler starts zoom immediately when rotation disabled', () => { + const map = createMap(); + const target = map.getCanvasContainer(); + map.touchZoomRotate.disableRotation(); + map.handlers._handlersById.tapZoom.disable(); + + const zoomstart = jest.fn(); + const zoom = jest.fn(); + const zoomend = jest.fn(); + + map.on('zoomstart', zoomstart); + map.on('zoom', zoom); + map.on('zoomend', zoomend); + + simulate.touchstart(map.getCanvasContainer(), { + touches: [ + { target, identifier: 0, clientX: 0, clientY: -5 }, + { target, identifier: 2, clientX: 0, clientY: 5 }, + ], + }); + map._renderTaskQueue.run(); + expect(zoomstart).toHaveBeenCalledTimes(0); + expect(zoom).toHaveBeenCalledTimes(0); + expect(zoomend).toHaveBeenCalledTimes(0); + + simulate.touchmove(map.getCanvasContainer(), { + touches: [ + { target, identifier: 0, clientX: 0, clientY: -5 }, + { target, identifier: 2, clientX: 0, clientY: 6 }, + ], + }); + map._renderTaskQueue.run(); + expect(zoomstart).toHaveBeenCalledTimes(1); + expect(zoom).toHaveBeenCalledTimes(1); + expect(zoomend).toHaveBeenCalledTimes(0); + + simulate.touchmove(map.getCanvasContainer(), { + touches: [ + { target, identifier: 0, clientX: 0, clientY: -5 }, + { target, identifier: 2, clientX: 0, clientY: 4 }, + ], + }); + map._renderTaskQueue.run(); + expect(zoomstart).toHaveBeenCalledTimes(1); + expect(zoom).toHaveBeenCalledTimes(2); + expect(zoomend).toHaveBeenCalledTimes(0); + + simulate.touchend(map.getCanvasContainer(), { touches: [] }); + map._renderTaskQueue.run(); + // incremented because inertia starts a second zoom + expect(zoomstart).toHaveBeenCalledTimes(2); + map._renderTaskQueue.run(); + expect(zoom).toHaveBeenCalledTimes(3); + expect(zoomend).toHaveBeenCalledTimes(1); + + map.remove(); + }); + + test('TwoFingersTouchZoomRotateHandler adds css class used for disabling default touch behavior in some browsers', () => { + const map = createMap(); + + const className = 'l7-touch-zoom-rotate'; + expect(map.getCanvasContainer().classList.contains(className)).toBeTruthy(); + map.touchZoomRotate.disable(); + expect(map.getCanvasContainer().classList.contains(className)).toBeFalsy(); + map.touchZoomRotate.enable(); + expect(map.getCanvasContainer().classList.contains(className)).toBeTruthy(); + }); +}); diff --git a/packages/map/__tests__/hash.spec.ts b/packages/map/__tests__/hash.spec.ts deleted file mode 100644 index 8ccbf01cee0..00000000000 --- a/packages/map/__tests__/hash.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -import Hash from '../src/hash'; -import { Map } from '../src/map'; -describe('Map', () => { - const el = document.createElement('div'); - el.id = 'test-div-id'; - // el.style.width = '500px'; - // el.style.height = '500px'; - el.style.background = '#aaa'; - let map: Map; - let hash: Hash; - document.querySelector('body')?.appendChild(el); - beforeEach(() => { - map = new Map({ - container: el, - }); - hash = new Hash('map'); - hash.addTo(map); - }); - it('hash update', () => { - map.setZoom(10); - map.setBearing(10); - map.setPitch(10); - map.setCenter([0, 0]); - expect(window.location.hash).toEqual('#map=10/0/0'); - }); - - it('hash remove', () => { - hash.remove(); - // @ts-ignore - expect(hash.map).toEqual(undefined); - }); - it('hash onHashChange', () => { - window.location.hash = '#map=11/10/10'; - hash.onHashChange(); - expect(map.getZoom()).toEqual(11); - expect(map.getCenter()).toEqual({ lng: 10, lat: 10 }); - }); -}); diff --git a/packages/map/__tests__/libs/fixed.ts b/packages/map/__tests__/libs/fixed.ts new file mode 100644 index 00000000000..e2e384d469b --- /dev/null +++ b/packages/map/__tests__/libs/fixed.ts @@ -0,0 +1,25 @@ +export function fixedNum(n: number, precision = 10) { + const fixedNum = parseFloat(n.toFixed(precision)); + + // Support signed zero + if (fixedNum === 0) { + return 0; + } else { + return fixedNum; + } +} + +export function fixedLngLat(l: { lng: number; lat: number }, precision = 9) { + return { + lng: fixedNum(l.lng, precision), + lat: fixedNum(l.lat, precision), + }; +} + +export function fixedCoord(coord, precision = 10) { + return { + x: fixedNum(coord.x, precision), + y: fixedNum(coord.y, precision), + z: fixedNum(coord.z, precision), + }; +} diff --git a/packages/map/__tests__/libs/simulate_interaction.ts b/packages/map/__tests__/libs/simulate_interaction.ts new file mode 100644 index 00000000000..9cfeb29d651 --- /dev/null +++ b/packages/map/__tests__/libs/simulate_interaction.ts @@ -0,0 +1,108 @@ +function click(target: HTMLElement | Window | Element) { + const options = { bubbles: true }; + target.dispatchEvent(new MouseEvent('mousedown', options)); + target.dispatchEvent(new MouseEvent('mouseup', options)); + target.dispatchEvent(new MouseEvent('click', options)); +} + +function drag(target: HTMLElement | Window, mousedownOptions, mouseUpOptions) { + mousedownOptions = Object.assign({ bubbles: true }, mousedownOptions); + mouseUpOptions = Object.assign({ bubbles: true }, mouseUpOptions); + target.dispatchEvent(new MouseEvent('mousedown', mousedownOptions)); + target.dispatchEvent(new MouseEvent('mouseup', mouseUpOptions)); + target.dispatchEvent(new MouseEvent('click', mouseUpOptions)); +} + +function dragWithMove( + target: HTMLElement | Window, + start: { x: number; y: number }, + end: { x: number; y: number }, +) { + target.dispatchEvent( + new MouseEvent('mousedown', { bubbles: true, clientX: start.x, clientY: start.y }), + ); + document.dispatchEvent( + new MouseEvent('mousemove', { bubbles: true, buttons: 1, clientX: end.x, clientY: end.y }), + ); + target.dispatchEvent( + new MouseEvent('mouseup', { bubbles: true, clientX: end.x, clientY: end.y }), + ); +} + +function dblclick(target: HTMLElement | Window) { + const options = { bubbles: true }; + target.dispatchEvent(new MouseEvent('mousedown', options)); + target.dispatchEvent(new MouseEvent('mouseup', options)); + target.dispatchEvent(new MouseEvent('click', options)); + target.dispatchEvent(new MouseEvent('mousedown', options)); + target.dispatchEvent(new MouseEvent('mouseup', options)); + target.dispatchEvent(new MouseEvent('click', options)); + target.dispatchEvent(new MouseEvent('dblclick', options)); +} + +function keyFunctionFactory(event: string) { + return (target: HTMLElement | Window, options) => { + options = Object.assign({ bubbles: true }, options); + target.dispatchEvent(new KeyboardEvent(event, options)); + }; +} + +function mouseFunctionFactory(event: string) { + return (target: HTMLElement | Window, options?) => { + options = Object.assign({ bubbles: true }, options); + target.dispatchEvent(new MouseEvent(event, options)); + }; +} + +function wheelFunctionFactory(event: string) { + return (target: HTMLElement | Window, options) => { + options = Object.assign({ bubbles: true }, options); + target.dispatchEvent(new WheelEvent(event, options)); + }; +} + +function touchFunctionFactory(event: string) { + return (target: HTMLElement | Window, options?) => { + const defaultTouches = + event.endsWith('end') || event.endsWith('cancel') ? [] : [{ clientX: 0, clientY: 0 }]; + options = Object.assign({ bubbles: true, touches: defaultTouches }, options); + target.dispatchEvent(new TouchEvent(event, options)); + }; +} + +function focusBlueFunctionFactory(event: string) { + return (target: HTMLElement | Window) => { + const options = { bubbles: true }; + target.dispatchEvent(new FocusEvent(event, options)); + }; +} + +const events = { + click, + drag, + dragWithMove, + dblclick, + keydown: keyFunctionFactory('keydown'), + keyup: keyFunctionFactory('keyup'), + keypress: keyFunctionFactory('keypress'), + mouseup: mouseFunctionFactory('mouseup'), + mousedown: mouseFunctionFactory('mousedown'), + mouseover: mouseFunctionFactory('mouseover'), + mousemove: mouseFunctionFactory('mousemove'), + mouseout: mouseFunctionFactory('mouseout'), + contextmenu: mouseFunctionFactory('contextmenu'), + wheel: wheelFunctionFactory('wheel'), + mousewheel: wheelFunctionFactory('mousewheel'), + /** + * magic deltaY value that indicates the event is from a mouse wheel (rather than a trackpad) + */ + magicWheelZoomDelta: 4.000244140625, + touchstart: touchFunctionFactory('touchstart'), + touchend: touchFunctionFactory('touchend'), + touchmove: touchFunctionFactory('touchmove'), + touchcancel: touchFunctionFactory('touchcancel'), + focus: focusBlueFunctionFactory('focus'), + blur: focusBlueFunctionFactory('blur'), +}; + +export default events; diff --git a/packages/map/__tests__/libs/util.ts b/packages/map/__tests__/libs/util.ts new file mode 100644 index 00000000000..acdc2579ec3 --- /dev/null +++ b/packages/map/__tests__/libs/util.ts @@ -0,0 +1,69 @@ +import { Map } from '../../src/map/map'; +import { extend } from '../../src/map/util/util'; + +export function createMap(options?, callback?) { + const container = window.document.createElement('div'); + const defaultOptions = { + container, + interactive: false, + attributionControl: false, + trackResize: true, + }; + + Object.defineProperty(container, 'clientWidth', { value: 200, configurable: true }); + Object.defineProperty(container, 'clientHeight', { value: 200, configurable: true }); + + const map = new Map(extend(defaultOptions, options)); + if (callback) + map.on('load', () => { + callback(null, map); + }); + + return map; +} + +export function setPerformance() { + window.performance.mark = jest.fn(); + window.performance.clearMeasures = jest.fn(); + window.performance.clearMarks = jest.fn(); +} + +export function setMatchMedia() { + // https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // deprecated + removeListener: jest.fn(), // deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), + }); +} + +function setResizeObserver() { + global.ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), + })); +} + +export function beforeMapTest() { + setPerformance(); + setMatchMedia(); + setResizeObserver(); +} + +/** + * This allows test to wait for a certain amount of time before continuing. + * @param milliseconds - the amount of time to wait in milliseconds + * @returns - a promise that resolves after the specified amount of time + */ +export const sleep = (milliseconds: number = 0) => { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +}; diff --git a/packages/map/__tests__/map.init.spec.ts b/packages/map/__tests__/map.init.spec.ts deleted file mode 100644 index 0309e3ae752..00000000000 --- a/packages/map/__tests__/map.init.spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Map } from '../src/map'; -describe('Map', () => { - const el = document.createElement('div'); - el.id = 'test-div-id'; - // el.style.width = '500px'; - // el.style.height = '500px'; - el.style.background = '#aaa'; - let map: Map; - document.querySelector('body')?.appendChild(el); - beforeEach(() => { - map = new Map({ - container: el, - }); - }); - it('should resize correctly', () => { - // 创建Map的实例,将mock的Map传 - - // map.resize(); - map.setCenter([120.11114550000002, 30.27817071635984]); - map.setZoom(8.592359444611867); - - // 验证transform.resize方法是否被正确调用 - }); -}); diff --git a/packages/map/__tests__/map.spec.ts b/packages/map/__tests__/map.spec.ts deleted file mode 100644 index 0ae74eb8f3e..00000000000 --- a/packages/map/__tests__/map.spec.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { Map } from '../src/map'; -describe('Map', () => { - const el = document.createElement('div'); - el.id = 'test-div-id'; - // el.style.width = '500px'; - // el.style.height = '500px'; - el.style.background = '#aaa'; - let map: Map; - document.querySelector('body')?.appendChild(el); - beforeEach(() => { - map = new Map({ - container: el, - }); - }); - it('should resize correctly', () => { - // 创建Map的实例,将mock的Map传 - - // map.resize(); - map.setCenter([121.434765, 31.256735]); - map.setZoom(14.83); - - // 验证transform.resize方法是否被正确调用 - }); - it('Map set zoom', () => { - map.setZoom(5); - expect(map.getZoom()).toEqual(5); - }); - it('Map set center', () => { - map.setCenter([120, 30]); - expect(map.getCenter()).toEqual({ lat: 30, lng: 120 }); - }); - it('Map set pitch', () => { - map.setPitch(10); - expect(map.getPitch()).toEqual(10); - }); - it('Map set Bearing', () => { - map.setBearing(10); - expect(map.getBearing()).toEqual(10); - }); - it('Map panTo ', () => { - map.panTo([121, 31], { animate: false }); - expect(map.getCenter()).toEqual({ lat: 31, lng: 121 }); - }); - it('Map panBy', () => { - map.panBy([10, 10], { animate: false }); - expect(map.getCenter().lng).toBeCloseTo(7.03, 2); - }); - it('Map zoomTo', () => { - map.zoomTo(10, { animate: false }); - expect(map.getZoom()).toEqual(10); - }); - it('Map zoomIn', () => { - map.setZoom(9); - map.zoomIn({ animate: false }); - expect(map.getZoom()).toEqual(10); - }); - it('Map zoomOut', () => { - map.setZoom(10); - map.zoomOut({ animate: false }); - expect(map.getZoom()).toEqual(9); - }); - it('Map setMaxZoom', () => { - map.setMaxZoom(15); - expect(map.getMaxZoom()).toEqual(15); - }); - it('Map setMinZoom', () => { - map.setMinZoom(5); - expect(map.getMinZoom()).toEqual(5); - }); - it('Map setMaxPitch', () => { - map.setMaxPitch(60); - expect(map.getMaxPitch()).toEqual(60); - }); - it('Map setMinPitch', () => { - map.setMinPitch(5); - expect(map.getMinPitch()).toEqual(5); - }); - // setPadding - it('Map setPadding', () => { - map.setPadding({ - top: 10, - bottom: 10, - left: 10, - right: 10, - }); - expect(map.getPadding()).toEqual({ bottom: 10, left: 10, right: 10, top: 10 }); - }); - // rotateTo - it('Map rotateTo', () => { - map.rotateTo(90); - expect(map.getBearing()).toEqual(-0); - }); - // resetNorth - it('Map resetNorth', () => { - map.resetNorth(); - expect(map.getBearing()).toEqual(-0); - }); - // resetNorthPitch - it('Map resetNorthPitch', () => { - map.resetNorthPitch(); - expect(map.getPitch()).toEqual(0); - }); - // fitBounds - it('Map fitBounds', () => { - map.fitBounds( - [ - [120, 30], - [121, 31], - ], - { animate: false }, - ); - expect(map.getZoom()).toBeCloseTo(7.5, 1); - }); - // snapToNorth - it('Map snapToNorth', () => { - map.snapToNorth(); - expect(map.getBearing()).toEqual(-0); - }); - //jumpTo - it('Map jumpTo', () => { - map.jumpTo( - { - center: [120, 30], - zoom: 10, - bearing: 90, - pitch: 60, - }, - { - animate: false, - }, - ); - expect(map.getCenter()).toEqual({ lat: 30, lng: 120 }); - expect(map.getZoom()).toEqual(10); - expect(map.getBearing()).toEqual(90); - expect(map.getPitch()).toEqual(60); - }); - - // map getContainer - it('Map getContainer', () => { - expect(map.getContainer()).toEqual(el); - }); - // getcanvas - it('Map getCanvas', () => { - expect(map.getCanvas()).toBeUndefined(); - }); - // project - it('Map project', () => { - expect(map.project([120, 30]).x).toBeCloseTo(370.6, 0); - expect(map.project([120, 30]).y).toBeCloseTo(105.2, 1); - }); - // unproject - it('Map unproject', () => { - expect(map.unproject([100, 100]).lng).toBeCloseTo(-70.3, 1); - expect(map.unproject([100, 100]).lat).toBeCloseTo(33.1, 1); - }); - // getbounds - it('Map getBounds', () => { - expect(map.getBounds().toArray()).toEqual([ - [-140.6250000000022, -71.96538769913126], - [140.62499999999886, 71.96538769913161], - ]); - }); - // map remove - it('Map remove', () => { - map.remove(); - expect(map.getCanvasContainer()).toEqual(null); - }); - // getMaxBounds - it('Map getMaxBounds', () => { - expect(map.getMaxBounds()).toEqual(null); - }); - // // setMaxBounds - it('Map setMaxBounds', () => { - map.setMaxBounds([ - [120, 30], - [121, 31], - ]); - expect(map.getMaxBounds()?.toArray()).toEqual([ - [120, 30], - [121, 31], - ]); - }); -}); diff --git a/packages/map/__tests__/map/map_animation.spec.ts b/packages/map/__tests__/map/map_animation.spec.ts new file mode 100644 index 00000000000..7e05e46c825 --- /dev/null +++ b/packages/map/__tests__/map/map_animation.spec.ts @@ -0,0 +1,62 @@ +import simulate from '../libs/simulate_interaction'; +import { beforeMapTest, createMap } from '../libs/util'; + +beforeEach(() => { + beforeMapTest(); + global.fetch = null; +}); + +test('stops camera animation on touchstart when interactive', () => { + const map = createMap({ interactive: true }); + map.flyTo({ center: [200, 0], duration: 100 }); + + simulate.touchstart(map.getCanvasContainer(), { + touches: [{ target: map.getCanvasContainer(), clientX: 0, clientY: 0 }], + }); + expect(map.isEasing()).toBe(false); + + map.remove(); +}); + +test('continues camera animation on touchstart when non-interactive', () => { + const map = createMap({ interactive: false }); + map.flyTo({ center: [200, 0], duration: 100 }); + + simulate.touchstart(map.getCanvasContainer()); + expect(map.isEasing()).toBe(true); + + map.remove(); +}); + +test('continues camera animation on resize', () => { + const map = createMap(), + container = map.getContainer(); + + map.flyTo({ center: [200, 0], duration: 100 }); + + Object.defineProperty(container, 'clientWidth', { value: 250 }); + Object.defineProperty(container, 'clientHeight', { value: 250 }); + map.resize(); + + expect(map.isMoving()).toBeTruthy(); +}); + +test('stops camera animation on mousedown when interactive', () => { + const map = createMap({ interactive: true }); + map.flyTo({ center: [200, 0], duration: 100 }); + + simulate.mousedown(map.getCanvasContainer()); + expect(map.isEasing()).toBe(false); + + map.remove(); +}); + +test('continues camera animation on mousedown when non-interactive', () => { + const map = createMap({ interactive: false }); + map.flyTo({ center: [200, 0], duration: 100 }); + + simulate.mousedown(map.getCanvasContainer()); + expect(map.isEasing()).toBe(true); + + map.remove(); +}); diff --git a/packages/map/__tests__/map/map_basic.spec.ts b/packages/map/__tests__/map/map_basic.spec.ts new file mode 100644 index 00000000000..166cbc5ec74 --- /dev/null +++ b/packages/map/__tests__/map/map_basic.spec.ts @@ -0,0 +1,77 @@ +import type { MapOptions } from '../../src/map/map'; +import { Map } from '../../src/map/map'; +import { fixedLngLat } from '../libs/fixed'; +import { beforeMapTest, createMap } from '../libs/util'; + +beforeEach(() => { + beforeMapTest(); + global.fetch = null; +}); + +describe('Map', () => { + test('constructor', () => { + const map = createMap({ interactive: true, style: null }); + expect(map.getContainer()).toBeTruthy(); + expect(map.boxZoom.isEnabled()).toBeTruthy(); + expect(map.doubleClickZoom.isEnabled()).toBeTruthy(); + expect(map.dragPan.isEnabled()).toBeTruthy(); + expect(map.dragRotate.isEnabled()).toBeTruthy(); + expect(map.keyboard.isEnabled()).toBeTruthy(); + expect(map.scrollZoom.isEnabled()).toBeTruthy(); + expect(map.touchZoomRotate.isEnabled()).toBeTruthy(); + expect(() => { + new Map({ + container: 'anElementIdWhichDoesNotExistInTheDocument', + } as any as MapOptions); + }).toThrow(new Error("Container 'anElementIdWhichDoesNotExistInTheDocument' not found.")); + }); + + test('bad map-specific token breaks map', () => { + const container = window.document.createElement('div'); + Object.defineProperty(container, 'offsetWidth', { value: 512 }); + Object.defineProperty(container, 'offsetHeight', { value: 512 }); + createMap(); + //t.error(); + }); + + test('#remove', () => { + const map = createMap(); + + expect(map.getContainer().childNodes).toHaveLength(1); + map.remove(); + expect(map.getContainer().childNodes).toHaveLength(0); + }); + + test('#project', () => { + const map = createMap(); + expect(map.project([0, 0])).toEqual({ x: 100, y: 100 }); + }); + + test('#unproject', () => { + const map = createMap(); + expect(fixedLngLat(map.unproject([100, 100]))).toEqual({ lng: 0, lat: 0 }); + }); + + describe('cooperativeGestures option', () => { + test('cooperativeGesture container element is hidden from a11y tree', () => { + const map = createMap({ cooperativeGestures: true }); + expect( + map + .getContainer() + .querySelector('.l7-cooperative-gesture-screen') + .getAttribute('aria-hidden'), + ).toBeTruthy(); + }); + + test('cooperativeGesture container element is not available when cooperativeGestures not initialized', () => { + const map = createMap({ cooperativeGestures: false }); + expect(map.getContainer().querySelector('.l7-cooperative-gesture-screen')).toBeFalsy(); + }); + + test('cooperativeGesture container element is not available when cooperativeGestures disabled', () => { + const map = createMap({ cooperativeGestures: true }); + map.cooperativeGestures.disable(); + expect(map.getContainer().querySelector('.l7-cooperative-gesture-screen')).toBeFalsy(); + }); + }); +}); diff --git a/packages/map/__tests__/map/map_bounds.spec.ts b/packages/map/__tests__/map/map_bounds.spec.ts new file mode 100644 index 00000000000..e525f04daab --- /dev/null +++ b/packages/map/__tests__/map/map_bounds.spec.ts @@ -0,0 +1,152 @@ +import type { LngLatBoundsLike } from '../../src/map/geo/lng_lat_bounds'; +import { fixedLngLat, fixedNum } from '../libs/fixed'; +import { beforeMapTest, createMap } from '../libs/util'; + +beforeEach(() => { + beforeMapTest(); + global.fetch = null; +}); + +test('initial bounds in constructor options', () => { + const container = window.document.createElement('div'); + Object.defineProperty(container, 'offsetWidth', { value: 512 }); + Object.defineProperty(container, 'offsetHeight', { value: 512 }); + + const bounds = [ + [-133, 16], + [-68, 50], + ]; + const map = createMap({ container, bounds }); + + expect(fixedLngLat(map.getCenter(), 4)).toEqual({ lng: -100.5, lat: 34.7171 }); + expect(fixedNum(map.getZoom(), 3)).toBe(2.113); +}); + +test('initial bounds options in constructor options', () => { + const bounds = [ + [-133, 16], + [-68, 50], + ]; + + const map = (fitBoundsOptions) => { + const container = window.document.createElement('div'); + Object.defineProperty(container, 'offsetWidth', { value: 512 }); + Object.defineProperty(container, 'offsetHeight', { value: 512 }); + return createMap({ container, bounds, fitBoundsOptions }); + }; + + const unpadded = map(undefined); + const padded = map({ padding: 100 }); + + expect(unpadded.getZoom() > padded.getZoom()).toBeTruthy(); +}); + +describe('#getBounds', () => { + test('getBounds', () => { + const map = createMap({ zoom: 0 }); + expect(parseFloat(map.getBounds().getCenter().lng.toFixed(10))).toBe(-0); + expect(parseFloat(map.getBounds().getCenter().lat.toFixed(10))).toBe(0); + + expect(toFixed(map.getBounds().toArray())).toEqual( + toFixed([ + [-70.31249999999976, -57.326521225216965], + [70.31249999999977, 57.32652122521695], + ]), + ); + }); + + test('rotated bounds', () => { + const map = createMap({ zoom: 1, bearing: 45 }); + expect( + toFixed([ + [-49.718445552178764, -44.44541580601936], + [49.7184455522, 44.445415806019355], + ]), + ).toEqual(toFixed(map.getBounds().toArray())); + + map.setBearing(135); + expect( + toFixed([ + [-49.718445552178764, -44.44541580601936], + [49.7184455522, 44.445415806019355], + ]), + ).toEqual(toFixed(map.getBounds().toArray())); + }); + + function toFixed(bounds) { + const n = 10; + return [ + [normalizeFixed(bounds[0][0], n), normalizeFixed(bounds[0][1], n)], + [normalizeFixed(bounds[1][0], n), normalizeFixed(bounds[1][1], n)], + ]; + } + + function normalizeFixed(num, n) { + // workaround for "-0.0000000000" ≠ "0.0000000000" + return parseFloat(num.toFixed(n)).toFixed(n); + } +}); + +describe('#setMaxBounds', () => { + test('constrains map bounds', () => { + const map = createMap({ zoom: 0 }); + map.setMaxBounds([ + [-130.4297, 50.0642], + [-61.52344, 24.20688], + ]); + expect( + toFixed([ + [-130.4297, 7.0136641176], + [-61.52344, 60.2398142283], + ]), + ).toEqual(toFixed(map.getBounds().toArray())); + }); + + test('when no argument is passed, map bounds constraints are removed', () => { + const map = createMap({ zoom: 0 }); + map.setMaxBounds([ + [-130.4297, 50.0642], + [-61.52344, 24.20688], + ]); + expect( + toFixed([ + [-166.28906999999964, -27.6835270554], + [-25.664070000000066, 73.8248206697], + ]), + ).toEqual(toFixed(map.setMaxBounds(null).setZoom(0).getBounds().toArray())); + }); + + test('should not zoom out farther than bounds', () => { + const map = createMap(); + map.setMaxBounds([ + [-130.4297, 50.0642], + [-61.52344, 24.20688], + ]); + expect(map.setZoom(0).getZoom()).not.toBe(0); + }); + + function toFixed(bounds) { + const n = 9; + return [ + [bounds[0][0].toFixed(n), bounds[0][1].toFixed(n)], + [bounds[1][0].toFixed(n), bounds[1][1].toFixed(n)], + ]; + } +}); + +describe('#getMaxBounds', () => { + test('returns null when no bounds set', () => { + const map = createMap({ zoom: 0 }); + expect(map.getMaxBounds()).toBeNull(); + }); + + test('returns bounds', () => { + const map = createMap({ zoom: 0 }); + const bounds = [ + [-130.4297, 50.0642], + [-61.52344, 24.20688], + ] as LngLatBoundsLike; + map.setMaxBounds(bounds); + expect(map.getMaxBounds().toArray()).toEqual(bounds); + }); +}); diff --git a/packages/map/__tests__/map/map_disable_handlers.spec.ts b/packages/map/__tests__/map/map_disable_handlers.spec.ts new file mode 100644 index 00000000000..607c0e178d3 --- /dev/null +++ b/packages/map/__tests__/map/map_disable_handlers.spec.ts @@ -0,0 +1,37 @@ +import { beforeMapTest, createMap } from '../libs/util'; + +beforeEach(() => { + beforeMapTest(); + global.fetch = null; +}); + +test('disable all handlers', () => { + const map = createMap({ interactive: false }); + + expect(map.boxZoom.isEnabled()).toBeFalsy(); + expect(map.doubleClickZoom.isEnabled()).toBeFalsy(); + expect(map.dragPan.isEnabled()).toBeFalsy(); + expect(map.dragRotate.isEnabled()).toBeFalsy(); + expect(map.keyboard.isEnabled()).toBeFalsy(); + expect(map.scrollZoom.isEnabled()).toBeFalsy(); + expect(map.touchZoomRotate.isEnabled()).toBeFalsy(); +}); + +const handlerNames = [ + 'scrollZoom', + 'boxZoom', + 'dragRotate', + 'dragPan', + 'keyboard', + 'doubleClickZoom', + 'touchZoomRotate', +]; +handlerNames.forEach((handlerName) => { + test(`disable "${handlerName}" handler`, () => { + const options = {}; + options[handlerName] = false; + const map = createMap(options); + + expect(map[handlerName].isEnabled()).toBeFalsy(); + }); +}); diff --git a/packages/map/__tests__/map/map_events.spec.ts b/packages/map/__tests__/map/map_events.spec.ts new file mode 100644 index 00000000000..310a17b21ca --- /dev/null +++ b/packages/map/__tests__/map/map_events.spec.ts @@ -0,0 +1,254 @@ +import { ErrorEvent } from '../../src/map/util/evented'; +import simulate from '../libs/simulate_interaction'; +import { beforeMapTest, createMap } from '../libs/util'; + +function assertNotAny() {} + +beforeEach(() => { + beforeMapTest(); +}); + +describe('map events', () => { + test('Map#on adds a non-delegated event listener', () => { + const map = createMap(); + const spy = jest.fn(function (e) { + expect(this).toBe(map); + expect(e.type).toBe('click'); + }); + + map.on('click', spy); + simulate.click(map.getCanvasContainer()); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + test('Map#off removes a non-delegated event listener', () => { + const map = createMap(); + const spy = jest.fn(); + + map.on('click', spy); + map.off('click', spy); + simulate.click(map.getCanvasContainer()); + + expect(spy).not.toHaveBeenCalled(); + }); + + test("Map#on calls an event listener with no type arguments, defaulting to 'unknown' originalEvent type", () => { + const map = createMap(); + + const handler = { + onMove: function onMove() {}, + }; + + jest.spyOn(handler, 'onMove'); + + map.on('move', () => handler.onMove()); + map.jumpTo({ center: { lng: 10, lat: 10 } }); + + expect(handler.onMove).toHaveBeenCalledTimes(1); + }); + + test('Map#on allows a listener to infer the event type ', () => { + const map = createMap(); + + const spy = jest.fn(); + map.on('mousemove', (event) => { + assertNotAny(); + const { lng, lat } = event.lngLat; + spy({ lng, lat }); + }); + + simulate.mousemove(map.getCanvasContainer()); + expect(spy).toHaveBeenCalledTimes(1); + }); + + test("Map#off calls an event listener with no type arguments, defaulting to 'unknown' originalEvent type", () => { + const map = createMap(); + + const handler = { + onMove: function onMove() {}, + }; + + jest.spyOn(handler, 'onMove'); + + map.off('move', () => handler.onMove()); + map.jumpTo({ center: { lng: 10, lat: 10 } }); + + expect(handler.onMove).toHaveBeenCalledTimes(0); + }); + + test('Map#off allows a listener to infer the event type ', () => { + const map = createMap(); + + const spy = jest.fn(); + map.off('mousemove', (event) => { + assertNotAny(); + const { lng, lat } = event.lngLat; + spy({ lng, lat }); + }); + + simulate.mousemove(map.getCanvasContainer()); + expect(spy).toHaveBeenCalledTimes(0); + }); + + test("Map#once calls an event listener with no type arguments, defaulting to 'unknown' originalEvent type", () => { + const map = createMap(); + + const handler = { + onMoveOnce: function onMoveOnce() {}, + }; + + jest.spyOn(handler, 'onMoveOnce'); + + map.once('move', () => handler.onMoveOnce()); + map.jumpTo({ center: { lng: 10, lat: 10 } }); + + expect(handler.onMoveOnce).toHaveBeenCalledTimes(1); + }); + + test('Map#once allows a listener to infer the event type ', () => { + const map = createMap(); + + const spy = jest.fn(); + map.once('mousemove', (event) => { + assertNotAny(); + const { lng, lat } = event.lngLat; + spy({ lng, lat }); + }); + + simulate.mousemove(map.getCanvasContainer()); + expect(spy).toHaveBeenCalledTimes(1); + }); + + test('Map#on mousedown can have default behavior prevented and still fire subsequent click event', () => { + const map = createMap(); + + map.on('mousedown', (e) => e.preventDefault()); + + const click = jest.fn(); + map.on('click', click); + + simulate.click(map.getCanvasContainer()); + expect(click).toHaveBeenCalled(); + + map.remove(); + }); + + test("Map#on mousedown doesn't fire subsequent click event if mousepos changes", () => { + const map = createMap(); + + map.on('mousedown', (e) => e.preventDefault()); + + const click = jest.fn(); + map.on('click', click); + const canvas = map.getCanvasContainer(); + + simulate.drag(canvas, {}, { clientX: 100, clientY: 100 }); + expect(click).not.toHaveBeenCalled(); + + map.remove(); + }); + + test('Map#on mousedown fires subsequent click event if mouse position changes less than click tolerance', () => { + const map = createMap({ clickTolerance: 4 }); + + map.on('mousedown', (e) => e.preventDefault()); + + const click = jest.fn(); + map.on('click', click); + const canvas = map.getCanvasContainer(); + + simulate.drag(canvas, { clientX: 100, clientY: 100 }, { clientX: 100, clientY: 103 }); + expect(click).toHaveBeenCalled(); + + map.remove(); + }); + + test('Map#on mousedown does not fire subsequent click event if mouse position changes more than click tolerance', () => { + const map = createMap({ clickTolerance: 4 }); + + map.on('mousedown', (e) => e.preventDefault()); + + const click = jest.fn(); + map.on('click', click); + const canvas = map.getCanvasContainer(); + + simulate.drag(canvas, { clientX: 100, clientY: 100 }, { clientX: 100, clientY: 104 }); + expect(click).not.toHaveBeenCalled(); + + map.remove(); + }); + + test('Map#on click fires subsequent click event if there is no corresponding mousedown/mouseup event', () => { + const map = createMap({ clickTolerance: 4 }); + + const click = jest.fn(); + map.on('click', click); + const canvas = map.getCanvasContainer(); + + const event = new MouseEvent('click', { bubbles: true, clientX: 100, clientY: 100 }); + canvas.dispatchEvent(event); + expect(click).toHaveBeenCalled(); + + map.remove(); + }); + + test('Map#isMoving() returns false in mousedown/mouseup/click with no movement', () => { + const map = createMap({ interactive: true, clickTolerance: 4 }); + let mousedown, mouseup, click; + map.on('mousedown', () => { + mousedown = map.isMoving(); + }); + map.on('mouseup', () => { + mouseup = map.isMoving(); + }); + map.on('click', () => { + click = map.isMoving(); + }); + + const canvas = map.getCanvasContainer(); + + canvas.dispatchEvent( + new MouseEvent('mousedown', { bubbles: true, clientX: 100, clientY: 100 }), + ); + expect(mousedown).toBe(false); + map._renderTaskQueue.run(); + expect(mousedown).toBe(false); + + canvas.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: 100, clientY: 100 })); + expect(mouseup).toBe(false); + map._renderTaskQueue.run(); + expect(mouseup).toBe(false); + + canvas.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: 100, clientY: 100 })); + expect(click).toBe(false); + map._renderTaskQueue.run(); + expect(click).toBe(false); + + map.remove(); + }); + + describe('error event', () => { + test('logs errors to console when it has NO listeners', () => { + // to avoid seeing error in the console in Jest + let stub = jest.spyOn(console, 'error').mockImplementation(() => {}); + const map = createMap(); + stub.mockReset(); + stub = jest.spyOn(console, 'error').mockImplementation(() => {}); + const error = new Error('test'); + map.fire(new ErrorEvent(error)); + expect(stub).toHaveBeenCalledTimes(1); + expect(stub.mock.calls[0][0]).toBe(error); + }); + + test('calls listeners', (done) => { + const map = createMap(); + const error = new Error('test'); + map.on('error', (event) => { + expect(event.error).toBe(error); + done(); + }); + map.fire(new ErrorEvent(error)); + }); + }); +}); diff --git a/packages/map/__tests__/map/map_is_moving.spec.ts b/packages/map/__tests__/map/map_is_moving.spec.ts new file mode 100644 index 00000000000..9018fa9ccb5 --- /dev/null +++ b/packages/map/__tests__/map/map_is_moving.spec.ts @@ -0,0 +1,177 @@ +import { Map } from '../../src/map/map'; +import { browser } from '../../src/map/util/browser'; +import { DOM } from '../../src/map/util/dom'; +import simulate from '../libs/simulate_interaction'; + +import { beforeMapTest } from '../libs/util'; + +let map; + +function createMap() { + return new Map({ container: DOM.create('div', '', window.document.body) }); +} + +beforeEach(() => { + beforeMapTest(); + map = createMap(); +}); + +afterEach(() => { + map.remove(); +}); + +describe('Map#isMoving', () => { + // MouseEvent.buttons + const buttons = 1; + + test('returns false by default', () => { + expect(map.isMoving()).toBe(false); + }); + + test('returns true during a camera zoom animation', (done) => { + map.on('zoomstart', () => { + expect(map.isMoving()).toBe(true); + }); + + map.on('zoomend', () => { + expect(map.isMoving()).toBe(false); + done(); + }); + + map.zoomTo(5, { duration: 0 }); + }); + + test('returns true when drag panning', (done) => { + map.on('movestart', () => { + expect(map.isMoving()).toBe(true); + }); + map.on('dragstart', () => { + expect(map.isMoving()).toBe(true); + }); + + map.on('dragend', () => { + expect(map.isMoving()).toBe(false); + }); + map.on('moveend', () => { + expect(map.isMoving()).toBe(false); + done(); + }); + + simulate.mousedown(map.getCanvasContainer()); + map._renderTaskQueue.run(); + + simulate.mousemove(map.getCanvasContainer(), { buttons, clientX: 10, clientY: 10 }); + map._renderTaskQueue.run(); + + simulate.mouseup(map.getCanvasContainer()); + map._renderTaskQueue.run(); + }); + + test('returns true when drag rotating', (done) => { + // Prevent inertial rotation. + jest.spyOn(browser, 'now').mockImplementation(() => { + return 0; + }); + + map.on('movestart', () => { + expect(map.isMoving()).toBe(true); + }); + + map.on('rotatestart', () => { + expect(map.isMoving()).toBe(true); + }); + + map.on('rotateend', () => { + expect(map.isMoving()).toBe(false); + }); + + map.on('moveend', () => { + expect(map.isMoving()).toBe(false); + done(); + }); + + simulate.mousedown(map.getCanvasContainer(), { buttons: 2, button: 2 }); + map._renderTaskQueue.run(); + + simulate.mousemove(map.getCanvasContainer(), { buttons: 2, clientX: 10, clientY: 10 }); + map._renderTaskQueue.run(); + + simulate.mouseup(map.getCanvasContainer(), { buttons: 0, button: 2 }); + map._renderTaskQueue.run(); + }); + + test('returns true when scroll zooming', (done) => { + map.on('zoomstart', () => { + expect(map.isMoving()).toBe(true); + }); + + map.on('zoomend', () => { + expect(map.isMoving()).toBe(false); + done(); + }); + + let now = 0; + jest.spyOn(browser, 'now').mockImplementation(() => { + return now; + }); + + simulate.wheel(map.getCanvasContainer(), { + type: 'wheel', + deltaY: -simulate.magicWheelZoomDelta, + }); + map._renderTaskQueue.run(); + + now += 400; + setTimeout(() => { + map._renderTaskQueue.run(); + }, 400); + }); + + test('returns true when drag panning and scroll zooming interleave', (done) => { + map.on('dragstart', () => { + expect(map.isMoving()).toBe(true); + }); + + map.on('zoomstart', () => { + expect(map.isMoving()).toBe(true); + }); + + map.on('zoomend', () => { + expect(map.isMoving()).toBe(true); + simulate.mouseup(map.getCanvasContainer()); + setTimeout(() => { + map._renderTaskQueue.run(); + done(); + }); + }); + + map.on('dragend', () => { + expect(map.isMoving()).toBe(false); + }); + + // The following should trigger the above events, where a zoomstart/zoomend + // pair is nested within a dragstart/dragend pair. + + simulate.mousedown(map.getCanvasContainer()); + map._renderTaskQueue.run(); + + simulate.mousemove(map.getCanvasContainer(), { buttons, clientX: 10, clientY: 10 }); + map._renderTaskQueue.run(); + + let now = 0; + jest.spyOn(browser, 'now').mockImplementation(() => { + return now; + }); + + simulate.wheel(map.getCanvasContainer(), { + type: 'wheel', + deltaY: -simulate.magicWheelZoomDelta, + }); + map._renderTaskQueue.run(); + + now += 400; + setTimeout(() => { + map._renderTaskQueue.run(); + }, 400); + }); +}); diff --git a/packages/map/__tests__/map/map_is_rotating.spec.ts b/packages/map/__tests__/map/map_is_rotating.spec.ts new file mode 100644 index 00000000000..e9f95a9a1f0 --- /dev/null +++ b/packages/map/__tests__/map/map_is_rotating.spec.ts @@ -0,0 +1,64 @@ +import { Map } from '../../src/map/map'; +import { browser } from '../../src/map/util/browser'; +import { DOM } from '../../src/map/util/dom'; +import simulate from '../libs/simulate_interaction'; +import { beforeMapTest } from '../libs/util'; + +let map; + +function createMap() { + return new Map({ container: DOM.create('div', '', window.document.body) }); +} + +beforeEach(() => { + beforeMapTest(); + map = createMap(); +}); + +afterEach(() => { + map.remove(); +}); + +describe('Map#isRotating', () => { + test('returns false by default', () => { + expect(map.isRotating()).toBe(false); + }); + + test('returns true during a camera rotate animation', (done) => { + map.on('rotatestart', () => { + expect(map.isRotating()).toBe(true); + }); + + map.on('rotateend', () => { + expect(map.isRotating()).toBe(false); + done(); + }); + + map.rotateTo(5, { duration: 0 }); + }); + + test('returns true when drag rotating', (done) => { + // Prevent inertial rotation. + jest.spyOn(browser, 'now').mockImplementation(() => { + return 0; + }); + + map.on('rotatestart', () => { + expect(map.isRotating()).toBe(true); + }); + + map.on('rotateend', () => { + expect(map.isRotating()).toBe(false); + done(); + }); + + simulate.mousedown(map.getCanvasContainer(), { buttons: 2, button: 2 }); + map._renderTaskQueue.run(); + + simulate.mousemove(map.getCanvasContainer(), { buttons: 2, clientX: 10, clientY: 10 }); + map._renderTaskQueue.run(); + + simulate.mouseup(map.getCanvasContainer(), { buttons: 0, button: 2 }); + map._renderTaskQueue.run(); + }); +}); diff --git a/packages/map/__tests__/map/map_is_zooming.spec.ts b/packages/map/__tests__/map/map_is_zooming.spec.ts new file mode 100644 index 00000000000..ca03857a904 --- /dev/null +++ b/packages/map/__tests__/map/map_is_zooming.spec.ts @@ -0,0 +1,93 @@ +import { Map } from '../../src/map/map'; +import { browser } from '../../src/map/util/browser'; +import { DOM } from '../../src/map/util/dom'; +import simulate from '../libs/simulate_interaction'; +import { beforeMapTest } from '../libs/util'; + +function createMap() { + return new Map({ container: DOM.create('div', '', window.document.body) }); +} + +beforeEach(() => { + beforeMapTest(); +}); + +describe('Map#isZooming', () => { + test('returns false by default', (done) => { + const map = createMap(); + expect(map.isZooming()).toBe(false); + map.remove(); + done(); + }); + + test('returns true during a camera zoom animation', (done) => { + const map = createMap(); + + map.on('zoomstart', () => { + expect(map.isZooming()).toBe(true); + }); + + map.on('zoomend', () => { + expect(map.isZooming()).toBe(false); + map.remove(); + done(); + }); + + map.zoomTo(5, { duration: 0 }); + }); + + test('returns true when scroll zooming', (done) => { + const map = createMap(); + + map.on('zoomstart', () => { + expect(map.isZooming()).toBe(true); + }); + + map.on('zoomend', () => { + expect(map.isZooming()).toBe(false); + map.remove(); + done(); + }); + + let now = 0; + jest.spyOn(browser, 'now').mockImplementation(() => { + return now; + }); + + simulate.wheel(map.getCanvasContainer(), { + type: 'wheel', + deltaY: -simulate.magicWheelZoomDelta, + }); + map._renderTaskQueue.run(); + + now += 400; + setTimeout(() => { + map._renderTaskQueue.run(); + }, 400); + }); + + test('returns true when double-click zooming', (done) => { + const map = createMap(); + + map.on('zoomstart', () => { + expect(map.isZooming()).toBe(true); + }); + + map.on('zoomend', () => { + expect(map.isZooming()).toBe(false); + map.remove(); + done(); + }); + + let now = 0; + jest.spyOn(browser, 'now').mockImplementation(() => { + return now; + }); + + simulate.dblclick(map.getCanvasContainer()); + map._renderTaskQueue.run(); + + now += 500; + map._renderTaskQueue.run(); + }); +}); diff --git a/packages/map/__tests__/map/map_pitch.spec.ts b/packages/map/__tests__/map/map_pitch.spec.ts new file mode 100644 index 00000000000..e02f79d8595 --- /dev/null +++ b/packages/map/__tests__/map/map_pitch.spec.ts @@ -0,0 +1,90 @@ +import { beforeMapTest, createMap } from '../libs/util'; + +beforeEach(() => { + beforeMapTest(); + global.fetch = null; +}); + +test('#setMinPitch', () => { + const map = createMap({ pitch: 20 }); + map.setMinPitch(10); + map.setPitch(0); + expect(map.getPitch()).toBe(10); +}); + +test('unset minPitch', () => { + const map = createMap({ minPitch: 20 }); + map.setMinPitch(null); + map.setPitch(0); + expect(map.getPitch()).toBe(0); +}); + +test('#getMinPitch', () => { + const map = createMap({ pitch: 0 }); + expect(map.getMinPitch()).toBe(0); + map.setMinPitch(10); + expect(map.getMinPitch()).toBe(10); +}); + +test('ignore minPitchs over maxPitch', () => { + const map = createMap({ pitch: 0, maxPitch: 10 }); + expect(() => { + map.setMinPitch(20); + }).toThrow(); + map.setPitch(0); + expect(map.getPitch()).toBe(0); +}); + +test('#setMaxPitch', () => { + const map = createMap({ pitch: 0 }); + map.setMaxPitch(10); + map.setPitch(20); + expect(map.getPitch()).toBe(10); +}); + +test('unset maxPitch', () => { + const map = createMap({ maxPitch: 10 }); + map.setMaxPitch(null); + map.setPitch(20); + expect(map.getPitch()).toBe(20); +}); + +test('#getMaxPitch', () => { + const map = createMap({ pitch: 0 }); + expect(map.getMaxPitch()).toBe(60); + map.setMaxPitch(10); + expect(map.getMaxPitch()).toBe(10); +}); + +test('ignore maxPitchs over minPitch', () => { + const map = createMap({ minPitch: 10 }); + expect(() => { + map.setMaxPitch(0); + }).toThrow(); + map.setPitch(10); + expect(map.getPitch()).toBe(10); +}); + +test('throw on maxPitch smaller than minPitch at init', () => { + expect(() => { + createMap({ minPitch: 10, maxPitch: 5 }); + }).toThrow(new Error('maxPitch must be greater than or equal to minPitch')); +}); + +test('throw on maxPitch smaller than minPitch at init with falsey maxPitch', () => { + expect(() => { + createMap({ minPitch: 1, maxPitch: 0 }); + }).toThrow(new Error('maxPitch must be greater than or equal to minPitch')); +}); + +test('throw on maxPitch greater than valid maxPitch at init', () => { + expect(() => { + createMap({ maxPitch: 90 }); + }).toThrow(new Error('maxPitch must be less than or equal to 85')); +}); + +test('throw on minPitch less than valid minPitch at init', () => { + expect(() => { + createMap({ minPitch: -10 }); + }).toThrow(new Error('minPitch must be greater than or equal to 0')); +}); diff --git a/packages/map/__tests__/map/map_request_render_frame.spec.ts b/packages/map/__tests__/map/map_request_render_frame.spec.ts new file mode 100644 index 00000000000..0907f553dcd --- /dev/null +++ b/packages/map/__tests__/map/map_request_render_frame.spec.ts @@ -0,0 +1,34 @@ +import { beforeMapTest, createMap } from '../libs/util'; + +beforeEach(() => { + beforeMapTest(); +}); + +describe('requestRenderFrame', () => { + test('Map#_requestRenderFrame should not schedule a render frame before style load', () => { + const map = createMap(); + const spy = jest.spyOn(map, 'triggerRepaint'); + map._requestRenderFrame(() => {}); + expect(spy).toHaveBeenCalledTimes(1); + map.remove(); + }); + + test('Map#_requestRenderFrame queues a task for the next render frame', async () => { + const map = createMap(); + const cb = jest.fn(); + map._requestRenderFrame(cb); + await map.once('render'); + expect(cb).toHaveBeenCalledTimes(1); + map.remove(); + }); + + test('Map#_cancelRenderFrame cancels a queued task', async () => { + const map = createMap(); + const cb = jest.fn(); + const id = map._requestRenderFrame(cb); + map._cancelRenderFrame(id); + await map.once('render'); + expect(cb).toHaveBeenCalledTimes(0); + map.remove(); + }); +}); diff --git a/packages/map/__tests__/map/map_resize.spec.ts b/packages/map/__tests__/map/map_resize.spec.ts new file mode 100644 index 00000000000..705f1e6bb40 --- /dev/null +++ b/packages/map/__tests__/map/map_resize.spec.ts @@ -0,0 +1,102 @@ +import { beforeMapTest, createMap, sleep } from '../libs/util'; + +beforeEach(() => { + beforeMapTest(); + global.fetch = null; +}); + +describe('#resize', () => { + test('sets width and height from container clients', () => { + const map = createMap(), + container = map.getContainer(); + + Object.defineProperty(container, 'clientWidth', { value: 250 }); + Object.defineProperty(container, 'clientHeight', { value: 250 }); + map.resize(); + + expect(map.transform.width).toBe(250); + expect(map.transform.height).toBe(250); + }); + + test('fires movestart, move, resize, and moveend events', () => { + const map = createMap(), + events = []; + + (['movestart', 'move', 'resize', 'moveend'] as any).forEach((event) => { + map.on(event, (e) => { + events.push(e.type); + }); + }); + + map.resize(); + expect(events).toEqual(['movestart', 'move', 'resize', 'moveend']); + }); + + test('listen to window resize event', () => { + const spy = jest.fn(); + global.ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: spy, + })); + + createMap(); + + expect(spy).toHaveBeenCalled(); + }); + + test('do not resize if trackResize is false', () => { + let observerCallback: Function = null; + global.ResizeObserver = jest.fn().mockImplementation((c) => ({ + observe: () => { + observerCallback = c; + }, + })); + + const map = createMap({ trackResize: false }); + + const spyA = jest.spyOn(map, 'stop'); + const spyB = jest.spyOn(map, '_update'); + const spyC = jest.spyOn(map, 'resize'); + + observerCallback(); + + expect(spyA).not.toHaveBeenCalled(); + expect(spyB).not.toHaveBeenCalled(); + expect(spyC).not.toHaveBeenCalled(); + }); + + test('do resize if trackResize is true (default)', async () => { + let observerCallback: Function = null; + global.ResizeObserver = jest.fn().mockImplementation((c) => ({ + observe: () => { + observerCallback = c; + }, + })); + + const map = createMap(); + + const updateSpy = jest.spyOn(map, '_update'); + const resizeSpy = jest.spyOn(map, 'resize'); + + // The initial "observe" event fired by ResizeObserver should be captured/muted + // in the map constructor + + observerCallback(); + expect(updateSpy).not.toHaveBeenCalled(); + expect(resizeSpy).not.toHaveBeenCalled(); + + // The next "observe" event should fire a resize / _update + + observerCallback(); + expect(updateSpy).toHaveBeenCalled(); + expect(resizeSpy).toHaveBeenCalledTimes(1); + + // Additional "observe" events should be throttled + observerCallback(); + observerCallback(); + observerCallback(); + observerCallback(); + expect(resizeSpy).toHaveBeenCalledTimes(1); + await sleep(100); + expect(resizeSpy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/map/__tests__/map/map_world_copies.spec.ts b/packages/map/__tests__/map/map_world_copies.spec.ts new file mode 100644 index 00000000000..5722e56a871 --- /dev/null +++ b/packages/map/__tests__/map/map_world_copies.spec.ts @@ -0,0 +1,101 @@ +import { beforeMapTest, createMap } from '../libs/util'; + +beforeEach(() => { + beforeMapTest(); + global.fetch = null; +}); + +describe('#getRenderWorldCopies', () => { + test('initially false', () => { + const map = createMap({ renderWorldCopies: false }); + expect(map.getRenderWorldCopies()).toBe(false); + }); + + test('initially true', () => { + const map = createMap({ renderWorldCopies: true }); + expect(map.getRenderWorldCopies()).toBe(true); + }); +}); + +describe('#setRenderWorldCopies', () => { + test('initially false', () => { + const map = createMap({ renderWorldCopies: false }); + map.setRenderWorldCopies(true); + expect(map.getRenderWorldCopies()).toBe(true); + }); + + test('initially true', () => { + const map = createMap({ renderWorldCopies: true }); + map.setRenderWorldCopies(false); + expect(map.getRenderWorldCopies()).toBe(false); + }); + + test('undefined', () => { + const map = createMap({ renderWorldCopies: false }); + map.setRenderWorldCopies(undefined); + expect(map.getRenderWorldCopies()).toBe(true); + }); + + test('null', () => { + const map = createMap({ renderWorldCopies: true }); + map.setRenderWorldCopies(null); + expect(map.getRenderWorldCopies()).toBe(false); + }); +}); + +describe('#renderWorldCopies', () => { + test('does not constrain horizontal panning when renderWorldCopies is set to true', () => { + const map = createMap({ renderWorldCopies: true }); + map.setCenter({ lng: 180, lat: 0 }); + expect(map.getCenter().lng).toBe(180); + }); + + test('constrains horizontal panning when renderWorldCopies is set to false', () => { + const map = createMap({ renderWorldCopies: false }); + map.setCenter({ lng: 180, lat: 0 }); + expect(map.getCenter().lng).toBeCloseTo(110, 0); + }); + + test('does not wrap the map when renderWorldCopies is set to false', () => { + const map = createMap({ renderWorldCopies: false }); + map.setCenter({ lng: 200, lat: 0 }); + expect(map.getCenter().lng).toBeCloseTo(110, 0); + }); + + test('panTo is constrained to single globe when renderWorldCopies is set to false', () => { + const map = createMap({ renderWorldCopies: false }); + map.panTo({ lng: 180, lat: 0 }, { duration: 0 }); + expect(map.getCenter().lng).toBeCloseTo(110, 0); + map.panTo({ lng: -3000, lat: 0 }, { duration: 0 }); + expect(map.getCenter().lng).toBeCloseTo(-110, 0); + }); + + test('flyTo is constrained to single globe when renderWorldCopies is set to false', () => { + const map = createMap({ renderWorldCopies: false }); + map.flyTo({ center: [1000, 0], zoom: 3, animate: false }); + expect(map.getCenter().lng).toBeCloseTo(171, 0); + map.flyTo({ center: [-1000, 0], zoom: 5, animate: false }); + expect(map.getCenter().lng).toBeCloseTo(-178, 0); + }); + + test('lng is constrained to a single globe when zooming with {renderWorldCopies: false}', () => { + const map = createMap({ renderWorldCopies: false, center: [180, 0], zoom: 2 }); + expect(map.getCenter().lng).toBeCloseTo(162, 0); + map.zoomTo(1, { animate: false }); + expect(map.getCenter().lng).toBeCloseTo(145, 0); + }); + + test('lng is constrained by maxBounds when {renderWorldCopies: false}', () => { + const map = createMap({ + renderWorldCopies: false, + maxBounds: [ + [70, 30], + [80, 40], + ], + zoom: 8, + center: [75, 35], + }); + map.setCenter({ lng: 180, lat: 0 }); + expect(map.getCenter().lng).toBeCloseTo(80, 0); + }); +}); diff --git a/packages/map/__tests__/map/map_zoom.spec.ts b/packages/map/__tests__/map/map_zoom.spec.ts new file mode 100644 index 00000000000..865630eba97 --- /dev/null +++ b/packages/map/__tests__/map/map_zoom.spec.ts @@ -0,0 +1,78 @@ +import { beforeMapTest, createMap } from '../libs/util'; + +beforeEach(() => { + beforeMapTest(); + global.fetch = null; +}); + +test('#setMinZoom', () => { + const map = createMap({ zoom: 5 }); + map.setMinZoom(3.5); + map.setZoom(1); + expect(map.getZoom()).toBe(3.5); +}); + +test('unset minZoom', () => { + const map = createMap({ minZoom: 5 }); + map.setMinZoom(null); + map.setZoom(1); + expect(map.getZoom()).toBe(1); +}); + +test('#getMinZoom', () => { + const map = createMap({ zoom: 0 }); + expect(map.getMinZoom()).toBe(-2); + map.setMinZoom(10); + expect(map.getMinZoom()).toBe(10); +}); + +test('ignore minZooms over maxZoom', () => { + const map = createMap({ zoom: 2, maxZoom: 5 }); + expect(() => { + map.setMinZoom(6); + }).toThrow(); + map.setZoom(0); + expect(map.getZoom()).toBe(0); +}); + +test('#setMaxZoom', () => { + const map = createMap({ zoom: 0 }); + map.setMaxZoom(3.5); + map.setZoom(4); + expect(map.getZoom()).toBe(3.5); +}); + +test('unset maxZoom', () => { + const map = createMap({ maxZoom: 5 }); + map.setMaxZoom(null); + map.setZoom(6); + expect(map.getZoom()).toBe(6); +}); + +test('#getMaxZoom', () => { + const map = createMap({ zoom: 0 }); + expect(map.getMaxZoom()).toBe(22); + map.setMaxZoom(10); + expect(map.getMaxZoom()).toBe(10); +}); + +test('ignore maxZooms over minZoom', () => { + const map = createMap({ minZoom: 5 }); + expect(() => { + map.setMaxZoom(4); + }).toThrow(); + map.setZoom(5); + expect(map.getZoom()).toBe(5); +}); + +test('throw on maxZoom smaller than minZoom at init', () => { + expect(() => { + createMap({ minZoom: 10, maxZoom: 5 }); + }).toThrow(new Error('maxZoom must be greater than or equal to minZoom')); +}); + +test('throw on maxZoom smaller than minZoom at init with falsey maxZoom', () => { + expect(() => { + createMap({ minZoom: 1, maxZoom: 0 }); + }).toThrow(new Error('maxZoom must be greater than or equal to minZoom')); +}); diff --git a/packages/map/__tests__/util/browser.spec.ts b/packages/map/__tests__/util/browser.spec.ts new file mode 100644 index 00000000000..dbc8d7cb2ec --- /dev/null +++ b/packages/map/__tests__/util/browser.spec.ts @@ -0,0 +1,19 @@ +import { browser } from '../../src/map/util/browser'; + +describe('browser', () => { + test('frameAsync', async () => { + const id = await browser.frameAsync(new AbortController()); + expect(id).toBeTruthy(); + }); + + test('now', () => { + expect(typeof browser.now()).toBe('number'); + }); + + test('frameAsync', async () => { + const abortController = new AbortController(); + const promise = browser.frameAsync(abortController); + abortController.abort(); + await expect(promise).rejects.toThrow(); + }); +}); diff --git a/packages/map/__tests__/util/evented.spec.ts b/packages/map/__tests__/util/evented.spec.ts new file mode 100644 index 00000000000..313357fc4de --- /dev/null +++ b/packages/map/__tests__/util/evented.spec.ts @@ -0,0 +1,193 @@ +import { Event, Evented } from '../../src/map/util/evented'; + +describe('Evented', () => { + test('calls listeners added with "on"', () => { + const evented = new Evented(); + const listener = jest.fn(); + evented.on('a', listener); + evented.fire(new Event('a')); + evented.fire(new Event('a')); + expect(listener).toHaveBeenCalledTimes(2); + }); + + test('calls listeners added with "once" once', () => { + const evented = new Evented(); + const listener = jest.fn(); + evented.once('a', listener); + evented.fire(new Event('a')); + evented.fire(new Event('a')); + expect(listener).toHaveBeenCalledTimes(1); + expect(evented.listens('a')).toBeFalsy(); + }); + + test('returns a promise when no listener is provided to "once" method', async () => { + const evented = new Evented(); + const promise = evented.once('a'); + evented.fire(new Event('a')); + evented.fire(new Event('a')); + await promise; + expect(evented.listens('a')).toBeFalsy(); + }); + + test('passes data to listeners', () => { + const evented = new Evented(); + evented.on('a', (data) => { + expect(data.foo).toBe('bar'); + }); + evented.fire(new Event('a', { foo: 'bar' })); + }); + + test('passes "target" to listeners', () => { + const evented = new Evented(); + evented.on('a', (data) => { + expect(data.target).toBe(evented); + }); + evented.fire(new Event('a')); + }); + + test('passes "type" to listeners', () => { + const evented = new Evented(); + evented.on('a', (data) => { + expect(data.type).toBe('a'); + }); + evented.fire(new Event('a')); + }); + + test('removes listeners with "off"', () => { + const evented = new Evented(); + const listener = jest.fn(); + evented.on('a', listener); + evented.off('a', listener); + evented.fire(new Event('a')); + expect(listener).not.toHaveBeenCalled(); + }); + + test('removes one-time listeners with "off"', () => { + const evented = new Evented(); + const listener = jest.fn(); + evented.once('a', listener); + evented.off('a', listener); + evented.fire(new Event('a')); + expect(listener).not.toHaveBeenCalled(); + }); + + test('once listener is removed prior to call', () => { + const evented = new Evented(); + const listener = jest.fn(); + evented.once('a', () => { + listener(); + evented.fire(new Event('a')); + }); + evented.fire(new Event('a')); + expect(listener).toHaveBeenCalledTimes(1); + }); + + test('reports if an event has listeners with "listens"', () => { + const evented = new Evented(); + evented.on('a', () => {}); + expect(evented.listens('a')).toBeTruthy(); + expect(evented.listens('b')).toBeFalsy(); + }); + + test('does not report true to "listens" if all listeners have been removed', () => { + const evented = new Evented(); + const listener = () => {}; + evented.on('a', listener); + evented.off('a', listener); + expect(evented.listens('a')).toBeFalsy(); + }); + + test('does not immediately call listeners added within another listener', (done) => { + const evented = new Evented(); + evented.on('a', () => { + evented.on('a', () => done('fail')); + }); + evented.fire(new Event('a')); + done(); + }); + + test('has backward compatibility for fire(string, object) API', () => { + const evented = new Evented(); + const listener = jest.fn((x) => x); + evented.on('a', listener); + evented.fire('a' as any as Event, { foo: 'bar' }); + expect(listener).toHaveBeenCalledTimes(1); + expect(listener.mock.calls[0][0].foo).toBe('bar'); + }); + + test('on is idempotent', () => { + const evented = new Evented(); + const order: string[] = []; + const listenerA = jest.fn(() => order.push('A')); + const listenerB = jest.fn(() => order.push('B')); + evented.on('a', listenerA); + evented.on('a', listenerB); + evented.on('a', listenerA); + evented.fire(new Event('a')); + expect(listenerA).toHaveBeenCalledTimes(1); + expect(order).toEqual(['A', 'B']); + }); +}); + +describe('evented parents', () => { + test('adds parents with "setEventedParent"', () => { + const listener = jest.fn(); + const eventedSource = new Evented(); + const eventedSink = new Evented(); + eventedSource.setEventedParent(eventedSink); + eventedSink.on('a', listener); + eventedSource.fire(new Event('a')); + eventedSource.fire(new Event('a')); + expect(listener).toHaveBeenCalledTimes(2); + }); + + test('passes original data to parent listeners', () => { + const eventedSource = new Evented(); + const eventedSink = new Evented(); + eventedSource.setEventedParent(eventedSink); + eventedSink.on('a', (data) => { + expect(data.foo).toBe('bar'); + }); + eventedSource.fire(new Event('a', { foo: 'bar' })); + }); + + test('attaches parent data to parent listeners', () => { + const eventedSource = new Evented(); + const eventedSink = new Evented(); + eventedSource.setEventedParent(eventedSink, { foz: 'baz' }); + eventedSink.on('a', (data) => { + expect(data.foz).toBe('baz'); + }); + eventedSource.fire(new Event('a', { foo: 'bar' })); + }); + + test('attaches parent data from a function to parent listeners', () => { + const eventedSource = new Evented(); + const eventedSink = new Evented(); + eventedSource.setEventedParent(eventedSink, () => ({ foz: 'baz' })); + eventedSink.on('a', (data) => { + expect(data.foz).toBe('baz'); + }); + eventedSource.fire(new Event('a', { foo: 'bar' })); + }); + + test('reports if an event has parent listeners with "listens"', () => { + const eventedSource = new Evented(); + const eventedSink = new Evented(); + eventedSink.on('a', () => {}); + eventedSource.setEventedParent(eventedSink); + expect(eventedSink.listens('a')).toBeTruthy(); + }); + + test('eventedParent data function is evaluated on every fire', () => { + const eventedSource = new Evented(); + const eventedParent = new Evented(); + let i = 0; + eventedSource.setEventedParent(eventedParent, () => i++); + eventedSource.on('a', () => {}); + eventedSource.fire(new Event('a')); + expect(i).toBe(1); + eventedSource.fire(new Event('a')); + expect(i).toBe(2); + }); +}); diff --git a/packages/map/__tests__/util/task_queue.spec.ts b/packages/map/__tests__/util/task_queue.spec.ts new file mode 100644 index 00000000000..054688ef4b8 --- /dev/null +++ b/packages/map/__tests__/util/task_queue.spec.ts @@ -0,0 +1,114 @@ +import { TaskQueue } from '../../src/map/util/task_queue'; + +describe('TaskQueue', () => { + test('Calls callbacks, in order', () => { + const q = new TaskQueue(); + let first = 0; + let second = 0; + q.add(() => { + expect(++first).toBe(1); + expect(second).toBe(0); + }); + q.add(() => { + expect(first).toBe(1); + expect(++second).toBe(1); + }); + q.run(); + expect(first).toBe(1); + expect(second).toBe(1); + }); + + test('Allows a given callback to be queued multiple times', () => { + const q = new TaskQueue(); + const fn = jest.fn(); + q.add(fn); + q.add(fn); + q.run(); + expect(fn).toHaveBeenCalledTimes(2); + }); + + test('Does not call a callback that was cancelled before the queue was run', () => { + const q = new TaskQueue(); + const yes = jest.fn(); + const no = jest.fn(); + q.add(yes); + const id = q.add(no); + q.remove(id); + q.run(); + expect(yes).toHaveBeenCalledTimes(1); + expect(no).not.toHaveBeenCalled(); + }); + + test('Does not call a callback that was cancelled while the queue was running', () => { + const q = new TaskQueue(); + const yes = jest.fn(); + const no = jest.fn(); + q.add(yes); + let id; // eslint-disable-line prefer-const + q.add(() => q.remove(id)); + id = q.add(no); + q.run(); + expect(yes).toHaveBeenCalledTimes(1); + expect(no).not.toHaveBeenCalled(); + }); + + test('Allows each instance of a multiply-queued callback to be cancelled independently', () => { + const q = new TaskQueue(); + const cb = jest.fn(); + q.add(cb); + const id = q.add(cb); + q.remove(id); + q.run(); + expect(cb).toHaveBeenCalledTimes(1); + }); + + test('Does not throw if a remove() is called after running the queue', () => { + const q = new TaskQueue(); + const cb = jest.fn(); + const id = q.add(cb); + q.run(); + q.remove(id); + expect(cb).toHaveBeenCalledTimes(1); + }); + + test('Does not add tasks to the currently-running queue', () => { + const q = new TaskQueue(); + const cb = jest.fn(); + q.add(() => q.add(cb)); + q.run(); + expect(cb).not.toHaveBeenCalled(); + q.run(); + expect(cb).toHaveBeenCalledTimes(1); + }); + + test('TaskQueue#run() throws on attempted re-entrance', () => { + const q = new TaskQueue(); + q.add(() => q.run()); + expect(() => q.run()).toThrow(); + }); + + test('TaskQueue#clear() prevents queued task from being executed', () => { + const q = new TaskQueue(); + const before = jest.fn(); + const after = jest.fn(); + q.add(before); + q.clear(); + q.add(after); + q.run(); + expect(before).not.toHaveBeenCalled(); + expect(after).toHaveBeenCalledTimes(1); + }); + + test('TaskQueue#clear() interrupts currently-running queue', () => { + const q = new TaskQueue(); + const before = jest.fn(); + const after = jest.fn(); + q.add(() => q.add(after)); + q.add(() => q.clear()); + q.add(before); + q.run(); + expect(before).not.toHaveBeenCalled(); + q.run(); + expect(after).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/map/legacy/README.md b/packages/map/legacy/README.md new file mode 100644 index 00000000000..26b480e6f50 --- /dev/null +++ b/packages/map/legacy/README.md @@ -0,0 +1,3 @@ +## Map + +Map fork from [mapbox-gl-js@1.x](https://github.com/mapbox/mapbox-gl-js/tree/release-v1.13.3), keep event loop, responds user interaction and updates the internal state of the map (current viewport, camera angle, etc.) diff --git a/packages/map/src/camera.ts b/packages/map/legacy/camera.ts similarity index 100% rename from packages/map/src/camera.ts rename to packages/map/legacy/camera.ts diff --git a/packages/map/src/css/l7.css b/packages/map/legacy/css/l7.css similarity index 71% rename from packages/map/src/css/l7.css rename to packages/map/legacy/css/l7.css index f1faa047a3f..4a6d99c3970 100644 --- a/packages/map/src/css/l7.css +++ b/packages/map/legacy/css/l7.css @@ -26,12 +26,7 @@ .l7-canvas-container.l7-interactive, .l7-ctrl-group button.l7-ctrl-compass { - cursor: -webkit-grab; - cursor: -moz-grab; cursor: grab; - -moz-user-select: none; - -webkit-user-select: none; - -ms-user-select: none; user-select: none; } @@ -41,8 +36,6 @@ .l7-canvas-container.l7-interactive:active, .l7-ctrl-group button.l7-ctrl-compass:active { - cursor: -webkit-grabbing; - cursor: -moz-grabbing; cursor: grabbing; } @@ -61,6 +54,47 @@ touch-action: none; } +.l7-canvas-container.l7-touch-drag-pan.l7-cooperative-gestures, +.l7-canvas-container.l7-touch-drag-pan.l7-cooperative-gestures .l7-canvas { + touch-action: pan-x pan-y; +} + +.l7-cooperative-gesture-screen { + background: rgba(0 0 0 / 40%); + position: absolute; + inset: 0; + display: flex; + justify-content: center; + align-items: center; + color: white; + padding: 1rem; + font-size: 1.4em; + line-height: 1.2; + opacity: 0; + pointer-events: none; + transition: opacity 1s ease 1s; + z-index: 99999; +} + +.l7-cooperative-gesture-screen.l7-show { + opacity: 1; + transition: opacity 0.05s; +} + +.l7-cooperative-gesture-screen .l7-mobile-message { + display: none; +} + +@media (hover: none), (width <= 480px) { + .l7-cooperative-gesture-screen .l7-desktop-message { + display: none; + } + + .l7-cooperative-gesture-screen .l7-mobile-message { + display: block; + } +} + .l7-ctrl-top-left, .l7-ctrl-top-right, .l7-ctrl-bottom-left, diff --git a/packages/map/src/earthmap.ts b/packages/map/legacy/earthmap.ts similarity index 99% rename from packages/map/src/earthmap.ts rename to packages/map/legacy/earthmap.ts index de95de44546..185a7b9749f 100644 --- a/packages/map/src/earthmap.ts +++ b/packages/map/legacy/earthmap.ts @@ -58,6 +58,10 @@ const DefaultOptions: IMapOptions = { pitchEnabled: true, rotateEnabled: true, }; + +/** + * @deprecated + */ export class EarthMap extends Camera { public doubleClickZoom: DoubleClickZoomHandler; public dragRotate: DragRotateHandler; diff --git a/packages/map/src/geo/edge_insets.ts b/packages/map/legacy/geo/edge_insets.ts similarity index 100% rename from packages/map/src/geo/edge_insets.ts rename to packages/map/legacy/geo/edge_insets.ts diff --git a/packages/map/src/geo/lng_lat.ts b/packages/map/legacy/geo/lng_lat.ts similarity index 100% rename from packages/map/src/geo/lng_lat.ts rename to packages/map/legacy/geo/lng_lat.ts diff --git a/packages/map/src/geo/lng_lat_bounds.ts b/packages/map/legacy/geo/lng_lat_bounds.ts similarity index 100% rename from packages/map/src/geo/lng_lat_bounds.ts rename to packages/map/legacy/geo/lng_lat_bounds.ts diff --git a/packages/map/src/geo/mercator.ts b/packages/map/legacy/geo/mercator.ts similarity index 95% rename from packages/map/src/geo/mercator.ts rename to packages/map/legacy/geo/mercator.ts index b8eb9347c00..dcee92f63fe 100644 --- a/packages/map/src/geo/mercator.ts +++ b/packages/map/legacy/geo/mercator.ts @@ -1,5 +1,5 @@ -import type { LngLatLike } from '../geo/lng_lat'; -import LngLat, { earthRadius } from '../geo/lng_lat'; +import type { LngLatLike } from './lng_lat'; +import LngLat, { earthRadius } from './lng_lat'; /* * The average circumference of the world in meters. diff --git a/packages/map/src/geo/point.ts b/packages/map/legacy/geo/point.ts similarity index 100% rename from packages/map/src/geo/point.ts rename to packages/map/legacy/geo/point.ts diff --git a/packages/map/src/geo/simple.ts b/packages/map/legacy/geo/simple.ts similarity index 95% rename from packages/map/src/geo/simple.ts rename to packages/map/legacy/geo/simple.ts index 7219394d437..24cbc9ea735 100644 --- a/packages/map/src/geo/simple.ts +++ b/packages/map/legacy/geo/simple.ts @@ -1,5 +1,5 @@ -import type { LngLatLike } from '../geo/lng_lat'; -import LngLat, { earthRadius } from '../geo/lng_lat'; +import type { LngLatLike } from './lng_lat'; +import LngLat, { earthRadius } from './lng_lat'; /* * The average circumference of the world in meters. diff --git a/packages/map/src/geo/transform.ts b/packages/map/legacy/geo/transform.ts similarity index 99% rename from packages/map/src/geo/transform.ts rename to packages/map/legacy/geo/transform.ts index db33c0f83e4..8505ec8e0ec 100644 --- a/packages/map/src/geo/transform.ts +++ b/packages/map/legacy/geo/transform.ts @@ -1,6 +1,5 @@ // @ts-ignore import { mat2, mat4, vec4 } from 'gl-matrix'; -import Point from '../geo/point'; import { clamp, interpolate, wrap } from '../util'; import type { IPaddingOptions } from './edge_insets'; import EdgeInsets from './edge_insets'; @@ -11,6 +10,7 @@ import MercatorCoordinate, { mercatorYfromLat, mercatorZfromAltitude, } from './mercator'; +import Point from './point'; export const EXTENT = 8192; export default class Transform { get minZoom(): number { diff --git a/packages/map/src/handler/IHandler.ts b/packages/map/legacy/handler/IHandler.ts similarity index 100% rename from packages/map/src/handler/IHandler.ts rename to packages/map/legacy/handler/IHandler.ts diff --git a/packages/map/src/handler/blockable_map_event.ts b/packages/map/legacy/handler/blockable_map_event.ts similarity index 100% rename from packages/map/src/handler/blockable_map_event.ts rename to packages/map/legacy/handler/blockable_map_event.ts diff --git a/packages/map/src/handler/box_zoom.ts b/packages/map/legacy/handler/box_zoom.ts similarity index 100% rename from packages/map/src/handler/box_zoom.ts rename to packages/map/legacy/handler/box_zoom.ts diff --git a/packages/map/src/handler/click_zoom.ts b/packages/map/legacy/handler/click_zoom.ts similarity index 100% rename from packages/map/src/handler/click_zoom.ts rename to packages/map/legacy/handler/click_zoom.ts diff --git a/packages/map/src/handler/events/event.ts b/packages/map/legacy/handler/events/event.ts similarity index 100% rename from packages/map/src/handler/events/event.ts rename to packages/map/legacy/handler/events/event.ts diff --git a/packages/map/src/handler/events/index.ts b/packages/map/legacy/handler/events/index.ts similarity index 100% rename from packages/map/src/handler/events/index.ts rename to packages/map/legacy/handler/events/index.ts diff --git a/packages/map/src/handler/events/map_mouse_event.ts b/packages/map/legacy/handler/events/map_mouse_event.ts similarity index 100% rename from packages/map/src/handler/events/map_mouse_event.ts rename to packages/map/legacy/handler/events/map_mouse_event.ts diff --git a/packages/map/src/handler/events/map_touch_event.ts b/packages/map/legacy/handler/events/map_touch_event.ts similarity index 100% rename from packages/map/src/handler/events/map_touch_event.ts rename to packages/map/legacy/handler/events/map_touch_event.ts diff --git a/packages/map/src/handler/events/map_wheel_event.ts b/packages/map/legacy/handler/events/map_wheel_event.ts similarity index 100% rename from packages/map/src/handler/events/map_wheel_event.ts rename to packages/map/legacy/handler/events/map_wheel_event.ts diff --git a/packages/map/src/handler/events/render_event.ts b/packages/map/legacy/handler/events/render_event.ts similarity index 100% rename from packages/map/src/handler/events/render_event.ts rename to packages/map/legacy/handler/events/render_event.ts diff --git a/packages/map/src/handler/handler_inertia.ts b/packages/map/legacy/handler/handler_inertia.ts similarity index 100% rename from packages/map/src/handler/handler_inertia.ts rename to packages/map/legacy/handler/handler_inertia.ts diff --git a/packages/map/src/handler/handler_manager.ts b/packages/map/legacy/handler/handler_manager.ts similarity index 100% rename from packages/map/src/handler/handler_manager.ts rename to packages/map/legacy/handler/handler_manager.ts diff --git a/packages/map/src/handler/handler_util.ts b/packages/map/legacy/handler/handler_util.ts similarity index 100% rename from packages/map/src/handler/handler_util.ts rename to packages/map/legacy/handler/handler_util.ts diff --git a/packages/map/src/handler/keyboard.ts b/packages/map/legacy/handler/keyboard.ts similarity index 100% rename from packages/map/src/handler/keyboard.ts rename to packages/map/legacy/handler/keyboard.ts diff --git a/packages/map/src/handler/map_event.ts b/packages/map/legacy/handler/map_event.ts similarity index 100% rename from packages/map/src/handler/map_event.ts rename to packages/map/legacy/handler/map_event.ts diff --git a/packages/map/src/handler/mouse/index.ts b/packages/map/legacy/handler/mouse/index.ts similarity index 100% rename from packages/map/src/handler/mouse/index.ts rename to packages/map/legacy/handler/mouse/index.ts diff --git a/packages/map/src/handler/mouse/mouse_handler.ts b/packages/map/legacy/handler/mouse/mouse_handler.ts similarity index 100% rename from packages/map/src/handler/mouse/mouse_handler.ts rename to packages/map/legacy/handler/mouse/mouse_handler.ts diff --git a/packages/map/src/handler/mouse/mousepan_handler.ts b/packages/map/legacy/handler/mouse/mousepan_handler.ts similarity index 100% rename from packages/map/src/handler/mouse/mousepan_handler.ts rename to packages/map/legacy/handler/mouse/mousepan_handler.ts diff --git a/packages/map/src/handler/mouse/mousepitch_hander.ts b/packages/map/legacy/handler/mouse/mousepitch_hander.ts similarity index 100% rename from packages/map/src/handler/mouse/mousepitch_hander.ts rename to packages/map/legacy/handler/mouse/mousepitch_hander.ts diff --git a/packages/map/src/handler/mouse/mouserotate_hander.ts b/packages/map/legacy/handler/mouse/mouserotate_hander.ts similarity index 100% rename from packages/map/src/handler/mouse/mouserotate_hander.ts rename to packages/map/legacy/handler/mouse/mouserotate_hander.ts diff --git a/packages/map/src/handler/mouse/util.ts b/packages/map/legacy/handler/mouse/util.ts similarity index 100% rename from packages/map/src/handler/mouse/util.ts rename to packages/map/legacy/handler/mouse/util.ts diff --git a/packages/map/src/handler/scroll_zoom.ts b/packages/map/legacy/handler/scroll_zoom.ts similarity index 100% rename from packages/map/src/handler/scroll_zoom.ts rename to packages/map/legacy/handler/scroll_zoom.ts diff --git a/packages/map/src/handler/shim/dblclick_zoom.ts b/packages/map/legacy/handler/shim/dblclick_zoom.ts similarity index 100% rename from packages/map/src/handler/shim/dblclick_zoom.ts rename to packages/map/legacy/handler/shim/dblclick_zoom.ts diff --git a/packages/map/src/handler/shim/drag_pan.ts b/packages/map/legacy/handler/shim/drag_pan.ts similarity index 95% rename from packages/map/src/handler/shim/drag_pan.ts rename to packages/map/legacy/handler/shim/drag_pan.ts index 751121dbbec..8b734189608 100644 --- a/packages/map/src/handler/shim/drag_pan.ts +++ b/packages/map/legacy/handler/shim/drag_pan.ts @@ -1,5 +1,5 @@ -import type { MousePanHandler } from '../mouse/'; -import type { TouchPanHandler } from '../touch/'; +import type { MousePanHandler } from '../mouse'; +import type { TouchPanHandler } from '../touch'; export interface IDragPanOptions { linearity?: number; diff --git a/packages/map/src/handler/shim/drag_rotate.ts b/packages/map/legacy/handler/shim/drag_rotate.ts similarity index 100% rename from packages/map/src/handler/shim/drag_rotate.ts rename to packages/map/legacy/handler/shim/drag_rotate.ts diff --git a/packages/map/src/handler/shim/touch_zoom_rotate.ts b/packages/map/legacy/handler/shim/touch_zoom_rotate.ts similarity index 100% rename from packages/map/src/handler/shim/touch_zoom_rotate.ts rename to packages/map/legacy/handler/shim/touch_zoom_rotate.ts diff --git a/packages/map/src/handler/tap/single_tap_recognizer.ts b/packages/map/legacy/handler/tap/single_tap_recognizer.ts similarity index 100% rename from packages/map/src/handler/tap/single_tap_recognizer.ts rename to packages/map/legacy/handler/tap/single_tap_recognizer.ts diff --git a/packages/map/src/handler/tap/tap_drag_zoom.ts b/packages/map/legacy/handler/tap/tap_drag_zoom.ts similarity index 100% rename from packages/map/src/handler/tap/tap_drag_zoom.ts rename to packages/map/legacy/handler/tap/tap_drag_zoom.ts diff --git a/packages/map/src/handler/tap/tap_recognizer.ts b/packages/map/legacy/handler/tap/tap_recognizer.ts similarity index 100% rename from packages/map/src/handler/tap/tap_recognizer.ts rename to packages/map/legacy/handler/tap/tap_recognizer.ts diff --git a/packages/map/src/handler/tap/tap_zoom.ts b/packages/map/legacy/handler/tap/tap_zoom.ts similarity index 100% rename from packages/map/src/handler/tap/tap_zoom.ts rename to packages/map/legacy/handler/tap/tap_zoom.ts diff --git a/packages/map/src/handler/touch/index.ts b/packages/map/legacy/handler/touch/index.ts similarity index 100% rename from packages/map/src/handler/touch/index.ts rename to packages/map/legacy/handler/touch/index.ts diff --git a/packages/map/src/handler/touch/touch_pan.ts b/packages/map/legacy/handler/touch/touch_pan.ts similarity index 100% rename from packages/map/src/handler/touch/touch_pan.ts rename to packages/map/legacy/handler/touch/touch_pan.ts diff --git a/packages/map/src/handler/touch/touch_pitch.ts b/packages/map/legacy/handler/touch/touch_pitch.ts similarity index 100% rename from packages/map/src/handler/touch/touch_pitch.ts rename to packages/map/legacy/handler/touch/touch_pitch.ts diff --git a/packages/map/src/handler/touch/touch_rotate.ts b/packages/map/legacy/handler/touch/touch_rotate.ts similarity index 100% rename from packages/map/src/handler/touch/touch_rotate.ts rename to packages/map/legacy/handler/touch/touch_rotate.ts diff --git a/packages/map/src/handler/touch/touch_zoom.ts b/packages/map/legacy/handler/touch/touch_zoom.ts similarity index 100% rename from packages/map/src/handler/touch/touch_zoom.ts rename to packages/map/legacy/handler/touch/touch_zoom.ts diff --git a/packages/map/src/handler/touch/two_touch.ts b/packages/map/legacy/handler/touch/two_touch.ts similarity index 100% rename from packages/map/src/handler/touch/two_touch.ts rename to packages/map/legacy/handler/touch/two_touch.ts diff --git a/packages/map/src/hash.ts b/packages/map/legacy/hash.ts similarity index 100% rename from packages/map/src/hash.ts rename to packages/map/legacy/hash.ts diff --git a/packages/map/src/interface.ts b/packages/map/legacy/interface.ts similarity index 94% rename from packages/map/src/interface.ts rename to packages/map/legacy/interface.ts index a33eb93060f..fe668a43f72 100644 --- a/packages/map/src/interface.ts +++ b/packages/map/legacy/interface.ts @@ -1,5 +1,9 @@ import type { LngLatBoundsLike } from './geo/lng_lat_bounds'; +/** + * @deprecated + * 请使用 MapOptions + */ export interface IMapOptions { hash: boolean; style?: any; diff --git a/packages/map/src/map.ts b/packages/map/legacy/map.ts similarity index 99% rename from packages/map/src/map.ts rename to packages/map/legacy/map.ts index ec6c7d7f2aa..b363fc24b01 100644 --- a/packages/map/src/map.ts +++ b/packages/map/legacy/map.ts @@ -1,6 +1,6 @@ import { DOM, lodashUtil } from '@antv/l7-utils'; import Camera from './camera'; -import './css/l7.css'; +// import './css/l7.css'; import type { LngLatLike } from './geo/lng_lat'; import LngLat from './geo/lng_lat'; import type { LngLatBoundsLike } from './geo/lng_lat_bounds'; @@ -74,6 +74,10 @@ const DefaultOptions: IMapOptions = { pitchEnabled: true, rotateEnabled: true, }; + +/** + * @deprecated + */ export class Map extends Camera { public doubleClickZoom: DoubleClickZoomHandler; public dragRotate: DragRotateHandler; diff --git a/packages/map/src/util.ts b/packages/map/legacy/util.ts similarity index 100% rename from packages/map/src/util.ts rename to packages/map/legacy/util.ts diff --git a/packages/map/src/utils/Aabb.ts b/packages/map/legacy/utils/Aabb.ts similarity index 100% rename from packages/map/src/utils/Aabb.ts rename to packages/map/legacy/utils/Aabb.ts diff --git a/packages/map/src/utils/dom.ts b/packages/map/legacy/utils/dom.ts similarity index 100% rename from packages/map/src/utils/dom.ts rename to packages/map/legacy/utils/dom.ts diff --git a/packages/map/src/utils/performance.ts b/packages/map/legacy/utils/performance.ts similarity index 100% rename from packages/map/src/utils/performance.ts rename to packages/map/legacy/utils/performance.ts diff --git a/packages/map/src/utils/primitives.ts b/packages/map/legacy/utils/primitives.ts similarity index 100% rename from packages/map/src/utils/primitives.ts rename to packages/map/legacy/utils/primitives.ts diff --git a/packages/map/src/utils/task_queue.ts b/packages/map/legacy/utils/task_queue.ts similarity index 100% rename from packages/map/src/utils/task_queue.ts rename to packages/map/legacy/utils/task_queue.ts diff --git a/packages/map/package.json b/packages/map/package.json index eb7ea838d04..7f24f152680 100644 --- a/packages/map/package.json +++ b/packages/map/package.json @@ -32,10 +32,13 @@ "@antv/l7-utils": "workspace:*", "@babel/runtime": "^7.7.7", "@mapbox/point-geometry": "^0.1.0", - "@mapbox/unitbezier": "^0.0.0", + "@mapbox/unitbezier": "^0.0.1", "eventemitter3": "^4.0.4", "gl-matrix": "^3.1.0" }, + "devDependencies": { + "@types/mapbox__point-geometry": "^0.1.4" + }, "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org" diff --git a/packages/map/src/index.ts b/packages/map/src/index.ts index 25c19e47d52..17cccc0d849 100644 --- a/packages/map/src/index.ts +++ b/packages/map/src/index.ts @@ -1,4 +1,3 @@ -export * from './earthmap'; -export * from './geo/mercator'; -export * from './interface'; -export * from './map'; +export { MercatorCoordinate } from './map/geo/mercator_coordinate'; +export { Map } from './map/map'; +export type { MapOptions } from './map/map'; diff --git a/packages/map/src/map/camera.ts b/packages/map/src/map/camera.ts new file mode 100644 index 00000000000..b0301c51b7a --- /dev/null +++ b/packages/map/src/map/camera.ts @@ -0,0 +1,1503 @@ +import Point from '@mapbox/point-geometry'; +import { LngLat } from './geo/lng_lat'; +import { LngLatBounds } from './geo/lng_lat_bounds'; +import { MercatorCoordinate } from './geo/mercator_coordinate'; +import { browser } from './util/browser'; +import { Event, Evented } from './util/evented'; +import { + clamp, + defaultEasing, + degreesToRadians, + extend, + interpolates, + pick, + warnOnce, + wrap, +} from './util/util'; + +import type { PaddingOptions } from './geo/edge_insets'; +import type { LngLatLike } from './geo/lng_lat'; +import type { LngLatBoundsLike } from './geo/lng_lat_bounds'; +import type { Transform } from './geo/transform'; +import type { HandlerManager } from './handler_manager'; +import type { TaskID } from './util/task_queue'; +/** + * A [Point](https://github.com/mapbox/point-geometry) or an array of two numbers representing `x` and `y` screen coordinates in pixels. + * + * @group Geography and Geometry + * + * @example + * ```ts + * let p1 = new Point(-77, 38); // a PointLike which is a Point + * let p2 = [-77, 38]; // a PointLike which is an array of two numbers + * ``` + */ +export type PointLike = Point | [number, number]; + +/** + * A helper to allow require of at least one property + */ +export type RequireAtLeastOne = { + [K in keyof T]-?: Required> & Partial>>; +}[keyof T]; + +/** + * Options common to {@link Map#jumpTo}, {@link Map#easeTo}, and {@link Map#flyTo}, controlling the desired location, + * zoom, bearing, and pitch of the camera. All properties are optional, and when a property is omitted, the current + * camera value for that property will remain unchanged. + * + * @example + * Set the map's initial perspective with CameraOptions + * ```ts + * let map = new Map({ + * container: 'map', + * style: 'https://demotiles.maplibre.org/style.json', + * center: [-73.5804, 45.53483], + * pitch: 60, + * bearing: -60, + * zoom: 10 + * }); + * ``` + * @see [Set pitch and bearing](https://maplibre.org/maplibre-gl-js/docs/examples/set-perspective/) + * @see [Jump to a series of locations](https://maplibre.org/maplibre-gl-js/docs/examples/jump-to/) + * @see [Fly to a location](https://maplibre.org/maplibre-gl-js/docs/examples/flyto/) + * @see [Display buildings in 3D](https://maplibre.org/maplibre-gl-js/docs/examples/3d-buildings/) + */ +export type CameraOptions = CenterZoomBearing & { + /** + * The desired pitch in degrees. The pitch is the angle towards the horizon + * measured in degrees with a range between 0 and 60 degrees. For example, pitch: 0 provides the appearance + * of looking straight down at the map, while pitch: 60 tilts the user's perspective towards the horizon. + * Increasing the pitch value is often used to display 3D objects. + */ + pitch?: number; + /** + * If `zoom` is specified, `around` determines the point around which the zoom is centered. + */ + around?: LngLatLike; +}; + +/** + * Holds center, zoom and bearing properties + */ +export type CenterZoomBearing = { + /** + * The desired center. + */ + center?: LngLatLike; + /** + * The desired zoom level. + */ + zoom?: number; + /** + * The desired bearing in degrees. The bearing is the compass direction that + * is "up". For example, `bearing: 90` orients the map so that east is up. + */ + bearing?: number; +}; + +/** + * The options object related to the {@link Map#jumpTo} method + */ +export type JumpToOptions = CameraOptions & { + /** + * Dimensions in pixels applied on each side of the viewport for shifting the vanishing point. + */ + padding?: PaddingOptions; +}; + +/** + * A options object for the {@link Map#cameraForBounds} method + */ +export type CameraForBoundsOptions = CameraOptions & { + /** + * The amount of padding in pixels to add to the given bounds. + */ + padding?: number | RequireAtLeastOne; + /** + * The center of the given bounds relative to the map's center, measured in pixels. + * @defaultValue [0, 0] + */ + offset?: PointLike; + /** + * The maximum zoom level to allow when the camera would transition to the specified bounds. + */ + maxZoom?: number; +}; + +/** + * The {@link Map#flyTo} options object + */ +export type FlyToOptions = AnimationOptions & + CameraOptions & { + /** + * The zooming "curve" that will occur along the + * flight path. A high value maximizes zooming for an exaggerated animation, while a low + * value minimizes zooming for an effect closer to {@link Map#easeTo}. 1.42 is the average + * value selected by participants in the user study discussed in + * [van Wijk (2003)](https://www.win.tue.nl/~vanwijk/zoompan.pdf). A value of + * `Math.pow(6, 0.25)` would be equivalent to the root mean squared average velocity. A + * value of 1 would produce a circular motion. + * @defaultValue 1.42 + */ + curve?: number; + /** + * The zero-based zoom level at the peak of the flight path. If + * `options.curve` is specified, this option is ignored. + */ + minZoom?: number; + /** + * The average speed of the animation defined in relation to + * `options.curve`. A speed of 1.2 means that the map appears to move along the flight path + * by 1.2 times `options.curve` screenfuls every second. A _screenful_ is the map's visible span. + * It does not correspond to a fixed physical distance, but varies by zoom level. + * @defaultValue 1.2 + */ + speed?: number; + /** + * The average speed of the animation measured in screenfuls + * per second, assuming a linear timing curve. If `options.speed` is specified, this option is ignored. + */ + screenSpeed?: number; + /** + * The animation's maximum duration, measured in milliseconds. + * If duration exceeds maximum duration, it resets to 0. + */ + maxDuration?: number; + /** + * The amount of padding in pixels to add to the given bounds. + */ + padding?: number | RequireAtLeastOne; + }; + +export type EaseToOptions = AnimationOptions & + CameraOptions & { + delayEndEvents?: number; + padding?: number | RequireAtLeastOne; + }; + +/** + * Options for {@link Map#fitBounds} method + */ +export type FitBoundsOptions = FlyToOptions & { + /** + * If `true`, the map transitions using {@link Map#easeTo}. If `false`, the map transitions using {@link Map#flyTo}. + * See those functions and {@link AnimationOptions} for information about options available. + * @defaultValue false + */ + linear?: boolean; + /** + * The center of the given bounds relative to the map's center, measured in pixels. + * @defaultValue [0, 0] + */ + offset?: PointLike; + /** + * The maximum zoom level to allow when the map view transitions to the specified bounds. + */ + maxZoom?: number; +}; + +/** + * Options common to map movement methods that involve animation, such as {@link Map#panBy} and + * {@link Map#easeTo}, controlling the duration and easing function of the animation. All properties + * are optional. + * + */ +export type AnimationOptions = { + /** + * The animation's duration, measured in milliseconds. + */ + duration?: number; + /** + * A function taking a time in the range 0..1 and returning a number where 0 is + * the initial state and 1 is the final state. + */ + easing?: (_: number) => number; + /** + * of the target center relative to real map container center at the end of animation. + */ + offset?: PointLike; + /** + * If `false`, no animation will occur. + */ + animate?: boolean; + /** + * If `true`, then the animation is considered essential and will not be affected by + * [`prefers-reduced-motion`](https://developer.mozilla.org/en-US/docs/Web/CSS/\@media/prefers-reduced-motion). + */ + essential?: boolean; +}; + +/** + * A callback hook that allows manipulating the camera and being notified about camera updates before they happen + */ +export type CameraUpdateTransformFunction = (next: { + center: LngLat; + zoom: number; + pitch: number; + bearing: number; + elevation: number; +}) => { + center?: LngLat; + zoom?: number; + pitch?: number; + bearing?: number; + elevation?: number; +}; + +export abstract class Camera extends Evented { + transform: Transform; + handlers: HandlerManager; + + _moving: boolean; + _zooming: boolean; + _rotating: boolean; + _pitching: boolean; + _padding: boolean; + + _bearingSnap: number; + _easeStart: number; + _easeOptions: { + duration?: number; + easing?: (_: number) => number; + }; + _easeId: string | void; + + _onEaseFrame: (_: number) => void; + _onEaseEnd: (easeId?: string) => void; + _easeFrameId: TaskID; + + /** + * @internal + * Used to track accumulated changes during continuous interaction + */ + _requestedCameraState?: Transform; + /** + * A callback used to defer camera updates or apply arbitrary constraints. + * If specified, this Camera instance can be used as a stateless component in React etc. + */ + transformCameraUpdate: CameraUpdateTransformFunction | null; + + abstract _requestRenderFrame(a: () => void): TaskID; + abstract _cancelRenderFrame(_: TaskID): void; + + constructor( + transform: Transform, + options: { + bearingSnap: number; + }, + ) { + super(); + this._moving = false; + this._zooming = false; + this.transform = transform; + this._bearingSnap = options.bearingSnap; + + this.on('moveend', () => { + delete this._requestedCameraState; + }); + } + + /** + * Returns the map's geographical centerpoint. + * + * @returns The map's geographical centerpoint. + * @example + * Return a LngLat object such as `{lng: 0, lat: 0}` + * ```ts + * let center = map.getCenter(); + * // access longitude and latitude values directly + * let {lng, lat} = map.getCenter(); + * ``` + */ + getCenter(): LngLat { + return new LngLat(this.transform.center.lng, this.transform.center.lat); + } + + /** + * Sets the map's geographical centerpoint. Equivalent to `jumpTo({center: center})`. + * + * Triggers the following events: `movestart` and `moveend`. + * + * @param center - The centerpoint to set. + * @param eventData - Additional properties to be added to event objects of events triggered by this method. + * @example + * ```ts + * map.setCenter([-74, 38]); + * ``` + */ + setCenter(center: LngLatLike, eventData?: any) { + return this.jumpTo({ center }, eventData); + } + + /** + * Pans the map by the specified offset. + * + * Triggers the following events: `movestart` and `moveend`. + * + * @param offset - `x` and `y` coordinates by which to pan the map. + * @param options - Options object + * @param eventData - Additional properties to be added to event objects of events triggered by this method. + * @see [Navigate the map with game-like controls](https://maplibre.org/maplibre-gl-js/docs/examples/game-controls/) + */ + panBy(offset: PointLike, options?: AnimationOptions, eventData?: any): this { + offset = Point.convert(offset).mult(-1); + return this.panTo(this.transform.center, extend({ offset }, options), eventData); + } + + /** + * Pans the map to the specified location with an animated transition. + * + * Triggers the following events: `movestart` and `moveend`. + * + * @param lnglat - The location to pan the map to. + * @param options - Options describing the destination and animation of the transition. + * @param eventData - Additional properties to be added to event objects of events triggered by this method. + * @example + * ```ts + * map.panTo([-74, 38]); + * // Specify that the panTo animation should last 5000 milliseconds. + * map.panTo([-74, 38], {duration: 5000}); + * ``` + * @see [Update a feature in realtime](https://maplibre.org/maplibre-gl-js/docs/examples/live-update-feature/) + */ + panTo(lnglat: LngLatLike, options?: AnimationOptions, eventData?: any): this { + return this.easeTo( + extend( + { + center: lnglat, + }, + options, + ), + eventData, + ); + } + + /** + * Returns the map's current zoom level. + * + * @returns The map's current zoom level. + * @example + * ```ts + * map.getZoom(); + * ``` + */ + getZoom(): number { + return this.transform.zoom; + } + + /** + * Sets the map's zoom level. Equivalent to `jumpTo({zoom: zoom})`. + * + * Triggers the following events: `movestart`, `move`, `moveend`, `zoomstart`, `zoom`, and `zoomend`. + * + * @param zoom - The zoom level to set (0-20). + * @param eventData - Additional properties to be added to event objects of events triggered by this method. + * @example + * Zoom to the zoom level 5 without an animated transition + * ```ts + * map.setZoom(5); + * ``` + */ + setZoom(zoom: number, eventData?: any): this { + this.jumpTo({ zoom }, eventData); + return this; + } + + /** + * Zooms the map to the specified zoom level, with an animated transition. + * + * Triggers the following events: `movestart`, `move`, `moveend`, `zoomstart`, `zoom`, and `zoomend`. + * + * @param zoom - The zoom level to transition to. + * @param options - Options object + * @param eventData - Additional properties to be added to event objects of events triggered by this method. + * @example + * ```ts + * // Zoom to the zoom level 5 without an animated transition + * map.zoomTo(5); + * // Zoom to the zoom level 8 with an animated transition + * map.zoomTo(8, { + * duration: 2000, + * offset: [100, 50] + * }); + * ``` + */ + zoomTo(zoom: number, options?: AnimationOptions | null, eventData?: any): this { + return this.easeTo( + extend( + { + zoom, + }, + options, + ), + eventData, + ); + } + + /** + * Increases the map's zoom level by 1. + * + * Triggers the following events: `movestart`, `move`, `moveend`, `zoomstart`, `zoom`, and `zoomend`. + * + * @param options - Options object + * @param eventData - Additional properties to be added to event objects of events triggered by this method. + * @example + * Zoom the map in one level with a custom animation duration + * ```ts + * map.zoomIn({duration: 1000}); + * ``` + */ + zoomIn(options?: AnimationOptions, eventData?: any): this { + this.zoomTo(this.getZoom() + 1, options, eventData); + return this; + } + + /** + * Decreases the map's zoom level by 1. + * + * Triggers the following events: `movestart`, `move`, `moveend`, `zoomstart`, `zoom`, and `zoomend`. + * + * @param options - Options object + * @param eventData - Additional properties to be added to event objects of events triggered by this method. + * @example + * Zoom the map out one level with a custom animation offset + * ```ts + * map.zoomOut({offset: [80, 60]}); + * ``` + */ + zoomOut(options?: AnimationOptions, eventData?: any): this { + this.zoomTo(this.getZoom() - 1, options, eventData); + return this; + } + + /** + * Returns the map's current bearing. The bearing is the compass direction that is "up"; for example, a bearing + * of 90° orients the map so that east is up. + * + * @returns The map's current bearing. + * @see [Navigate the map with game-like controls](https://maplibre.org/maplibre-gl-js/docs/examples/game-controls/) + */ + getBearing(): number { + return this.transform.bearing; + } + + /** + * Sets the map's bearing (rotation). The bearing is the compass direction that is "up"; for example, a bearing + * of 90° orients the map so that east is up. + * + * Equivalent to `jumpTo({bearing: bearing})`. + * + * Triggers the following events: `movestart`, `moveend`, and `rotate`. + * + * @param bearing - The desired bearing. + * @param eventData - Additional properties to be added to event objects of events triggered by this method. + * @example + * Rotate the map to 90 degrees + * ```ts + * map.setBearing(90); + * ``` + */ + setBearing(bearing: number, eventData?: any): this { + this.jumpTo({ bearing }, eventData); + return this; + } + + /** + * Returns the current padding applied around the map viewport. + * + * @returns The current padding around the map viewport. + */ + getPadding(): PaddingOptions { + return this.transform.padding; + } + + /** + * Sets the padding in pixels around the viewport. + * + * Equivalent to `jumpTo({padding: padding})`. + * + * Triggers the following events: `movestart` and `moveend`. + * + * @param padding - The desired padding. + * @param eventData - Additional properties to be added to event objects of events triggered by this method. + * @example + * Sets a left padding of 300px, and a top padding of 50px + * ```ts + * map.setPadding({ left: 300, top: 50 }); + * ``` + */ + setPadding(padding: PaddingOptions, eventData?: any): this { + this.jumpTo({ padding }, eventData); + return this; + } + + /** + * Rotates the map to the specified bearing, with an animated transition. The bearing is the compass direction + * that is "up"; for example, a bearing of 90° orients the map so that east is up. + * + * Triggers the following events: `movestart`, `moveend`, and `rotate`. + * + * @param bearing - The desired bearing. + * @param options - Options object + * @param eventData - Additional properties to be added to event objects of events triggered by this method. + */ + rotateTo(bearing: number, options?: AnimationOptions, eventData?: any): this { + return this.easeTo( + extend( + { + bearing, + }, + options, + ), + eventData, + ); + } + + /** + * Rotates the map so that north is up (0° bearing), with an animated transition. + * + * Triggers the following events: `movestart`, `moveend`, and `rotate`. + * + * @param options - Options object + * @param eventData - Additional properties to be added to event objects of events triggered by this method. + */ + resetNorth(options?: AnimationOptions, eventData?: any): this { + this.rotateTo(0, extend({ duration: 1000 }, options), eventData); + return this; + } + + /** + * Rotates and pitches the map so that north is up (0° bearing) and pitch is 0°, with an animated transition. + * + * Triggers the following events: `movestart`, `move`, `moveend`, `pitchstart`, `pitch`, `pitchend`, and `rotate`. + * + * @param options - Options object + * @param eventData - Additional properties to be added to event objects of events triggered by this method. + */ + resetNorthPitch(options?: AnimationOptions, eventData?: any): this { + this.easeTo( + extend( + { + bearing: 0, + pitch: 0, + duration: 1000, + }, + options, + ), + eventData, + ); + return this; + } + + /** + * Snaps the map so that north is up (0° bearing), if the current bearing is close enough to it (i.e. within the + * `bearingSnap` threshold). + * + * Triggers the following events: `movestart`, `moveend`, and `rotate`. + * + * @param options - Options object + * @param eventData - Additional properties to be added to event objects of events triggered by this method. + */ + snapToNorth(options?: AnimationOptions, eventData?: any): this { + if (Math.abs(this.getBearing()) < this._bearingSnap) { + return this.resetNorth(options, eventData); + } + return this; + } + + /** + * Returns the map's current pitch (tilt). + * + * @returns The map's current pitch, measured in degrees away from the plane of the screen. + */ + getPitch(): number { + return this.transform.pitch; + } + + /** + * Sets the map's pitch (tilt). Equivalent to `jumpTo({pitch: pitch})`. + * + * Triggers the following events: `movestart`, `moveend`, `pitchstart`, and `pitchend`. + * + * @param pitch - The pitch to set, measured in degrees away from the plane of the screen (0-60). + * @param eventData - Additional properties to be added to event objects of events triggered by this method. + */ + setPitch(pitch: number, eventData?: any): this { + this.jumpTo({ pitch }, eventData); + return this; + } + + /** + * @param bounds - Calculate the center for these bounds in the viewport and use + * the highest zoom level up to and including `Map#getMaxZoom()` that fits + * in the viewport. LngLatBounds represent a box that is always axis-aligned with bearing 0. + * @param options - Options object + * @returns If map is able to fit to provided bounds, returns `center`, `zoom`, and `bearing`. + * If map is unable to fit, method will warn and return undefined. + * @example + * ```ts + * let bbox = [[-79, 43], [-73, 45]]; + * let newCameraTransform = map.cameraForBounds(bbox, { + * padding: {top: 10, bottom:25, left: 15, right: 5} + * }); + * ``` + */ + cameraForBounds( + bounds: LngLatBoundsLike, + options?: CameraForBoundsOptions, + ): CenterZoomBearing | undefined { + bounds = LngLatBounds.convert(bounds); + const bearing = (options && options.bearing) || 0; + return this._cameraForBoxAndBearing( + bounds.getNorthWest(), + bounds.getSouthEast(), + bearing, + options, + ); + } + + /** + * @internal + * Calculate the center of these two points in the viewport and use + * the highest zoom level up to and including `Map#getMaxZoom()` that fits + * the points in the viewport at the specified bearing. + * @param p0 - First point + * @param p1 - Second point + * @param bearing - Desired map bearing at end of animation, in degrees + * @param options - the camera options + * @returns If map is able to fit to provided bounds, returns `center`, `zoom`, and `bearing`. + * If map is unable to fit, method will warn and return undefined. + * @example + * ```ts + * let p0 = [-79, 43]; + * let p1 = [-73, 45]; + * let bearing = 90; + * let newCameraTransform = map._cameraForBoxAndBearing(p0, p1, bearing, { + * padding: {top: 10, bottom:25, left: 15, right: 5} + * }); + * ``` + */ + _cameraForBoxAndBearing( + p0: LngLatLike, + p1: LngLatLike, + bearing: number, + options?: CameraForBoundsOptions, + ): CenterZoomBearing | undefined { + const defaultPadding = { + top: 0, + bottom: 0, + right: 0, + left: 0, + }; + options = extend( + { + padding: defaultPadding, + offset: [0, 0], + maxZoom: this.transform.maxZoom, + }, + options, + ); + + if (typeof options.padding === 'number') { + const p = options.padding; + options.padding = { + top: p, + bottom: p, + right: p, + left: p, + }; + } + + options.padding = extend(defaultPadding, options.padding) as PaddingOptions; + const tr = this.transform; + const edgePadding = tr.padding; + + // Consider all corners of the rotated bounding box derived from the given points + // when find the camera position that fits the given points. + const bounds = new LngLatBounds(p0, p1); + const nwWorld = tr.project(bounds.getNorthWest()); + const neWorld = tr.project(bounds.getNorthEast()); + const seWorld = tr.project(bounds.getSouthEast()); + const swWorld = tr.project(bounds.getSouthWest()); + + const bearingRadians = degreesToRadians(-bearing); + + const nwRotatedWorld = nwWorld.rotate(bearingRadians); + const neRotatedWorld = neWorld.rotate(bearingRadians); + const seRotatedWorld = seWorld.rotate(bearingRadians); + const swRotatedWorld = swWorld.rotate(bearingRadians); + + const upperRight = new Point( + Math.max(nwRotatedWorld.x, neRotatedWorld.x, swRotatedWorld.x, seRotatedWorld.x), + Math.max(nwRotatedWorld.y, neRotatedWorld.y, swRotatedWorld.y, seRotatedWorld.y), + ); + + const lowerLeft = new Point( + Math.min(nwRotatedWorld.x, neRotatedWorld.x, swRotatedWorld.x, seRotatedWorld.x), + Math.min(nwRotatedWorld.y, neRotatedWorld.y, swRotatedWorld.y, seRotatedWorld.y), + ); + + // Calculate zoom: consider the original bbox and padding. + const size = upperRight.sub(lowerLeft); + const scaleX = + (tr.width - + (edgePadding.left + edgePadding.right + options.padding.left + options.padding.right)) / + size.x; + const scaleY = + (tr.height - + (edgePadding.top + edgePadding.bottom + options.padding.top + options.padding.bottom)) / + size.y; + + if (scaleY < 0 || scaleX < 0) { + warnOnce('Map cannot fit within canvas with the given bounds, padding, and/or offset.'); + return undefined; + } + + const zoom = Math.min(tr.scaleZoom(tr.scale * Math.min(scaleX, scaleY)), options.maxZoom); + + // Calculate center: apply the zoom, the configured offset, as well as offset that exists as a result of padding. + const offset = Point.convert(options.offset); + const paddingOffsetX = (options.padding.left - options.padding.right) / 2; + const paddingOffsetY = (options.padding.top - options.padding.bottom) / 2; + const paddingOffset = new Point(paddingOffsetX, paddingOffsetY); + const rotatedPaddingOffset = paddingOffset.rotate(degreesToRadians(bearing)); + const offsetAtInitialZoom = offset.add(rotatedPaddingOffset); + const offsetAtFinalZoom = offsetAtInitialZoom.mult(tr.scale / tr.zoomScale(zoom)); + + const center = tr.unproject( + // either world diagonal can be used (NW-SE or NE-SW) + nwWorld.add(seWorld).div(2).sub(offsetAtFinalZoom), + ); + + return { + center, + zoom, + bearing, + }; + } + + /** + * Pans and zooms the map to contain its visible area within the specified geographical bounds. + * This function will also reset the map's bearing to 0 if bearing is nonzero. + * + * Triggers the following events: `movestart` and `moveend`. + * + * @param bounds - Center these bounds in the viewport and use the highest + * zoom level up to and including `Map#getMaxZoom()` that fits them in the viewport. + * @param options - Options supports all properties from {@link AnimationOptions} and {@link CameraOptions} in addition to the fields below. + * @param eventData - Additional properties to be added to event objects of events triggered by this method. + * @example + * ```ts + * let bbox = [[-79, 43], [-73, 45]]; + * map.fitBounds(bbox, { + * padding: {top: 10, bottom:25, left: 15, right: 5} + * }); + * ``` + * @see [Fit a map to a bounding box](https://maplibre.org/maplibre-gl-js/docs/examples/fitbounds/) + */ + fitBounds(bounds: LngLatBoundsLike, options?: FitBoundsOptions, eventData?: any): this { + return this._fitInternal(this.cameraForBounds(bounds, options), options, eventData); + } + + /** + * Pans, rotates and zooms the map to to fit the box made by points p0 and p1 + * once the map is rotated to the specified bearing. To zoom without rotating, + * pass in the current map bearing. + * + * Triggers the following events: `movestart`, `move`, `moveend`, `zoomstart`, `zoom`, `zoomend` and `rotate`. + * + * @param p0 - First point on screen, in pixel coordinates + * @param p1 - Second point on screen, in pixel coordinates + * @param bearing - Desired map bearing at end of animation, in degrees + * @param options - Options object + * @param eventData - Additional properties to be added to event objects of events triggered by this method. + * @example + * ```ts + * let p0 = [220, 400]; + * let p1 = [500, 900]; + * map.fitScreenCoordinates(p0, p1, map.getBearing(), { + * padding: {top: 10, bottom:25, left: 15, right: 5} + * }); + * ``` + * @see Used by {@link BoxZoomHandler} + */ + fitScreenCoordinates( + p0: PointLike, + p1: PointLike, + bearing: number, + options?: FitBoundsOptions, + eventData?: any, + ): this { + return this._fitInternal( + this._cameraForBoxAndBearing( + this.transform.pointLocation(Point.convert(p0)), + this.transform.pointLocation(Point.convert(p1)), + bearing, + options, + ), + options, + eventData, + ); + } + + _fitInternal( + calculatedOptions?: CenterZoomBearing, + options?: FitBoundsOptions, + eventData?: any, + ): this { + // cameraForBounds warns + returns undefined if unable to fit: + if (!calculatedOptions) return this; + + options = extend(calculatedOptions, options); + // Explicitly remove the padding field because, calculatedOptions already accounts for padding by setting zoom and center accordingly. + delete options.padding; + + return options.linear ? this.easeTo(options, eventData) : this.flyTo(options, eventData); + } + + /** + * Changes any combination of center, zoom, bearing, and pitch, without + * an animated transition. The map will retain its current values for any + * details not specified in `options`. + * + * Triggers the following events: `movestart`, `move`, `moveend`, `zoomstart`, `zoom`, `zoomend`, `pitchstart`, + * `pitch`, `pitchend`, and `rotate`. + * + * @param options - Options object + * @param eventData - Additional properties to be added to event objects of events triggered by this method. + * @example + * ```ts + * // jump to coordinates at current zoom + * map.jumpTo({center: [0, 0]}); + * // jump with zoom, pitch, and bearing options + * map.jumpTo({ + * center: [0, 0], + * zoom: 8, + * pitch: 45, + * bearing: 90 + * }); + * ``` + * @see [Jump to a series of locations](https://maplibre.org/maplibre-gl-js/docs/examples/jump-to/) + * @see [Update a feature in realtime](https://maplibre.org/maplibre-gl-js/docs/examples/live-update-feature/) + */ + jumpTo(options: JumpToOptions, eventData?: any): this { + this.stop(); + + const tr = this._getTransformForUpdate(); + let zoomChanged = false, + bearingChanged = false, + pitchChanged = false; + + if ('zoom' in options && tr.zoom !== +options.zoom) { + zoomChanged = true; + tr.zoom = +options.zoom; + } + + if (options.center !== undefined) { + tr.center = LngLat.convert(options.center); + } + + if ('bearing' in options && tr.bearing !== +options.bearing) { + bearingChanged = true; + tr.bearing = +options.bearing; + } + + if ('pitch' in options && tr.pitch !== +options.pitch) { + pitchChanged = true; + tr.pitch = +options.pitch; + } + + if (options.padding != null && !tr.isPaddingEqual(options.padding)) { + tr.padding = options.padding; + } + this._applyUpdatedTransform(tr); + + this.fire(new Event('movestart', eventData)).fire(new Event('move', eventData)); + + if (zoomChanged) { + this.fire(new Event('zoomstart', eventData)) + .fire(new Event('zoom', eventData)) + .fire(new Event('zoomend', eventData)); + } + + if (bearingChanged) { + this.fire(new Event('rotatestart', eventData)) + .fire(new Event('rotate', eventData)) + .fire(new Event('rotateend', eventData)); + } + + if (pitchChanged) { + this.fire(new Event('pitchstart', eventData)) + .fire(new Event('pitch', eventData)) + .fire(new Event('pitchend', eventData)); + } + + return this.fire(new Event('moveend', eventData)); + } + + /** + * Calculates pitch, zoom and bearing for looking at `newCenter` with the camera position being `newCenter` + * and returns them as {@link CameraOptions}. + * @param from - The camera to look from + * @param altitudeFrom - The altitude of the camera to look from + * @param to - The center to look at + * @param altitudeTo - Optional altitude of the center to look at. If none given the ground height will be used. + * @returns the calculated camera options + */ + calculateCameraOptionsFromTo( + from: LngLat, + altitudeFrom: number, + to: LngLat, + altitudeTo: number = 0, + ): CameraOptions { + const fromMerc = MercatorCoordinate.fromLngLat(from, altitudeFrom); + const toMerc = MercatorCoordinate.fromLngLat(to, altitudeTo); + const dx = toMerc.x - fromMerc.x; + const dy = toMerc.y - fromMerc.y; + const dz = toMerc.z - fromMerc.z; + + const distance3D = Math.hypot(dx, dy, dz); + if (distance3D === 0) throw new Error("Can't calculate camera options with same From and To"); + + const groundDistance = Math.hypot(dx, dy); + + const zoom = this.transform.scaleZoom( + this.transform.cameraToCenterDistance / distance3D / this.transform.tileSize, + ); + const bearing = (Math.atan2(dx, -dy) * 180) / Math.PI; + let pitch = (Math.acos(groundDistance / distance3D) * 180) / Math.PI; + pitch = dz < 0 ? 90 - pitch : 90 + pitch; + + return { + center: toMerc.toLngLat(), + zoom, + pitch, + bearing, + }; + } + + /** + * Changes any combination of `center`, `zoom`, `bearing`, `pitch`, and `padding` with an animated transition + * between old and new values. The map will retain its current values for any + * details not specified in `options`. + * + * Note: The transition will happen instantly if the user has enabled + * the `reduced motion` accessibility feature enabled in their operating system, + * unless `options` includes `essential: true`. + * + * Triggers the following events: `movestart`, `move`, `moveend`, `zoomstart`, `zoom`, `zoomend`, `pitchstart`, + * `pitch`, `pitchend`, and `rotate`. + * + * @param options - Options describing the destination and animation of the transition. + * Accepts {@link CameraOptions} and {@link AnimationOptions}. + * @param eventData - Additional properties to be added to event objects of events triggered by this method. + * @see [Navigate the map with game-like controls](https://maplibre.org/maplibre-gl-js/docs/examples/game-controls/) + */ + easeTo( + options: EaseToOptions & { + easeId?: string; + noMoveStart?: boolean; + }, + eventData?: any, + ): this { + this._stop(false, options.easeId); + + options = extend( + { + offset: [0, 0], + duration: 500, + easing: defaultEasing, + }, + options, + ); + + if (options.animate === false || (!options.essential && browser.prefersReducedMotion)) + options.duration = 0; + + const tr = this._getTransformForUpdate(), + startZoom = this.getZoom(), + startBearing = this.getBearing(), + startPitch = this.getPitch(), + startPadding = this.getPadding(), + bearing = + 'bearing' in options ? this._normalizeBearing(options.bearing, startBearing) : startBearing, + pitch = 'pitch' in options ? +options.pitch : startPitch, + padding = 'padding' in options ? options.padding : tr.padding; + + const offsetAsPoint = Point.convert(options.offset); + let pointAtOffset = tr.centerPoint.add(offsetAsPoint); + const locationAtOffset = tr.pointLocation(pointAtOffset); + + const { center, zoom } = tr.getConstrained( + LngLat.convert(options.center || locationAtOffset), + options.zoom ?? startZoom, + ); + this._normalizeCenter(center); + + const from = tr.project(locationAtOffset); + const delta = tr.project(center).sub(from); + const finalScale = tr.zoomScale(zoom - startZoom); + + let around, aroundPoint; + + if (options.around) { + around = LngLat.convert(options.around); + aroundPoint = tr.locationPoint(around); + } + + const currently = { + moving: this._moving, + zooming: this._zooming, + rotating: this._rotating, + pitching: this._pitching, + }; + + this._zooming = this._zooming || zoom !== startZoom; + this._rotating = this._rotating || startBearing !== bearing; + this._pitching = this._pitching || pitch !== startPitch; + this._padding = !tr.isPaddingEqual(padding as PaddingOptions); + + this._easeId = options.easeId; + this._prepareEase(eventData, options.noMoveStart, currently); + + this._ease( + (k) => { + if (this._zooming) { + tr.zoom = interpolates.number(startZoom, zoom, k); + } + if (this._rotating) { + tr.bearing = interpolates.number(startBearing, bearing, k); + } + if (this._pitching) { + tr.pitch = interpolates.number(startPitch, pitch, k); + } + if (this._padding) { + tr.interpolatePadding(startPadding, padding as PaddingOptions, k); + // When padding is being applied, Transform#centerPoint is changing continuously, + // thus we need to recalculate offsetPoint every frame + pointAtOffset = tr.centerPoint.add(offsetAsPoint); + } + + if (around) { + tr.setLocationAtPoint(around, aroundPoint); + } else { + const scale = tr.zoomScale(tr.zoom - startZoom); + const base = zoom > startZoom ? Math.min(2, finalScale) : Math.max(0.5, finalScale); + const speedup = Math.pow(base, 1 - k); + const newCenter = tr.unproject(from.add(delta.mult(k * speedup)).mult(scale)); + tr.setLocationAtPoint(tr.renderWorldCopies ? newCenter.wrap() : newCenter, pointAtOffset); + } + + this._applyUpdatedTransform(tr); + + this._fireMoveEvents(eventData); + }, + (interruptingEaseId?: string) => { + this._afterEase(eventData, interruptingEaseId); + }, + options as any, + ); + + return this; + } + + _prepareEase(eventData: any, noMoveStart: boolean, currently: any = {}) { + this._moving = true; + if (!noMoveStart && !currently.moving) { + this.fire(new Event('movestart', eventData)); + } + if (this._zooming && !currently.zooming) { + this.fire(new Event('zoomstart', eventData)); + } + if (this._rotating && !currently.rotating) { + this.fire(new Event('rotatestart', eventData)); + } + if (this._pitching && !currently.pitching) { + this.fire(new Event('pitchstart', eventData)); + } + } + + /** + * @internal + * Called when the camera is about to be manipulated. + * If `transformCameraUpdate` is specified, a copy of the current transform is created to track the accumulated changes. + * This underlying transform represents the "desired state" proposed by input handlers / animations / UI controls. + * It may differ from the state used for rendering (`this.transform`). + * @returns Transform to apply changes to + */ + _getTransformForUpdate(): Transform { + if (!this.transformCameraUpdate) return this.transform; + + if (!this._requestedCameraState) { + this._requestedCameraState = this.transform.clone(); + } + return this._requestedCameraState; + } + + /** + * @internal + * Called after the camera is done being manipulated. + * @param tr - the requested camera end state + * Call `transformCameraUpdate` if present, and then apply the "approved" changes. + */ + _applyUpdatedTransform(tr: Transform) { + if (!this.transformCameraUpdate) return; + + const nextTransform = tr.clone(); + const { center, zoom, pitch, bearing, elevation } = this.transformCameraUpdate(nextTransform); + if (center) nextTransform.center = center; + if (zoom !== undefined) nextTransform.zoom = zoom; + if (pitch !== undefined) nextTransform.pitch = pitch; + if (bearing !== undefined) nextTransform.bearing = bearing; + if (elevation !== undefined) nextTransform.elevation = elevation; + this.transform.apply(nextTransform); + } + + _fireMoveEvents(eventData?: any) { + this.fire(new Event('move', eventData)); + if (this._zooming) { + this.fire(new Event('zoom', eventData)); + } + if (this._rotating) { + this.fire(new Event('rotate', eventData)); + } + if (this._pitching) { + this.fire(new Event('pitch', eventData)); + } + } + + _afterEase(eventData?: any, easeId?: string) { + // if this easing is being stopped to start another easing with + // the same id then don't fire any events to avoid extra start/stop events + if (this._easeId && easeId && this._easeId === easeId) { + return; + } + delete this._easeId; + + const wasZooming = this._zooming; + const wasRotating = this._rotating; + const wasPitching = this._pitching; + this._moving = false; + this._zooming = false; + this._rotating = false; + this._pitching = false; + this._padding = false; + + if (wasZooming) { + this.fire(new Event('zoomend', eventData)); + } + if (wasRotating) { + this.fire(new Event('rotateend', eventData)); + } + if (wasPitching) { + this.fire(new Event('pitchend', eventData)); + } + this.fire(new Event('moveend', eventData)); + } + + /** + * Changes any combination of center, zoom, bearing, and pitch, animating the transition along a curve that + * evokes flight. The animation seamlessly incorporates zooming and panning to help + * the user maintain her bearings even after traversing a great distance. + * + * Note: The animation will be skipped, and this will behave equivalently to `jumpTo` + * if the user has the `reduced motion` accessibility feature enabled in their operating system, + * unless 'options' includes `essential: true`. + * + * Triggers the following events: `movestart`, `move`, `moveend`, `zoomstart`, `zoom`, `zoomend`, `pitchstart`, + * `pitch`, `pitchend`, and `rotate`. + * + * @param options - Options describing the destination and animation of the transition. + * Accepts {@link CameraOptions}, {@link AnimationOptions}, + * and the following additional options. + * @param eventData - Additional properties to be added to event objects of events triggered by this method. + * @example + * ```ts + * // fly with default options to null island + * map.flyTo({center: [0, 0], zoom: 9}); + * // using flyTo options + * map.flyTo({ + * center: [0, 0], + * zoom: 9, + * speed: 0.2, + * curve: 1, + * easing(t) { + * return t; + * } + * }); + * ``` + * @see [Fly to a location](https://maplibre.org/maplibre-gl-js/docs/examples/flyto/) + * @see [Slowly fly to a location](https://maplibre.org/maplibre-gl-js/docs/examples/flyto-options/) + * @see [Fly to a location based on scroll position](https://maplibre.org/maplibre-gl-js/docs/examples/scroll-fly-to/) + */ + flyTo(options: FlyToOptions, eventData?: any): this { + // Fall through to jumpTo if user has set prefers-reduced-motion + if (!options.essential && browser.prefersReducedMotion) { + const coercedOptions = pick(options, [ + 'center', + 'zoom', + 'bearing', + 'pitch', + 'around', + ]) as CameraOptions; + return this.jumpTo(coercedOptions, eventData); + } + + // This method implements an “optimal path” animation, as detailed in: + // + // Van Wijk, Jarke J.; Nuij, Wim A. A. “Smooth and efficient zooming and panning.” INFOVIS + // ’03. pp. 15–22. . + // + // Where applicable, local variable documentation begins with the associated variable or + // function in van Wijk (2003). + + this.stop(); + + options = extend( + { + offset: [0, 0], + speed: 1.2, + curve: 1.42, + easing: defaultEasing, + }, + options, + ); + + const tr = this._getTransformForUpdate(), + startZoom = this.getZoom(), + startBearing = this.getBearing(), + startPitch = this.getPitch(), + startPadding = this.getPadding(); + + const bearing = + 'bearing' in options ? this._normalizeBearing(options.bearing, startBearing) : startBearing; + const pitch = 'pitch' in options ? +options.pitch : startPitch; + const padding = 'padding' in options ? options.padding : tr.padding; + + const offsetAsPoint = Point.convert(options.offset); + let pointAtOffset = tr.centerPoint.add(offsetAsPoint); + const locationAtOffset = tr.pointLocation(pointAtOffset); + + const { center, zoom } = tr.getConstrained( + LngLat.convert(options.center || locationAtOffset), + options.zoom ?? startZoom, + ); + this._normalizeCenter(center); + const scale = tr.zoomScale(zoom - startZoom); + + const from = tr.project(locationAtOffset); + const delta = tr.project(center).sub(from); + + let rho = options.curve; + + // w₀: Initial visible span, measured in pixels at the initial scale. + const w0 = Math.max(tr.width, tr.height), + // w₁: Final visible span, measured in pixels with respect to the initial scale. + w1 = w0 / scale, + // Length of the flight path as projected onto the ground plane, measured in pixels from + // the world image origin at the initial scale. + u1 = delta.mag(); + + if ('minZoom' in options) { + const minZoom = clamp(Math.min(options.minZoom, startZoom, zoom), tr.minZoom, tr.maxZoom); + // wm: Maximum visible span, measured in pixels with respect to the initial + // scale. + const wMax = w0 / tr.zoomScale(minZoom - startZoom); + rho = Math.sqrt((wMax / u1) * 2); + } + + // ρ² + const rho2 = rho * rho; + + /** + * rᵢ: Returns the zoom-out factor at one end of the animation. + * + * @param descent - `true` for the descent, `false` for the ascent + */ + function zoomOutFactor(descent: boolean) { + const b = + (w1 * w1 - w0 * w0 + (descent ? -1 : 1) * rho2 * rho2 * u1 * u1) / + (2 * (descent ? w1 : w0) * rho2 * u1); + return Math.log(Math.sqrt(b * b + 1) - b); + } + + function sinh(n) { + return (Math.exp(n) - Math.exp(-n)) / 2; + } + function cosh(n) { + return (Math.exp(n) + Math.exp(-n)) / 2; + } + function tanh(n) { + return sinh(n) / cosh(n); + } + + // r₀: Zoom-out factor during ascent. + const r0 = zoomOutFactor(false); + + // w(s): Returns the visible span on the ground, measured in pixels with respect to the + // initial scale. Assumes an angular field of view of 2 arctan ½ ≈ 53°. + let w: (_: number) => number = function (s) { + return cosh(r0) / cosh(r0 + rho * s); + }; + + // u(s): Returns the distance along the flight path as projected onto the ground plane, + // measured in pixels from the world image origin at the initial scale. + let u: (_: number) => number = function (s) { + return (w0 * ((cosh(r0) * tanh(r0 + rho * s) - sinh(r0)) / rho2)) / u1; + }; + + // S: Total length of the flight path, measured in ρ-screenfuls. + let S = (zoomOutFactor(true) - r0) / rho; + + // When u₀ = u₁, the optimal path doesn’t require both ascent and descent. + if (Math.abs(u1) < 0.000001 || !isFinite(S)) { + // Perform a more or less instantaneous transition if the path is too short. + if (Math.abs(w0 - w1) < 0.000001) return this.easeTo(options, eventData); + + const k = w1 < w0 ? -1 : 1; + S = Math.abs(Math.log(w1 / w0)) / rho; + + u = () => 0; + w = (s) => Math.exp(k * rho * s); + } + + if ('duration' in options) { + options.duration = +options.duration; + } else { + const V = 'screenSpeed' in options ? +options.screenSpeed / rho : +options.speed; + options.duration = (1000 * S) / V; + } + + if (options.maxDuration && options.duration > options.maxDuration) { + options.duration = 0; + } + + this._zooming = true; + this._rotating = startBearing !== bearing; + this._pitching = pitch !== startPitch; + this._padding = !tr.isPaddingEqual(padding as PaddingOptions); + + this._prepareEase(eventData, false); + + this._ease( + (k) => { + // s: The distance traveled along the flight path, measured in ρ-screenfuls. + const s = k * S; + const scale = 1 / w(s); + tr.zoom = k === 1 ? zoom : startZoom + tr.scaleZoom(scale); + + if (this._rotating) { + tr.bearing = interpolates.number(startBearing, bearing, k); + } + if (this._pitching) { + tr.pitch = interpolates.number(startPitch, pitch, k); + } + if (this._padding) { + tr.interpolatePadding(startPadding, padding as PaddingOptions, k); + // When padding is being applied, Transform#centerPoint is changing continuously, + // thus we need to recalculate offsetPoint every frame + pointAtOffset = tr.centerPoint.add(offsetAsPoint); + } + + const newCenter = k === 1 ? center : tr.unproject(from.add(delta.mult(u(s))).mult(scale)); + tr.setLocationAtPoint(tr.renderWorldCopies ? newCenter.wrap() : newCenter, pointAtOffset); + + this._applyUpdatedTransform(tr); + + this._fireMoveEvents(eventData); + }, + () => { + this._afterEase(eventData); + }, + options, + ); + + return this; + } + + isEasing() { + return !!this._easeFrameId; + } + + /** + * Stops any animated transition underway. + */ + stop(): this { + return this._stop(); + } + + _stop(allowGestures?: boolean, easeId?: string): this { + if (this._easeFrameId) { + this._cancelRenderFrame(this._easeFrameId); + delete this._easeFrameId; + delete this._onEaseFrame; + } + + if (this._onEaseEnd) { + // The _onEaseEnd function might emit events which trigger new + // animation, which sets a new _onEaseEnd. Ensure we don't delete + // it unintentionally. + const onEaseEnd = this._onEaseEnd; + delete this._onEaseEnd; + onEaseEnd.call(this, easeId); + } + if (!allowGestures) { + this.handlers?.stop(false); + } + return this; + } + + _ease( + frame: (_: number) => void, + finish: () => void, + options: { + animate?: boolean; + duration?: number; + easing?: (_: number) => number; + }, + ) { + if (options.animate === false || options.duration === 0) { + frame(1); + finish(); + } else { + this._easeStart = browser.now(); + this._easeOptions = options; + this._onEaseFrame = frame; + this._onEaseEnd = finish; + this._easeFrameId = this._requestRenderFrame(this._renderFrameCallback); + } + } + + // Callback for map._requestRenderFrame + _renderFrameCallback = () => { + const t = Math.min((browser.now() - this._easeStart) / this._easeOptions.duration, 1); + this._onEaseFrame(this._easeOptions.easing(t)); + + // if _stop is called during _onEaseFrame from _fireMoveEvents we should avoid a new _requestRenderFrame, checking it by ensuring _easeFrameId was not deleted + if (t < 1 && this._easeFrameId) { + this._easeFrameId = this._requestRenderFrame(this._renderFrameCallback); + } else { + this.stop(); + } + }; + + // convert bearing so that it's numerically close to the current one so that it interpolates properly + _normalizeBearing(bearing: number, currentBearing: number) { + bearing = wrap(bearing, -180, 180); + const diff = Math.abs(bearing - currentBearing); + if (Math.abs(bearing - 360 - currentBearing) < diff) bearing -= 360; + if (Math.abs(bearing + 360 - currentBearing) < diff) bearing += 360; + return bearing; + } + + // If a path crossing the antimeridian would be shorter, extend the final coordinate so that + // interpolating between the two endpoints will cross it. + _normalizeCenter(center: LngLat) { + const tr = this.transform; + if (!tr.renderWorldCopies || tr.lngRange) return; + + const delta = center.lng - tr.center.lng; + center.lng += delta > 180 ? -360 : delta < -180 ? 360 : 0; + } +} diff --git a/packages/map/src/map/css/l7.css b/packages/map/src/map/css/l7.css new file mode 100644 index 00000000000..4a6d99c3970 --- /dev/null +++ b/packages/map/src/map/css/l7.css @@ -0,0 +1,163 @@ +.l7-map { + font: + 12px/20px 'Helvetica Neue', + Arial, + Helvetica, + sans-serif; + overflow: hidden; + position: relative; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); +} + +.l7-canvas { + position: absolute; + left: 0; + top: 0; +} + +.l7-map:-webkit-full-screen { + width: 100%; + height: 100%; +} + +.l7-canary { + background-color: salmon; +} + +.l7-canvas-container.l7-interactive, +.l7-ctrl-group button.l7-ctrl-compass { + cursor: grab; + user-select: none; +} + +.l7-canvas-container.l7-interactive.l7-track-pointer { + cursor: pointer; +} + +.l7-canvas-container.l7-interactive:active, +.l7-ctrl-group button.l7-ctrl-compass:active { + cursor: grabbing; +} + +.l7-canvas-container.l7-touch-zoom-rotate, +.l7-canvas-container.l7-touch-zoom-rotate .l7-canvas { + touch-action: pan-x pan-y; +} + +.l7-canvas-container.l7-touch-drag-pan, +.l7-canvas-container.l7-touch-drag-pan .l7-canvas { + touch-action: pinch-zoom; +} + +.l7-canvas-container.l7-touch-zoom-rotate.l7-touch-drag-pan, +.l7-canvas-container.l7-touch-zoom-rotate.l7-touch-drag-pan .l7-canvas { + touch-action: none; +} + +.l7-canvas-container.l7-touch-drag-pan.l7-cooperative-gestures, +.l7-canvas-container.l7-touch-drag-pan.l7-cooperative-gestures .l7-canvas { + touch-action: pan-x pan-y; +} + +.l7-cooperative-gesture-screen { + background: rgba(0 0 0 / 40%); + position: absolute; + inset: 0; + display: flex; + justify-content: center; + align-items: center; + color: white; + padding: 1rem; + font-size: 1.4em; + line-height: 1.2; + opacity: 0; + pointer-events: none; + transition: opacity 1s ease 1s; + z-index: 99999; +} + +.l7-cooperative-gesture-screen.l7-show { + opacity: 1; + transition: opacity 0.05s; +} + +.l7-cooperative-gesture-screen .l7-mobile-message { + display: none; +} + +@media (hover: none), (width <= 480px) { + .l7-cooperative-gesture-screen .l7-desktop-message { + display: none; + } + + .l7-cooperative-gesture-screen .l7-mobile-message { + display: block; + } +} + +.l7-ctrl-top-left, +.l7-ctrl-top-right, +.l7-ctrl-bottom-left, +.l7-ctrl-bottom-right { + position: absolute; + pointer-events: none; + z-index: 2; +} +.l7-ctrl-top-left { + top: 0; + left: 0; +} +.l7-ctrl-top-right { + top: 0; + right: 0; +} +.l7-ctrl-bottom-left { + bottom: 0; + left: 0; +} +.l7-ctrl-bottom-right { + right: 0; + bottom: 0; +} + +.l7-ctrl { + clear: both; + pointer-events: auto; + + /* workaround for a Safari bug https://github.com/mapbox/mapbox-gl-js/issues/8185 */ + transform: translate(0, 0); +} +.l7-ctrl-top-left .l7-ctrl { + margin: 10px 0 0 10px; + float: left; +} +.l7-ctrl-top-right .l7-ctrl { + margin: 10px 10px 0 0; + float: right; +} +.l7-ctrl-bottom-left .l7-ctrl { + margin: 0 0 10px 10px; + float: left; +} +.l7-ctrl-bottom-right .l7-ctrl { + margin: 0 10px 10px 0; + float: right; +} + +.l7-crosshair, +.l7-crosshair .l7-interactive, +.l7-crosshair .l7-interactive:active { + cursor: crosshair; +} + +.l7-boxzoom { + position: absolute; + top: 0; + left: 0; + width: 0; + height: 0; + background: #fff; + border: 2px dotted #202020; + opacity: 0.5; + z-index: 10; +} diff --git a/packages/map/src/map/events.ts b/packages/map/src/map/events.ts new file mode 100644 index 00000000000..8502ca679cd --- /dev/null +++ b/packages/map/src/map/events.ts @@ -0,0 +1,469 @@ +import { Event } from './util/evented'; + +import Point from '@mapbox/point-geometry'; +import { DOM } from './util/dom'; + +import type { LngLat } from './geo/lng_lat'; +import type { Map } from './map'; + +/** + * `MapEventType` - a mapping between the event name and the event value. + * These events are used with the {@link Map#on} method. + * When using a `layerId` with {@link Map#on} method, please refer to {@link MapLayerEventType}. + * The following example can be used for all the events. + * + * @group Event Related + * @example + * ```ts + * // Initialize the map + * let map = new Map({ // map options }); + * // Set an event listener + * map.on('the-event-name', () => { + * console.log('An event has occurred!'); + * }); + * ``` + */ +export type MapEventType = { + /** + * Fired when an error occurs. This is GL JS's primary error reporting + * mechanism. We use an event instead of `throw` to better accommodate + * asynchronous operations. If no listeners are bound to the `error` event, the + * error will be printed to the console. + */ + error: ErrorEvent; + /** + * Fired after the last frame rendered before the map enters an + * "idle" state: + * + * - No camera transitions are in progress + * - All currently requested tiles have loaded + * - All fade/transition animations have completed + */ + idle: MapLibreEvent; + /** + * Fired immediately after the map has been removed with {@link Map#remove}. + */ + remove: MapLibreEvent; + /** + * Fired immediately after the map has been resized. + */ + resize: MapLibreEvent; + /** + * Fired when the user cancels a "box zoom" interaction, or when the bounding box does not meet the minimum size threshold. + * See {@link BoxZoomHandler}. + */ + boxzoomcancel: MapLibreZoomEvent; + /** + * Fired when a "box zoom" interaction starts. See {@link BoxZoomHandler}. + */ + boxzoomstart: MapLibreZoomEvent; + /** + * Fired when a "box zoom" interaction ends. See {@link BoxZoomHandler}. + */ + boxzoomend: MapLibreZoomEvent; + /** + * Fired when a [`touchcancel`](https://developer.mozilla.org/en-US/docs/Web/Events/touchcancel) event occurs within the map. + */ + touchcancel: MapTouchEvent; + /** + * Fired when a [`touchmove`](https://developer.mozilla.org/en-US/docs/Web/Events/touchmove) event occurs within the map. + * @see [Create a draggable point](https://maplibre.org/maplibre-gl-js/docs/examples/drag-a-point/) + */ + touchmove: MapTouchEvent; + /** + * Fired when a [`touchend`](https://developer.mozilla.org/en-US/docs/Web/Events/touchend) event occurs within the map. + * @see [Create a draggable point](https://maplibre.org/maplibre-gl-js/docs/examples/drag-a-point/) + */ + touchend: MapTouchEvent; + /** + * Fired when a [`touchstart`](https://developer.mozilla.org/en-US/docs/Web/Events/touchstart) event occurs within the map. + * @see [Create a draggable point](https://maplibre.org/maplibre-gl-js/docs/examples/drag-a-point/) + */ + touchstart: MapTouchEvent; + /** + * Fired when a pointing device (usually a mouse) is pressed and released at the same point on the map. + * + * @see [Measure distances](https://maplibre.org/maplibre-gl-js/docs/examples/measure/) + * @see [Center the map on a clicked symbol](https://maplibre.org/maplibre-gl-js/docs/examples/center-on-symbol/) + */ + click: MapMouseEvent; + /** + * Fired when the right button of the mouse is clicked or the context menu key is pressed within the map. + */ + contextmenu: MapMouseEvent; + /** + * Fired when a pointing device (usually a mouse) is pressed and released twice at the same point on the map in rapid succession. + * + * **Note:** Under normal conditions, this event will be preceded by two `click` events. + */ + dblclick: MapMouseEvent; + /** + * Fired when a pointing device (usually a mouse) is moved while the cursor is inside the map. + * As you move the cursor across the map, the event will fire every time the cursor changes position within the map. + * + * @see [Get coordinates of the mouse pointer](https://maplibre.org/maplibre-gl-js/docs/examples/mouse-position/) + * @see [Highlight features under the mouse pointer](https://maplibre.org/maplibre-gl-js/docs/examples/hover-styles/) + * @see [Display a popup on over](https://maplibre.org/maplibre-gl-js/docs/examples/popup-on-hover/) + */ + mousemove: MapMouseEvent; + /** + * Fired when a pointing device (usually a mouse) is released within the map. + * + * @see [Create a draggable point](https://maplibre.org/maplibre-gl-js/docs/examples/drag-a-point/) + */ + mouseup: MapMouseEvent; + /** + * Fired when a pointing device (usually a mouse) is pressed within the map. + * + * @see [Create a draggable point](https://maplibre.org/maplibre-gl-js/docs/examples/drag-a-point/) + */ + mousedown: MapMouseEvent; + /** + * Fired when a point device (usually a mouse) leaves the map's canvas. + */ + mouseout: MapMouseEvent; + /** + * Fired when a pointing device (usually a mouse) is moved within the map. + * As you move the cursor across a web page containing a map, + * the event will fire each time it enters the map or any child elements. + * + * @see [Get coordinates of the mouse pointer](https://maplibre.org/maplibre-gl-js/docs/examples/mouse-position/) + * @see [Highlight features under the mouse pointer](https://maplibre.org/maplibre-gl-js/docs/examples/hover-styles/) + * @see [Display a popup on hover](https://maplibre.org/maplibre-gl-js/docs/examples/popup-on-hover/) + */ + mouseover: MapMouseEvent; + /** + * Fired just before the map begins a transition from one + * view to another, as the result of either user interaction or methods such as {@link Map#jumpTo}. + * + */ + movestart: MapLibreEvent; + /** + * Fired repeatedly during an animated transition from one view to + * another, as the result of either user interaction or methods such as {@link Map#flyTo}. + * + * @see [Display HTML clusters with custom properties](https://maplibre.org/maplibre-gl-js/docs/examples/cluster-html/) + */ + move: MapLibreEvent; + /** + * Fired just after the map completes a transition from one + * view to another, as the result of either user interaction or methods such as {@link Map#jumpTo}. + * + * @see [Display HTML clusters with custom properties](https://maplibre.org/maplibre-gl-js/docs/examples/cluster-html/) + */ + moveend: MapLibreEvent; + /** + * Fired just before the map begins a transition from one zoom level to another, + * as the result of either user interaction or methods such as {@link Map#flyTo}. + */ + zoomstart: MapLibreEvent; + /** + * Fired repeatedly during an animated transition from one zoom level to another, + * as the result of either user interaction or methods such as {@link Map#flyTo}. + */ + zoom: MapLibreEvent; + /** + * Fired just after the map completes a transition from one zoom level to another, + * as the result of either user interaction or methods such as {@link Map#flyTo}. + */ + zoomend: MapLibreEvent; + /** + * Fired when a "drag to rotate" interaction starts. See {@link DragRotateHandler}. + */ + rotatestart: MapLibreEvent; + /** + * Fired repeatedly during a "drag to rotate" interaction. See {@link DragRotateHandler}. + */ + rotate: MapLibreEvent; + /** + * Fired when a "drag to rotate" interaction ends. See {@link DragRotateHandler}. + */ + rotateend: MapLibreEvent; + /** + * Fired when a "drag to pan" interaction starts. See {@link DragPanHandler}. + */ + dragstart: MapLibreEvent; + /** + * Fired repeatedly during a "drag to pan" interaction. See {@link DragPanHandler}. + */ + drag: MapLibreEvent; + /** + * Fired when a "drag to pan" interaction ends. See {@link DragPanHandler}. + * @see [Create a draggable marker](https://maplibre.org/maplibre-gl-js/docs/examples/drag-a-marker/) + */ + dragend: MapLibreEvent; + /** + * Fired whenever the map's pitch (tilt) begins a change as + * the result of either user interaction or methods such as {@link Map#flyTo} . + */ + pitchstart: MapLibreEvent; + /** + * Fired repeatedly during the map's pitch (tilt) animation between + * one state and another as the result of either user interaction + * or methods such as {@link Map#flyTo}. + */ + pitch: MapLibreEvent; + /** + * Fired immediately after the map's pitch (tilt) finishes changing as + * the result of either user interaction or methods such as {@link Map#flyTo}. + */ + pitchend: MapLibreEvent; + /** + * Fired when a [`wheel`](https://developer.mozilla.org/en-US/docs/Web/Events/wheel) event occurs within the map. + */ + wheel: MapWheelEvent; +}; + +/** + * The base event for MapLibre + * + * @group Event Related + */ +export type MapLibreEvent = { + type: keyof MapEventType; + target: Map; + originalEvent: TOrig; +}; + +/** + * `MapMouseEvent` is the event type for mouse-related map events. + * + * @group Event Related + * + * @example + * ```ts + * // The `click` event is an example of a `MapMouseEvent`. + * // Set up an event listener on the map. + * map.on('click', (e) => { + * // The event object (e) contains information like the + * // coordinates of the point on the map that was clicked. + * console.log('A click event has occurred at ' + e.lngLat); + * }); + * ``` + */ +export class MapMouseEvent extends Event implements MapLibreEvent { + /** + * The event type + */ + public declare type: + | 'mousedown' + | 'mouseup' + | 'click' + | 'dblclick' + | 'mousemove' + | 'mouseover' + | 'mouseout' + | 'contextmenu'; + + /** + * The `Map` object that fired the event. + */ + target: Map; + + /** + * The DOM event which caused the map event. + */ + originalEvent: MouseEvent; + + /** + * The pixel coordinates of the mouse cursor, relative to the map and measured from the top left corner. + */ + point: Point; + + /** + * The geographic location on the map of the mouse cursor. + */ + lngLat: LngLat; + + /** + * Prevents subsequent default processing of the event by the map. + * + * Calling this method will prevent the following default map behaviors: + * + * * On `mousedown` events, the behavior of {@link DragPanHandler} + * * On `mousedown` events, the behavior of {@link DragRotateHandler} + * * On `mousedown` events, the behavior of {@link BoxZoomHandler} + * * On `dblclick` events, the behavior of {@link DoubleClickZoomHandler} + * + */ + preventDefault() { + this._defaultPrevented = true; + } + + /** + * `true` if `preventDefault` has been called. + */ + get defaultPrevented(): boolean { + return this._defaultPrevented; + } + + _defaultPrevented: boolean; + + constructor(type: string, map: Map, originalEvent: MouseEvent, data: any = {}) { + super(type, data); + const point = DOM.mousePos(map.getCanvasContainer(), originalEvent); + const lngLat = map.unproject(point); + this.point = point; + this.lngLat = lngLat; + this.originalEvent = originalEvent; + this._defaultPrevented = false; + this.target = map; + } +} + +/** + * `MapTouchEvent` is the event type for touch-related map events. + * + * @group Event Related + */ +export class MapTouchEvent extends Event implements MapLibreEvent { + /** + * The event type. + */ + public declare type: 'touchstart' | 'touchmove' | 'touchend' | 'touchcancel'; + + /** + * The `Map` object that fired the event. + */ + target: Map; + + /** + * The DOM event which caused the map event. + */ + originalEvent: TouchEvent; + + /** + * The geographic location on the map of the center of the touch event points. + */ + lngLat: LngLat; + + /** + * The pixel coordinates of the center of the touch event points, relative to the map and measured from the top left + * corner. + */ + point: Point; + + /** + * The array of pixel coordinates corresponding to a + * [touch event's `touches`](https://developer.mozilla.org/en-US/docs/Web/API/TouchEvent/touches) property. + */ + points: Array; + + /** + * The geographical locations on the map corresponding to a + * [touch event's `touches`](https://developer.mozilla.org/en-US/docs/Web/API/TouchEvent/touches) property. + */ + lngLats: Array; + + /** + * Prevents subsequent default processing of the event by the map. + * + * Calling this method will prevent the following default map behaviors: + * + * * On `touchstart` events, the behavior of {@link DragPanHandler} + * * On `touchstart` events, the behavior of {@link TwoFingersTouchZoomRotateHandler} + * + */ + preventDefault() { + this._defaultPrevented = true; + } + + /** + * `true` if `preventDefault` has been called. + */ + get defaultPrevented(): boolean { + return this._defaultPrevented; + } + + _defaultPrevented: boolean; + + constructor(type: string, map: Map, originalEvent: TouchEvent) { + super(type); + const touches = type === 'touchend' ? originalEvent.changedTouches : originalEvent.touches; + const points = DOM.touchPos(map.getCanvasContainer(), touches); + const lngLats = points.map((t) => map.unproject(t)); + const point = points.reduce( + (prev, curr, i, arr) => { + return prev.add(curr.div(arr.length)); + }, + new Point(0, 0), + ); + const lngLat = map.unproject(point); + + this.target = map; + this.points = points; + this.point = point; + this.lngLats = lngLats; + this.lngLat = lngLat; + this.originalEvent = originalEvent; + this._defaultPrevented = false; + } +} + +/** + * `MapWheelEvent` is the event type for the `wheel` map event. + * + * @group Event Related + * + */ +export class MapWheelEvent extends Event { + /** + * The event type + */ + public declare type: 'wheel'; + /** + * The `Map` object that fired the event. + */ + target: Map; + + /** + * The DOM event which caused the map event. + */ + originalEvent: WheelEvent; + + /** + * Prevents subsequent default processing of the event by the map. + * + * Calling this method will prevent the behavior of {@link ScrollZoomHandler}. + */ + preventDefault() { + this._defaultPrevented = true; + } + + /** + * `true` if `preventDefault` has been called. + */ + get defaultPrevented(): boolean { + return this._defaultPrevented; + } + + _defaultPrevented: boolean; + + /** */ + constructor(type: string, map: Map, originalEvent: WheelEvent) { + super(type); + this.target = map; + this._defaultPrevented = false; + this.originalEvent = originalEvent; + } +} + +/** + * A `MapLibreZoomEvent` is the event type for the boxzoom-related map events emitted by the {@link BoxZoomHandler}. + * + * @group Event Related + */ +export type MapLibreZoomEvent = { + /** + * The type of boxzoom event. One of `boxzoomstart`, `boxzoomend` or `boxzoomcancel` + */ + type: 'boxzoomstart' | 'boxzoomend' | 'boxzoomcancel'; + /** + * The `Map` instance that triggered the event + */ + target: Map; + /** + * The DOM event that triggered the boxzoom event. Can be a `MouseEvent` or `KeyboardEvent` + */ + originalEvent: MouseEvent; +}; diff --git a/packages/map/src/map/geo/edge_insets.ts b/packages/map/src/map/geo/edge_insets.ts new file mode 100644 index 00000000000..ccc12eebaef --- /dev/null +++ b/packages/map/src/map/geo/edge_insets.ts @@ -0,0 +1,158 @@ +import Point from '@mapbox/point-geometry'; +import { clamp, interpolates } from '../util/util'; + +/** + * An `EdgeInset` object represents screen space padding applied to the edges of the viewport. + * This shifts the apprent center or the vanishing point of the map. This is useful for adding floating UI elements + * on top of the map and having the vanishing point shift as UI elements resize. + * + * @group Geography and Geometry + */ +export class EdgeInsets { + /** + * @defaultValue 0 + */ + top: number; + /** + * @defaultValue 0 + */ + bottom: number; + /** + * @defaultValue 0 + */ + left: number; + /** + * @defaultValue 0 + */ + right: number; + + constructor(top: number = 0, bottom: number = 0, left: number = 0, right: number = 0) { + if ( + isNaN(top) || + top < 0 || + isNaN(bottom) || + bottom < 0 || + isNaN(left) || + left < 0 || + isNaN(right) || + right < 0 + ) { + throw new Error( + 'Invalid value for edge-insets, top, bottom, left and right must all be numbers', + ); + } + + this.top = top; + this.bottom = bottom; + this.left = left; + this.right = right; + } + + /** + * Interpolates the inset in-place. + * This maintains the current inset value for any inset not present in `target`. + * @param start - interpolation start + * @param target - interpolation target + * @param t - interpolation step/weight + * @returns the insets + */ + interpolate(start: PaddingOptions | EdgeInsets, target: PaddingOptions, t: number): EdgeInsets { + if (target.top != null && start.top != null) + this.top = interpolates.number(start.top, target.top, t); + if (target.bottom != null && start.bottom != null) + this.bottom = interpolates.number(start.bottom, target.bottom, t); + if (target.left != null && start.left != null) + this.left = interpolates.number(start.left, target.left, t); + if (target.right != null && start.right != null) + this.right = interpolates.number(start.right, target.right, t); + + return this; + } + + /** + * Utility method that computes the new apprent center or vanishing point after applying insets. + * This is in pixels and with the top left being (0.0) and +y being downwards. + * + * @param width - the width + * @param height - the height + * @returns the point + */ + getCenter(width: number, height: number): Point { + // Clamp insets so they never overflow width/height and always calculate a valid center + const x = clamp((this.left + width - this.right) / 2, 0, width); + const y = clamp((this.top + height - this.bottom) / 2, 0, height); + + return new Point(x, y); + } + + equals(other: PaddingOptions): boolean { + return ( + this.top === other.top && + this.bottom === other.bottom && + this.left === other.left && + this.right === other.right + ); + } + + clone(): EdgeInsets { + return new EdgeInsets(this.top, this.bottom, this.left, this.right); + } + + /** + * Returns the current state as json, useful when you want to have a + * read-only representation of the inset. + * + * @returns state as json + */ + toJSON(): PaddingOptions { + return { + top: this.top, + bottom: this.bottom, + left: this.left, + right: this.right, + }; + } +} + +/** + * Options for setting padding on calls to methods such as {@link Map#fitBounds}, {@link Map#fitScreenCoordinates}, and {@link Map#setPadding}. Adjust these options to set the amount of padding in pixels added to the edges of the canvas. Set a uniform padding on all edges or individual values for each edge. All properties of this object must be + * non-negative integers. + * + * @group Geography and Geometry + * + * @example + * ```ts + * let bbox = [[-79, 43], [-73, 45]]; + * map.fitBounds(bbox, { + * padding: {top: 10, bottom:25, left: 15, right: 5} + * }); + * ``` + * + * @example + * ```ts + * let bbox = [[-79, 43], [-73, 45]]; + * map.fitBounds(bbox, { + * padding: 20 + * }); + * ``` + * @see [Fit to the bounds of a LineString](https://maplibre.org/maplibre-gl-js/docs/examples/zoomto-linestring/) + * @see [Fit a map to a bounding box](https://maplibre.org/maplibre-gl-js/docs/examples/fitbounds/) + */ +export type PaddingOptions = { + /** + * Padding in pixels from the top of the map canvas. + */ + top: number; + /** + * Padding in pixels from the bottom of the map canvas. + */ + bottom: number; + /** + * Padding in pixels from the left of the map canvas. + */ + right: number; + /** + * Padding in pixels from the right of the map canvas. + */ + left: number; +}; diff --git a/packages/map/src/map/geo/lng_lat.ts b/packages/map/src/map/geo/lng_lat.ts new file mode 100644 index 00000000000..a527ac19e2a --- /dev/null +++ b/packages/map/src/map/geo/lng_lat.ts @@ -0,0 +1,177 @@ +import { wrap } from '../util/util'; + +/* + * Approximate radius of the earth in meters. + * Uses the WGS-84 approximation. The radius at the equator is ~6378137 and at the poles is ~6356752. https://en.wikipedia.org/wiki/World_Geodetic_System#WGS84 + * 6371008.8 is one published "average radius" see https://en.wikipedia.org/wiki/Earth_radius#Mean_radius, or ftp://athena.fsv.cvut.cz/ZFG/grs80-Moritz.pdf p.4 + */ +export const earthRadius = 6371008.8; + +/** + * A {@link LngLat} object, an array of two numbers representing longitude and latitude, + * or an object with `lng` and `lat` or `lon` and `lat` properties. + * + * @group Geography and Geometry + * + * @example + * ```ts + * let v1 = new LngLat(-122.420679, 37.772537); + * let v2 = [-122.420679, 37.772537]; + * let v3 = {lon: -122.420679, lat: 37.772537}; + * ``` + */ +export type LngLatLike = + | LngLat + | { + lng: number; + lat: number; + } + | { + lon: number; + lat: number; + } + | [number, number]; + +/** + * A `LngLat` object represents a given longitude and latitude coordinate, measured in degrees. + * These coordinates are based on the [WGS84 (EPSG:4326) standard](https://en.wikipedia.org/wiki/World_Geodetic_System#WGS84). + * + * MapLibre GL JS uses longitude, latitude coordinate order (as opposed to latitude, longitude) to match the + * [GeoJSON specification](https://tools.ietf.org/html/rfc7946). + * + * Note that any MapLibre GL JS method that accepts a `LngLat` object as an argument or option + * can also accept an `Array` of two numbers and will perform an implicit conversion. + * This flexible type is documented as {@link LngLatLike}. + * + * @group Geography and Geometry + * + * @example + * ```ts + * let ll = new LngLat(-123.9749, 40.7736); + * ll.lng; // = -123.9749 + * ``` + * @see [Get coordinates of the mouse pointer](https://maplibre.org/maplibre-gl-js/docs/examples/mouse-position/) + * @see [Display a popup](https://maplibre.org/maplibre-gl-js/docs/examples/popup/) + * @see [Create a timeline animation](https://maplibre.org/maplibre-gl-js/docs/examples/timeline-animation/) + */ +export class LngLat { + lng: number; + lat: number; + + /** + * @param lng - Longitude, measured in degrees. + * @param lat - Latitude, measured in degrees. + */ + constructor(lng: number, lat: number) { + if (isNaN(lng) || isNaN(lat)) { + throw new Error(`Invalid LngLat object: (${lng}, ${lat})`); + } + this.lng = +lng; + this.lat = +lat; + if (this.lat > 90 || this.lat < -90) { + throw new Error('Invalid LngLat latitude value: must be between -90 and 90'); + } + } + + /** + * Returns a new `LngLat` object whose longitude is wrapped to the range (-180, 180). + * + * @returns The wrapped `LngLat` object. + * @example + * ```ts + * let ll = new LngLat(286.0251, 40.7736); + * let wrapped = ll.wrap(); + * wrapped.lng; // = -73.9749 + * ``` + */ + wrap() { + return new LngLat(wrap(this.lng, -180, 180), this.lat); + } + + /** + * Returns the coordinates represented as an array of two numbers. + * + * @returns The coordinates represented as an array of longitude and latitude. + * @example + * ```ts + * let ll = new LngLat(-73.9749, 40.7736); + * ll.toArray(); // = [-73.9749, 40.7736] + * ``` + */ + toArray(): [number, number] { + return [this.lng, this.lat]; + } + + /** + * Returns the coordinates represent as a string. + * + * @returns The coordinates represented as a string of the format `'LngLat(lng, lat)'`. + * @example + * ```ts + * let ll = new LngLat(-73.9749, 40.7736); + * ll.toString(); // = "LngLat(-73.9749, 40.7736)" + * ``` + */ + toString(): string { + return `LngLat(${this.lng}, ${this.lat})`; + } + + /** + * Returns the approximate distance between a pair of coordinates in meters + * Uses the Haversine Formula (from R.W. Sinnott, "Virtues of the Haversine", Sky and Telescope, vol. 68, no. 2, 1984, p. 159) + * + * @param lngLat - coordinates to compute the distance to + * @returns Distance in meters between the two coordinates. + * @example + * ```ts + * let new_york = new LngLat(-74.0060, 40.7128); + * let los_angeles = new LngLat(-118.2437, 34.0522); + * new_york.distanceTo(los_angeles); // = 3935751.690893987, "true distance" using a non-spherical approximation is ~3966km + * ``` + */ + distanceTo(lngLat: LngLat): number { + const rad = Math.PI / 180; + const lat1 = this.lat * rad; + const lat2 = lngLat.lat * rad; + const a = + Math.sin(lat1) * Math.sin(lat2) + + Math.cos(lat1) * Math.cos(lat2) * Math.cos((lngLat.lng - this.lng) * rad); + + const maxMeters = earthRadius * Math.acos(Math.min(a, 1)); + return maxMeters; + } + + /** + * Converts an array of two numbers or an object with `lng` and `lat` or `lon` and `lat` properties + * to a `LngLat` object. + * + * If a `LngLat` object is passed in, the function returns it unchanged. + * + * @param input - An array of two numbers or object to convert, or a `LngLat` object to return. + * @returns A new `LngLat` object, if a conversion occurred, or the original `LngLat` object. + * @example + * ```ts + * let arr = [-73.9749, 40.7736]; + * let ll = LngLat.convert(arr); + * ll; // = LngLat {lng: -73.9749, lat: 40.7736} + * ``` + */ + static convert(input: LngLatLike): LngLat { + if (input instanceof LngLat) { + return input; + } + if (Array.isArray(input) && (input.length === 2 || input.length === 3)) { + return new LngLat(Number(input[0]), Number(input[1])); + } + if (!Array.isArray(input) && typeof input === 'object' && input !== null) { + return new LngLat( + // flow can't refine this to have one of lng or lat, so we have to cast to any + Number('lng' in input ? (input as any).lng : (input as any).lon), + Number(input.lat), + ); + } + throw new Error( + '`LngLatLike` argument must be specified as a LngLat instance, an object {lng: , lat: }, an object {lon: , lat: }, or an array of [, ]', + ); + } +} diff --git a/packages/map/src/map/geo/lng_lat_bounds.ts b/packages/map/src/map/geo/lng_lat_bounds.ts new file mode 100644 index 00000000000..bbe185802fd --- /dev/null +++ b/packages/map/src/map/geo/lng_lat_bounds.ts @@ -0,0 +1,350 @@ +import type { LngLatLike } from './lng_lat'; +import { LngLat } from './lng_lat'; + +/** + * A {@link LngLatBounds} object, an array of {@link LngLatLike} objects in [sw, ne] order, + * or an array of numbers in [west, south, east, north] order. + * + * @group Geography and Geometry + * + * @example + * ```ts + * let v1 = new LngLatBounds( + * new LngLat(-73.9876, 40.7661), + * new LngLat(-73.9397, 40.8002) + * ); + * let v2 = new LngLatBounds([-73.9876, 40.7661], [-73.9397, 40.8002]) + * let v3 = [[-73.9876, 40.7661], [-73.9397, 40.8002]]; + * ``` + */ +export type LngLatBoundsLike = + | LngLatBounds + | [LngLatLike, LngLatLike] + | [number, number, number, number]; + +/** + * A `LngLatBounds` object represents a geographical bounding box, + * defined by its southwest and northeast points in longitude and latitude. + * + * If no arguments are provided to the constructor, a `null` bounding box is created. + * + * Note that any Mapbox GL method that accepts a `LngLatBounds` object as an argument or option + * can also accept an `Array` of two {@link LngLatLike} constructs and will perform an implicit conversion. + * This flexible type is documented as {@link LngLatBoundsLike}. + * + * @group Geography and Geometry + * + * @example + * ```ts + * let sw = new LngLat(-73.9876, 40.7661); + * let ne = new LngLat(-73.9397, 40.8002); + * let llb = new LngLatBounds(sw, ne); + * ``` + */ +export class LngLatBounds { + _ne: LngLat; + _sw: LngLat; + + /** + * @param sw - The southwest corner of the bounding box. + * OR array of 4 numbers in the order of west, south, east, north + * OR array of 2 LngLatLike: [sw,ne] + * @param ne - The northeast corner of the bounding box. + * @example + * ```ts + * let sw = new LngLat(-73.9876, 40.7661); + * let ne = new LngLat(-73.9397, 40.8002); + * let llb = new LngLatBounds(sw, ne); + * ``` + * OR + * ```ts + * let llb = new LngLatBounds([-73.9876, 40.7661, -73.9397, 40.8002]); + * ``` + * OR + * ```ts + * let llb = new LngLatBounds([sw, ne]); + * ``` + */ + constructor( + sw?: LngLatLike | [number, number, number, number] | [LngLatLike, LngLatLike], + ne?: LngLatLike, + ) { + if (!sw) { + // noop + } else if (ne) { + this.setSouthWest(sw).setNorthEast(ne); + } else if (Array.isArray(sw)) { + if (sw.length === 4) { + // 4 element array: west, south, east, north + this.setSouthWest([sw[0], sw[1]]).setNorthEast([sw[2], sw[3]]); + } else { + this.setSouthWest(sw[0] as LngLatLike).setNorthEast(sw[1] as LngLatLike); + } + } + } + + /** + * Set the northeast corner of the bounding box + * + * @param ne - a {@link LngLatLike} object describing the northeast corner of the bounding box. + */ + setNorthEast(ne: LngLatLike): this { + this._ne = ne instanceof LngLat ? new LngLat(ne.lng, ne.lat) : LngLat.convert(ne); + return this; + } + + /** + * Set the southwest corner of the bounding box + * + * @param sw - a {@link LngLatLike} object describing the southwest corner of the bounding box. + */ + setSouthWest(sw: LngLatLike): this { + this._sw = sw instanceof LngLat ? new LngLat(sw.lng, sw.lat) : LngLat.convert(sw); + return this; + } + + /** + * Extend the bounds to include a given LngLatLike or LngLatBoundsLike. + * + * @param obj - object to extend to + */ + extend(obj: LngLatLike | LngLatBoundsLike): this { + const sw = this._sw, + ne = this._ne; + let sw2, ne2; + + if (obj instanceof LngLat) { + sw2 = obj; + ne2 = obj; + } else if (obj instanceof LngLatBounds) { + sw2 = obj._sw; + ne2 = obj._ne; + + if (!sw2 || !ne2) return this; + } else { + if (Array.isArray(obj)) { + if (obj.length === 4 || (obj as any[]).every(Array.isArray)) { + const lngLatBoundsObj = obj as any as LngLatBoundsLike; + return this.extend(LngLatBounds.convert(lngLatBoundsObj)); + } else { + const lngLatObj = obj as any as LngLatLike; + return this.extend(LngLat.convert(lngLatObj)); + } + } else if (obj && ('lng' in obj || 'lon' in obj) && 'lat' in obj) { + return this.extend(LngLat.convert(obj)); + } + + return this; + } + + if (!sw && !ne) { + this._sw = new LngLat(sw2.lng, sw2.lat); + this._ne = new LngLat(ne2.lng, ne2.lat); + } else { + sw.lng = Math.min(sw2.lng, sw.lng); + sw.lat = Math.min(sw2.lat, sw.lat); + ne.lng = Math.max(ne2.lng, ne.lng); + ne.lat = Math.max(ne2.lat, ne.lat); + } + + return this; + } + + /** + * Returns the geographical coordinate equidistant from the bounding box's corners. + * + * @returns The bounding box's center. + * @example + * ```ts + * let llb = new LngLatBounds([-73.9876, 40.7661], [-73.9397, 40.8002]); + * llb.getCenter(); // = LngLat {lng: -73.96365, lat: 40.78315} + * ``` + */ + getCenter(): LngLat { + return new LngLat((this._sw.lng + this._ne.lng) / 2, (this._sw.lat + this._ne.lat) / 2); + } + + /** + * Returns the southwest corner of the bounding box. + * + * @returns The southwest corner of the bounding box. + */ + getSouthWest(): LngLat { + return this._sw; + } + + /** + * Returns the northeast corner of the bounding box. + * + * @returns The northeast corner of the bounding box. + */ + getNorthEast(): LngLat { + return this._ne; + } + + /** + * Returns the northwest corner of the bounding box. + * + * @returns The northwest corner of the bounding box. + */ + getNorthWest(): LngLat { + return new LngLat(this.getWest(), this.getNorth()); + } + + /** + * Returns the southeast corner of the bounding box. + * + * @returns The southeast corner of the bounding box. + */ + getSouthEast(): LngLat { + return new LngLat(this.getEast(), this.getSouth()); + } + + /** + * Returns the west edge of the bounding box. + * + * @returns The west edge of the bounding box. + */ + getWest(): number { + return this._sw.lng; + } + + /** + * Returns the south edge of the bounding box. + * + * @returns The south edge of the bounding box. + */ + getSouth(): number { + return this._sw.lat; + } + + /** + * Returns the east edge of the bounding box. + * + * @returns The east edge of the bounding box. + */ + getEast(): number { + return this._ne.lng; + } + + /** + * Returns the north edge of the bounding box. + * + * @returns The north edge of the bounding box. + */ + getNorth(): number { + return this._ne.lat; + } + + /** + * Returns the bounding box represented as an array. + * + * @returns The bounding box represented as an array, consisting of the + * southwest and northeast coordinates of the bounding represented as arrays of numbers. + * @example + * ```ts + * let llb = new LngLatBounds([-73.9876, 40.7661], [-73.9397, 40.8002]); + * llb.toArray(); // = [[-73.9876, 40.7661], [-73.9397, 40.8002]] + * ``` + */ + toArray() { + return [this._sw.toArray(), this._ne.toArray()]; + } + + /** + * Return the bounding box represented as a string. + * + * @returns The bounding box represents as a string of the format + * `'LngLatBounds(LngLat(lng, lat), LngLat(lng, lat))'`. + * @example + * ```ts + * let llb = new LngLatBounds([-73.9876, 40.7661], [-73.9397, 40.8002]); + * llb.toString(); // = "LngLatBounds(LngLat(-73.9876, 40.7661), LngLat(-73.9397, 40.8002))" + * ``` + */ + toString() { + return `LngLatBounds(${this._sw.toString()}, ${this._ne.toString()})`; + } + + /** + * Check if the bounding box is an empty/`null`-type box. + * + * @returns True if bounds have been defined, otherwise false. + */ + isEmpty() { + return !(this._sw && this._ne); + } + + /** + * Check if the point is within the bounding box. + * + * @param lnglat - geographic point to check against. + * @returns `true` if the point is within the bounding box. + * @example + * ```ts + * let llb = new LngLatBounds( + * new LngLat(-73.9876, 40.7661), + * new LngLat(-73.9397, 40.8002) + * ); + * + * let ll = new LngLat(-73.9567, 40.7789); + * + * console.log(llb.contains(ll)); // = true + * ``` + */ + contains(lnglat: LngLatLike) { + const { lng, lat } = LngLat.convert(lnglat); + + const containsLatitude = this._sw.lat <= lat && lat <= this._ne.lat; + let containsLongitude = this._sw.lng <= lng && lng <= this._ne.lng; + if (this._sw.lng > this._ne.lng) { + // wrapped coordinates + containsLongitude = this._sw.lng >= lng && lng >= this._ne.lng; + } + + return containsLatitude && containsLongitude; + } + + /** + * Converts an array to a `LngLatBounds` object. + * + * If a `LngLatBounds` object is passed in, the function returns it unchanged. + * + * Internally, the function calls `LngLat#convert` to convert arrays to `LngLat` values. + * + * @param input - An array of two coordinates to convert, or a `LngLatBounds` object to return. + * @returns A new `LngLatBounds` object, if a conversion occurred, or the original `LngLatBounds` object. + * @example + * ```ts + * let arr = [[-73.9876, 40.7661], [-73.9397, 40.8002]]; + * let llb = LngLatBounds.convert(arr); // = LngLatBounds {_sw: LngLat {lng: -73.9876, lat: 40.7661}, _ne: LngLat {lng: -73.9397, lat: 40.8002}} + * ``` + */ + static convert(input: LngLatBoundsLike): LngLatBounds { + if (input instanceof LngLatBounds) return input; + return new LngLatBounds(input); + } + + /** + * Returns a `LngLatBounds` from the coordinates extended by a given `radius`. The returned `LngLatBounds` completely contains the `radius`. + * + * @param center - center coordinates of the new bounds. + * @param radius - Distance in meters from the coordinates to extend the bounds. + * @returns A new `LngLatBounds` object representing the coordinates extended by the `radius`. + * @example + * ```ts + * let center = new LngLat(-73.9749, 40.7736); + * LngLatBounds.fromLngLat(100).toArray(); // = [[-73.97501862141328, 40.77351016847229], [-73.97478137858673, 40.77368983152771]] + * ``` + */ + static fromLngLat(center: LngLat, radius: number = 0): LngLatBounds { + const earthCircumferenceInMetersAtEquator = 40075017; + const latAccuracy = (360 * radius) / earthCircumferenceInMetersAtEquator, + lngAccuracy = latAccuracy / Math.cos((Math.PI / 180) * center.lat); + + return new LngLatBounds( + new LngLat(center.lng - lngAccuracy, center.lat - latAccuracy), + new LngLat(center.lng + lngAccuracy, center.lat + latAccuracy), + ); + } +} diff --git a/packages/map/src/map/geo/mercator_coordinate.ts b/packages/map/src/map/geo/mercator_coordinate.ts new file mode 100644 index 00000000000..a460d4957b5 --- /dev/null +++ b/packages/map/src/map/geo/mercator_coordinate.ts @@ -0,0 +1,173 @@ +import type { LngLatLike } from './lng_lat'; +import { LngLat, earthRadius } from './lng_lat'; + +interface ILngLat { + lng: number; + lat: number; + wrap(): ILngLat; + toArray(): [number, number]; + distanceTo(lngLat: ILngLat): number; + toString(): string; +} + +interface IMercatorCoordinate { + x: number; + y: number; + z: number; + toLngLat(): ILngLat; + toAltitude(): number; + meterInMercatorCoordinateUnits(): number; +} + +/* + * The average circumference of the world in meters. + */ +const earthCircumfrence = 2 * Math.PI * earthRadius; // meters + +/* + * The circumference at a line of latitude in meters. + */ +function circumferenceAtLatitude(latitude: number) { + return earthCircumfrence * Math.cos((latitude * Math.PI) / 180); +} + +export function mercatorXfromLng(lng: number) { + return (180 + lng) / 360; +} + +export function mercatorYfromLat(lat: number) { + return (180 - (180 / Math.PI) * Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360))) / 360; +} + +export function mercatorZfromAltitude(altitude: number, lat: number) { + return altitude / circumferenceAtLatitude(lat); +} + +export function lngFromMercatorX(x: number) { + return x * 360 - 180; +} + +export function latFromMercatorY(y: number) { + const y2 = 180 - y * 360; + return (360 / Math.PI) * Math.atan(Math.exp((y2 * Math.PI) / 180)) - 90; +} + +export function altitudeFromMercatorZ(z: number, y: number) { + return z * circumferenceAtLatitude(latFromMercatorY(y)); +} + +/** + * Determine the Mercator scale factor for a given latitude, see + * https://en.wikipedia.org/wiki/Mercator_projection#Scale_factor + * + * At the equator the scale factor will be 1, which increases at higher latitudes. + * + * @param lat - Latitude + * @returns scale factor + */ +export function mercatorScale(lat: number) { + return 1 / Math.cos((lat * Math.PI) / 180); +} + +/** + * A `MercatorCoordinate` object represents a projected three dimensional position. + * + * `MercatorCoordinate` uses the web mercator projection ([EPSG:3857](https://epsg.io/3857)) with slightly different units: + * + * - the size of 1 unit is the width of the projected world instead of the "mercator meter" + * - the origin of the coordinate space is at the north-west corner instead of the middle + * + * For example, `MercatorCoordinate(0, 0, 0)` is the north-west corner of the mercator world and + * `MercatorCoordinate(1, 1, 0)` is the south-east corner. If you are familiar with + * [vector tiles](https://github.com/mapbox/vector-tile-spec) it may be helpful to think + * of the coordinate space as the `0/0/0` tile with an extent of `1`. + * + * The `z` dimension of `MercatorCoordinate` is conformal. A cube in the mercator coordinate space would be rendered as a cube. + * + * @group Geography and Geometry + * + * @example + * ```ts + * let nullIsland = new MercatorCoordinate(0.5, 0.5, 0); + * ``` + * @see [Add a custom style layer](https://maplibre.org/maplibre-gl-js/docs/examples/custom-style-layer/) + */ +export class MercatorCoordinate implements IMercatorCoordinate { + x: number; + y: number; + z: number; + + /** + * @param x - The x component of the position. + * @param y - The y component of the position. + * @param z - The z component of the position. + */ + constructor(x: number, y: number, z: number = 0) { + this.x = +x; + this.y = +y; + this.z = +z; + } + + /** + * Project a `LngLat` to a `MercatorCoordinate`. + * + * @param lngLatLike - The location to project. + * @param altitude - The altitude in meters of the position. + * @returns The projected mercator coordinate. + * @example + * ```ts + * let coord = MercatorCoordinate.fromLngLat({ lng: 0, lat: 0}, 0); + * coord; // MercatorCoordinate(0.5, 0.5, 0) + * ``` + */ + static fromLngLat(lngLatLike: LngLatLike, altitude: number = 0): MercatorCoordinate { + const lngLat = LngLat.convert(lngLatLike); + + return new MercatorCoordinate( + mercatorXfromLng(lngLat.lng), + mercatorYfromLat(lngLat.lat), + mercatorZfromAltitude(altitude, lngLat.lat), + ); + } + + /** + * Returns the `LngLat` for the coordinate. + * + * @returns The `LngLat` object. + * @example + * ```ts + * let coord = new MercatorCoordinate(0.5, 0.5, 0); + * let lngLat = coord.toLngLat(); // LngLat(0, 0) + * ``` + */ + toLngLat() { + return new LngLat(lngFromMercatorX(this.x), latFromMercatorY(this.y)); + } + + /** + * Returns the altitude in meters of the coordinate. + * + * @returns The altitude in meters. + * @example + * ```ts + * let coord = new MercatorCoordinate(0, 0, 0.02); + * coord.toAltitude(); // 6914.281956295339 + * ``` + */ + toAltitude(): number { + return altitudeFromMercatorZ(this.z, this.y); + } + + /** + * Returns the distance of 1 meter in `MercatorCoordinate` units at this latitude. + * + * For coordinates in real world units using meters, this naturally provides the scale + * to transform into `MercatorCoordinate`s. + * + * @returns Distance of 1 meter in `MercatorCoordinate` units. + */ + meterInMercatorCoordinateUnits(): number { + // 1 meter / circumference at equator in meters * Mercator projection scale factor at this latitude + return (1 / earthCircumfrence) * mercatorScale(latFromMercatorY(this.y)); + } +} diff --git a/packages/map/src/map/geo/transform.ts b/packages/map/src/map/geo/transform.ts new file mode 100644 index 00000000000..f8a2eaccf29 --- /dev/null +++ b/packages/map/src/map/geo/transform.ts @@ -0,0 +1,849 @@ +import Point from '@mapbox/point-geometry'; +import { mat2, mat4, vec4 } from 'gl-matrix'; +import { clamp, interpolates, wrap } from '../util/util'; +import { EdgeInsets } from './edge_insets'; +import { LngLat } from './lng_lat'; +import { LngLatBounds } from './lng_lat_bounds'; +import { + MercatorCoordinate, + mercatorXfromLng, + mercatorYfromLat, + mercatorZfromAltitude, +} from './mercator_coordinate'; + +import type { PaddingOptions } from './edge_insets'; + +export const MAX_VALID_LATITUDE = 85.051129; + +/** + * @internal + * A single transform, generally used for a single tile to be + * scaled, rotated, and zoomed. + */ +export class Transform { + tileSize: number; + tileZoom: number; + lngRange: [number, number]; + latRange: [number, number]; + scale: number; + width: number; + height: number; + angle: number; + rotationMatrix: mat2; + pixelsToGLUnits: [number, number]; + cameraToCenterDistance: number; + mercatorMatrix: mat4; + projMatrix: mat4; + invProjMatrix: mat4; + alignedProjMatrix: mat4; + pixelMatrix: mat4; + pixelMatrix3D: mat4; + pixelMatrixInverse: mat4; + glCoordMatrix: mat4; + labelPlaneMatrix: mat4; + minElevationForCurrentTile: number; + _fov: number; + _pitch: number; + _zoom: number; + _unmodified: boolean; + _renderWorldCopies: boolean; + _minZoom: number; + _maxZoom: number; + _minPitch: number; + _maxPitch: number; + _center: LngLat; + _elevation: number; + _pixelPerMeter: number; + _edgeInsets: EdgeInsets; + _constraining: boolean; + _posMatrixCache: { [_: string]: mat4 }; + _alignedPosMatrixCache: { [_: string]: mat4 }; + + constructor( + minZoom?: number, + maxZoom?: number, + minPitch?: number, + maxPitch?: number, + renderWorldCopies?: boolean, + ) { + this.tileSize = 512; // constant + + this._renderWorldCopies = renderWorldCopies === undefined ? true : !!renderWorldCopies; + this._minZoom = minZoom || 0; + this._maxZoom = maxZoom || 22; + + this._minPitch = minPitch === undefined || minPitch === null ? 0 : minPitch; + this._maxPitch = maxPitch === undefined || maxPitch === null ? 60 : maxPitch; + + this.setMaxBounds(); + + this.width = 0; + this.height = 0; + this._center = new LngLat(0, 0); + this._elevation = 0; + this.zoom = 0; + this.angle = 0; + this._fov = 0.6435011087932844; + this._pitch = 0; + this._unmodified = true; + this._edgeInsets = new EdgeInsets(); + this._posMatrixCache = {}; + this._alignedPosMatrixCache = {}; + this.minElevationForCurrentTile = 0; + } + + clone(): Transform { + const clone = new Transform( + this._minZoom, + this._maxZoom, + this._minPitch, + this.maxPitch, + this._renderWorldCopies, + ); + clone.apply(this); + return clone; + } + + apply(that: Transform) { + this.tileSize = that.tileSize; + this.latRange = that.latRange; + this.width = that.width; + this.height = that.height; + this._center = that._center; + this._elevation = that._elevation; + this.minElevationForCurrentTile = that.minElevationForCurrentTile; + this.zoom = that.zoom; + this.angle = that.angle; + this._fov = that._fov; + this._pitch = that._pitch; + this._unmodified = that._unmodified; + this._edgeInsets = that._edgeInsets.clone(); + this._calcMatrices(); + } + + get minZoom(): number { + return this._minZoom; + } + set minZoom(zoom: number) { + if (this._minZoom === zoom) return; + this._minZoom = zoom; + this.zoom = Math.max(this.zoom, zoom); + } + + get maxZoom(): number { + return this._maxZoom; + } + set maxZoom(zoom: number) { + if (this._maxZoom === zoom) return; + this._maxZoom = zoom; + this.zoom = Math.min(this.zoom, zoom); + } + + get minPitch(): number { + return this._minPitch; + } + set minPitch(pitch: number) { + if (this._minPitch === pitch) return; + this._minPitch = pitch; + this.pitch = Math.max(this.pitch, pitch); + } + + get maxPitch(): number { + return this._maxPitch; + } + set maxPitch(pitch: number) { + if (this._maxPitch === pitch) return; + this._maxPitch = pitch; + this.pitch = Math.min(this.pitch, pitch); + } + + get renderWorldCopies(): boolean { + return this._renderWorldCopies; + } + set renderWorldCopies(renderWorldCopies: boolean | null | undefined) { + if (renderWorldCopies === undefined) { + renderWorldCopies = true; + } else if (renderWorldCopies === null) { + renderWorldCopies = false; + } + + this._renderWorldCopies = renderWorldCopies; + } + + get worldSize(): number { + return this.tileSize * this.scale; + } + + get centerOffset(): Point { + return this.centerPoint._sub(this.size._div(2)); + } + + get size(): Point { + return new Point(this.width, this.height); + } + + get bearing(): number { + return (-this.angle / Math.PI) * 180; + } + set bearing(bearing: number) { + const b = (-wrap(bearing, -180, 180) * Math.PI) / 180; + if (this.angle === b) return; + this._unmodified = false; + this.angle = b; + this._calcMatrices(); + + // 2x2 matrix for rotating points + this.rotationMatrix = mat2.create(); + mat2.rotate(this.rotationMatrix, this.rotationMatrix, this.angle); + } + + get pitch(): number { + return (this._pitch / Math.PI) * 180; + } + set pitch(pitch: number) { + const p = (clamp(pitch, this.minPitch, this.maxPitch) / 180) * Math.PI; + if (this._pitch === p) return; + this._unmodified = false; + this._pitch = p; + this._calcMatrices(); + } + + get fov(): number { + return (this._fov / Math.PI) * 180; + } + set fov(fov: number) { + fov = Math.max(0.01, Math.min(60, fov)); + if (this._fov === fov) return; + this._unmodified = false; + this._fov = (fov / 180) * Math.PI; + this._calcMatrices(); + } + + get zoom(): number { + return this._zoom; + } + set zoom(zoom: number) { + const constrainedZoom = Math.min(Math.max(zoom, this.minZoom), this.maxZoom); + if (this._zoom === constrainedZoom) return; + this._unmodified = false; + this._zoom = constrainedZoom; + this.tileZoom = Math.max(0, Math.floor(constrainedZoom)); + this.scale = this.zoomScale(constrainedZoom); + this._constrain(); + this._calcMatrices(); + } + + get center(): LngLat { + return this._center; + } + set center(center: LngLat) { + if (center.lat === this._center.lat && center.lng === this._center.lng) return; + this._unmodified = false; + this._center = center; + this._constrain(); + this._calcMatrices(); + } + + /** + * Elevation at current center point, meters above sea level + */ + get elevation(): number { + return this._elevation; + } + set elevation(elevation: number) { + if (elevation === this._elevation) return; + this._elevation = elevation; + this._constrain(); + this._calcMatrices(); + } + + get padding(): PaddingOptions { + return this._edgeInsets.toJSON(); + } + set padding(padding: PaddingOptions) { + if (this._edgeInsets.equals(padding)) return; + this._unmodified = false; + //Update edge-insets inplace + this._edgeInsets.interpolate(this._edgeInsets, padding, 1); + this._calcMatrices(); + } + + /** + * The center of the screen in pixels with the top-left corner being (0,0) + * and +y axis pointing downwards. This accounts for padding. + */ + get centerPoint(): Point { + return this._edgeInsets.getCenter(this.width, this.height); + } + + /** + * Returns if the padding params match + * + * @param padding - the padding to check against + * @returns true if they are equal, false otherwise + */ + isPaddingEqual(padding: PaddingOptions): boolean { + return this._edgeInsets.equals(padding); + } + + /** + * Helper method to update edge-insets in place + * + * @param start - the starting padding + * @param target - the target padding + * @param t - the step/weight + */ + interpolatePadding(start: PaddingOptions, target: PaddingOptions, t: number) { + this._unmodified = false; + this._edgeInsets.interpolate(start, target, t); + this._constrain(); + this._calcMatrices(); + } + + /** + * Return a zoom level that will cover all tiles the transform + * @param options - the options + * @returns zoom level An integer zoom level at which all tiles will be visible. + */ + coveringZoomLevel(options: { + /** + * Target zoom level. If true, the value will be rounded to the closest integer. Otherwise the value will be floored. + */ + roundZoom?: boolean; + /** + * Tile size, expressed in screen pixels. + */ + tileSize: number; + }): number { + const z = (options.roundZoom ? Math.round : Math.floor)( + this.zoom + this.scaleZoom(this.tileSize / options.tileSize), + ); + // At negative zoom levels load tiles from z0 because negative tile zoom levels don't exist. + return Math.max(0, z); + } + + resize(width: number, height: number) { + this.width = width; + this.height = height; + + this.pixelsToGLUnits = [2 / width, -2 / height]; + this._constrain(); + this._calcMatrices(); + } + + get unmodified(): boolean { + return this._unmodified; + } + + zoomScale(zoom: number) { + return Math.pow(2, zoom); + } + scaleZoom(scale: number) { + return Math.log(scale) / Math.LN2; + } + + /** + * Convert from LngLat to world coordinates (Mercator coordinates scaled by 512) + * @param lnglat - the lngLat + * @returns Point + */ + project(lnglat: LngLat) { + const lat = clamp(lnglat.lat, -MAX_VALID_LATITUDE, MAX_VALID_LATITUDE); + return new Point( + mercatorXfromLng(lnglat.lng) * this.worldSize, + mercatorYfromLat(lat) * this.worldSize, + ); + } + + /** + * Convert from world coordinates ([0, 512],[0, 512]) to LngLat ([-180, 180], [-90, 90]) + * @param point - world coordinate + * @returns LngLat + */ + unproject(point: Point): LngLat { + return new MercatorCoordinate(point.x / this.worldSize, point.y / this.worldSize).toLngLat(); + } + + get point(): Point { + return this.project(this.center); + } + + /** + * get the camera position in LngLat and altitudes in meter + * @returns An object with lngLat & altitude. + */ + getCameraPosition(): { + lngLat: LngLat; + altitude: number; + } { + const lngLat = this.pointLocation(this.getCameraPoint()); + const altitude = (Math.cos(this._pitch) * this.cameraToCenterDistance) / this._pixelPerMeter; + return { lngLat, altitude: altitude + this.elevation }; + } + + setLocationAtPoint(lnglat: LngLat, point: Point) { + const a = this.pointCoordinate(point); + const b = this.pointCoordinate(this.centerPoint); + const loc = this.locationCoordinate(lnglat); + const newCenter = new MercatorCoordinate(loc.x - (a.x - b.x), loc.y - (a.y - b.y)); + this.center = this.coordinateLocation(newCenter); + if (this._renderWorldCopies) { + this.center = this.center.wrap(); + } + } + + /** + * Given a LngLat location, return the screen point that corresponds to it + * @param lnglat - location + * @param terrain - optional terrain + * @returns screen point + */ + locationPoint(lnglat: LngLat): Point { + return this.coordinatePoint(this.locationCoordinate(lnglat)); + } + + /** + * Given a point on screen, return its lnglat + * @param p - screen point + * @param terrain - optional terrain + * @returns lnglat location + */ + pointLocation(p: Point): LngLat { + return this.coordinateLocation(this.pointCoordinate(p)); + } + + /** + * Given a geographical lnglat, return an unrounded + * coordinate that represents it at low zoom level. + * @param lnglat - the location + * @returns The mercator coordinate + */ + locationCoordinate(lnglat: LngLat): MercatorCoordinate { + return MercatorCoordinate.fromLngLat(lnglat); + } + + /** + * Given a Coordinate, return its geographical position. + * @param coord - mercator coordinates + * @returns lng and lat + */ + coordinateLocation(coord: MercatorCoordinate): LngLat { + return coord && coord.toLngLat(); + } + + /** + * Given a Point, return its mercator coordinate. + * @param p - the point + * @param terrain - optional terrain + * @returns lnglat + */ + pointCoordinate(p: Point): MercatorCoordinate { + // calculate point-coordinate on flat earth + const targetZ = 0; + // since we don't know the correct projected z value for the point, + // unproject two points to get a line and then find the point on that + // line with z=0 + + const coord0 = [p.x, p.y, 0, 1] as vec4; + const coord1 = [p.x, p.y, 1, 1] as vec4; + + vec4.transformMat4(coord0, coord0, this.pixelMatrixInverse); + vec4.transformMat4(coord1, coord1, this.pixelMatrixInverse); + + const w0 = coord0[3]; + const w1 = coord1[3]; + const x0 = coord0[0] / w0; + const x1 = coord1[0] / w1; + const y0 = coord0[1] / w0; + const y1 = coord1[1] / w1; + const z0 = coord0[2] / w0; + const z1 = coord1[2] / w1; + + const t = z0 === z1 ? 0 : (targetZ - z0) / (z1 - z0); + + return new MercatorCoordinate( + interpolates.number(x0, x1, t) / this.worldSize, + interpolates.number(y0, y1, t) / this.worldSize, + ); + } + + /** + * Given a coordinate, return the screen point that corresponds to it + * @param coord - the coordinates + * @param elevation - the elevation + * @param pixelMatrix - the pixel matrix + * @returns screen point + */ + coordinatePoint( + coord: MercatorCoordinate, + elevation: number = 0, + pixelMatrix = this.pixelMatrix, + ): Point { + const p = [coord.x * this.worldSize, coord.y * this.worldSize, elevation, 1] as vec4; + vec4.transformMat4(p, p, pixelMatrix); + return new Point(p[0] / p[3], p[1] / p[3]); + } + + /** + * Returns the map's geographical bounds. When the bearing or pitch is non-zero, the visible region is not + * an axis-aligned rectangle, and the result is the smallest bounds that encompasses the visible region. + * @returns Returns a {@link LngLatBounds} object describing the map's geographical bounds. + */ + getBounds(): LngLatBounds { + const top = Math.max(0, this.height / 2 - this.getHorizon()); + return new LngLatBounds() + .extend(this.pointLocation(new Point(0, top))) + .extend(this.pointLocation(new Point(this.width, top))) + .extend(this.pointLocation(new Point(this.width, this.height))) + .extend(this.pointLocation(new Point(0, this.height))); + } + + /** + * Returns the maximum geographical bounds the map is constrained to, or `null` if none set. + * @returns max bounds + */ + getMaxBounds(): LngLatBounds | null { + if ( + !this.latRange || + this.latRange.length !== 2 || + !this.lngRange || + this.lngRange.length !== 2 + ) + return null; + + return new LngLatBounds( + [this.lngRange[0], this.latRange[0]], + [this.lngRange[1], this.latRange[1]], + ); + } + + /** + * Calculate pixel height of the visible horizon in relation to map-center (e.g. height/2), + * multiplied by a static factor to simulate the earth-radius. + * The calculated value is the horizontal line from the camera-height to sea-level. + * @returns Horizon above center in pixels. + */ + getHorizon(): number { + return Math.tan(Math.PI / 2 - this._pitch) * this.cameraToCenterDistance * 0.85; + } + + /** + * Sets or clears the map's geographical constraints. + * @param bounds - A {@link LngLatBounds} object describing the new geographic boundaries of the map. + */ + setMaxBounds(bounds?: LngLatBounds | null) { + if (bounds) { + this.lngRange = [bounds.getWest(), bounds.getEast()]; + this.latRange = [bounds.getSouth(), bounds.getNorth()]; + this._constrain(); + } else { + this.lngRange = null; + this.latRange = [-MAX_VALID_LATITUDE, MAX_VALID_LATITUDE]; + } + } + + customLayerMatrix(): mat4 { + return this.mercatorMatrix.slice() as any; + } + + /** + * Get center lngLat and zoom to ensure that + * 1) everything beyond the bounds is excluded + * 2) a given lngLat is as near the center as possible + * Bounds are those set by maxBounds or North & South "Poles" and, if only 1 globe is displayed, antimeridian. + */ + getConstrained(lngLat: LngLat, zoom: number): { center: LngLat; zoom: number } { + zoom = clamp(+zoom, this.minZoom, this.maxZoom); + const result = { + center: new LngLat(lngLat.lng, lngLat.lat), + zoom, + }; + + let lngRange = this.lngRange; + + if (!this._renderWorldCopies && lngRange === null) { + const almost180 = 180 - 1e-10; + lngRange = [-almost180, almost180]; + } + + const worldSize = this.tileSize * this.zoomScale(result.zoom); // A world size for the requested zoom level, not the current world size + let minY = 0; + let maxY = worldSize; + let minX = 0; + let maxX = worldSize; + let scaleY = 0; + let scaleX = 0; + const { x: screenWidth, y: screenHeight } = this.size; + + if (this.latRange) { + const latRange = this.latRange; + minY = mercatorYfromLat(latRange[1]) * worldSize; + maxY = mercatorYfromLat(latRange[0]) * worldSize; + const shouldZoomIn = maxY - minY < screenHeight; + if (shouldZoomIn) scaleY = screenHeight / (maxY - minY); + } + + if (lngRange) { + minX = wrap(mercatorXfromLng(lngRange[0]) * worldSize, 0, worldSize); + maxX = wrap(mercatorXfromLng(lngRange[1]) * worldSize, 0, worldSize); + + if (maxX < minX) maxX += worldSize; + + const shouldZoomIn = maxX - minX < screenWidth; + if (shouldZoomIn) scaleX = screenWidth / (maxX - minX); + } + + const { x: originalX, y: originalY } = this.project.call({ worldSize }, lngLat); + let modifiedX, modifiedY; + + const scale = Math.max(scaleX || 0, scaleY || 0); + + if (scale) { + // zoom in to exclude all beyond the given lng/lat ranges + const newPoint = new Point( + scaleX ? (maxX + minX) / 2 : originalX, + scaleY ? (maxY + minY) / 2 : originalY, + ); + result.center = this.unproject.call({ worldSize }, newPoint).wrap(); + result.zoom += this.scaleZoom(scale); + return result; + } + + if (this.latRange) { + const h2 = screenHeight / 2; + if (originalY - h2 < minY) modifiedY = minY + h2; + if (originalY + h2 > maxY) modifiedY = maxY - h2; + } + + if (lngRange) { + const centerX = (minX + maxX) / 2; + let wrappedX = originalX; + if (this._renderWorldCopies) { + wrappedX = wrap(originalX, centerX - worldSize / 2, centerX + worldSize / 2); + } + const w2 = screenWidth / 2; + + if (wrappedX - w2 < minX) modifiedX = minX + w2; + if (wrappedX + w2 > maxX) modifiedX = maxX - w2; + } + + // pan the map if the screen goes off the range + if (modifiedX !== undefined || modifiedY !== undefined) { + const newPoint = new Point(modifiedX ?? originalX, modifiedY ?? originalY); + result.center = this.unproject.call({ worldSize }, newPoint).wrap(); + } + + return result; + } + + _constrain() { + if (!this.center || !this.width || !this.height || this._constraining) return; + this._constraining = true; + const unmodified = this._unmodified; + const { center, zoom } = this.getConstrained(this.center, this.zoom); + this.center = center; + this.zoom = zoom; + this._unmodified = unmodified; + this._constraining = false; + } + + _calcMatrices() { + if (!this.height) return; + + const halfFov = this._fov / 2; + const offset = this.centerOffset; + const x = this.point.x, + y = this.point.y; + this.cameraToCenterDistance = (0.5 / Math.tan(halfFov)) * this.height; + this._pixelPerMeter = mercatorZfromAltitude(1, this.center.lat) * this.worldSize; + + let m = mat4.identity(new Float64Array(16) as any); + mat4.scale(m, m, [this.width / 2, -this.height / 2, 1]); + mat4.translate(m, m, [1, -1, 0]); + this.labelPlaneMatrix = m; + + m = mat4.identity(new Float64Array(16) as any); + mat4.scale(m, m, [1, -1, 1]); + mat4.translate(m, m, [-1, -1, 0]); + mat4.scale(m, m, [2 / this.width, 2 / this.height, 1]); + this.glCoordMatrix = m; + + // Calculate the camera to sea-level distance in pixel in respect of terrain + const cameraToSeaLevelDistance = + this.cameraToCenterDistance + (this._elevation * this._pixelPerMeter) / Math.cos(this._pitch); + // In case of negative minimum elevation (e.g. the dead see, under the sea maps) use a lower plane for calculation + const minElevation = Math.min(this.elevation, this.minElevationForCurrentTile); + const cameraToLowestPointDistance = + cameraToSeaLevelDistance - (minElevation * this._pixelPerMeter) / Math.cos(this._pitch); + const lowestPlane = minElevation < 0 ? cameraToLowestPointDistance : cameraToSeaLevelDistance; + + // Find the distance from the center point [width/2 + offset.x, height/2 + offset.y] to the + // center top point [width/2 + offset.x, 0] in Z units, using the law of sines. + // 1 Z unit is equivalent to 1 horizontal px at the center of the map + // (the distance between[width/2, height/2] and [width/2 + 1, height/2]) + const groundAngle = Math.PI / 2 + this._pitch; + const fovAboveCenter = this._fov * (0.5 + offset.y / this.height); + const topHalfSurfaceDistance = + (Math.sin(fovAboveCenter) * lowestPlane) / + Math.sin(clamp(Math.PI - groundAngle - fovAboveCenter, 0.01, Math.PI - 0.01)); + + // Find the distance from the center point to the horizon + const horizon = this.getHorizon(); + const horizonAngle = Math.atan(horizon / this.cameraToCenterDistance); + const fovCenterToHorizon = 2 * horizonAngle * (0.5 + offset.y / (horizon * 2)); + const topHalfSurfaceDistanceHorizon = + (Math.sin(fovCenterToHorizon) * lowestPlane) / + Math.sin(clamp(Math.PI - groundAngle - fovCenterToHorizon, 0.01, Math.PI - 0.01)); + + // Calculate z distance of the farthest fragment that should be rendered. + // Add a bit extra to avoid precision problems when a fragment's distance is exactly `furthestDistance` + const topHalfMinDistance = Math.min(topHalfSurfaceDistance, topHalfSurfaceDistanceHorizon); + const farZ = (Math.cos(Math.PI / 2 - this._pitch) * topHalfMinDistance + lowestPlane) * 1.01; + + // The larger the value of nearZ is + // - the more depth precision is available for features (good) + // - clipping starts appearing sooner when the camera is close to 3d features (bad) + // + // Other values work for mapbox-gl-js but deckgl was encountering precision issues + // when rendering custom layers. This value was experimentally chosen and + // seems to solve z-fighting issues in deckgl while not clipping buildings too close to the camera. + const nearZ = this.height / 50; + + // matrix for conversion from location to clip space(-1 .. 1) + m = new Float64Array(16) as any; + mat4.perspective(m, this._fov, this.width / this.height, nearZ, farZ); + + // Apply center of perspective offset + m[8] = (-offset.x * 2) / this.width; + m[9] = (offset.y * 2) / this.height; + + mat4.scale(m, m, [1, -1, 1]); + mat4.translate(m, m, [0, 0, -this.cameraToCenterDistance]); + mat4.rotateX(m, m, this._pitch); + mat4.rotateZ(m, m, this.angle); + mat4.translate(m, m, [-x, -y, 0]); + + // The mercatorMatrix can be used to transform points from mercator coordinates + // ([0, 0] nw, [1, 1] se) to clip space. + this.mercatorMatrix = mat4.scale([] as any, m, [ + this.worldSize, + this.worldSize, + this.worldSize, + ]); + + // scale vertically to meters per pixel (inverse of ground resolution): + mat4.scale(m, m, [1, 1, this._pixelPerMeter]); + + // matrix for conversion from world space to screen coordinates in 2D + this.pixelMatrix = mat4.multiply(new Float64Array(16) as any, this.labelPlaneMatrix, m); + + // matrix for conversion from world space to clip space (-1 .. 1) + mat4.translate(m, m, [0, 0, -this.elevation]); // elevate camera over terrain + this.projMatrix = m; + this.invProjMatrix = mat4.invert([] as any, m); + + // matrix for conversion from world space to screen coordinates in 3D + this.pixelMatrix3D = mat4.multiply(new Float64Array(16) as any, this.labelPlaneMatrix, m); + + // Make a second projection matrix that is aligned to a pixel grid for rendering raster tiles. + // We're rounding the (floating point) x/y values to achieve to avoid rendering raster images to fractional + // coordinates. Additionally, we adjust by half a pixel in either direction in case that viewport dimension + // is an odd integer to preserve rendering to the pixel grid. We're rotating this shift based on the angle + // of the transformation so that 0°, 90°, 180°, and 270° rasters are crisp, and adjust the shift so that + // it is always <= 0.5 pixels. + const xShift = (this.width % 2) / 2, + yShift = (this.height % 2) / 2, + angleCos = Math.cos(this.angle), + angleSin = Math.sin(this.angle), + dx = x - Math.round(x) + angleCos * xShift + angleSin * yShift, + dy = y - Math.round(y) + angleCos * yShift + angleSin * xShift; + const alignedM = new Float64Array(m) as any as mat4; + mat4.translate(alignedM, alignedM, [dx > 0.5 ? dx - 1 : dx, dy > 0.5 ? dy - 1 : dy, 0]); + this.alignedProjMatrix = alignedM; + + // inverse matrix for conversion from screen coordinates to location + m = mat4.invert(new Float64Array(16) as any, this.pixelMatrix); + if (!m) throw new Error('failed to invert matrix'); + this.pixelMatrixInverse = m; + + this._posMatrixCache = {}; + this._alignedPosMatrixCache = {}; + } + + maxPitchScaleFactor() { + // calcMatrices hasn't run yet + if (!this.pixelMatrixInverse) return 1; + + const coord = this.pointCoordinate(new Point(0, 0)); + const p = [coord.x * this.worldSize, coord.y * this.worldSize, 0, 1] as vec4; + const topPoint = vec4.transformMat4(p, p, this.pixelMatrix); + return topPoint[3] / this.cameraToCenterDistance; + } + + /** + * The camera looks at the map from a 3D (lng, lat, altitude) location. Let's use `cameraLocation` + * as the name for the location under the camera and on the surface of the earth (lng, lat, 0). + * `cameraPoint` is the projected position of the `cameraLocation`. + * + * This point is useful to us because only fill-extrusions that are between `cameraPoint` and + * the query point on the surface of the earth can extend and intersect the query. + * + * When the map is not pitched the `cameraPoint` is equivalent to the center of the map because + * the camera is right above the center of the map. + */ + getCameraPoint() { + const pitch = this._pitch; + const yOffset = Math.tan(pitch) * (this.cameraToCenterDistance || 1); + return this.centerPoint.add(new Point(0, yOffset)); + } + + /** + * When the map is pitched, some of the 3D features that intersect a query will not intersect + * the query at the surface of the earth. Instead the feature may be closer and only intersect + * the query because it extrudes into the air. + * @param queryGeometry - For point queries, the line from the query point to the "camera point", + * for other geometries, the envelope of the query geometry and the "camera point" + * @returns a geometry that includes all of the original query as well as all possible ares of the + * screen where the *base* of a visible extrusion could be. + * + */ + getCameraQueryGeometry(queryGeometry: Array): Array { + const c = this.getCameraPoint(); + + if (queryGeometry.length === 1) { + return [queryGeometry[0], c]; + } else { + let minX = c.x; + let minY = c.y; + let maxX = c.x; + let maxY = c.y; + for (const p of queryGeometry) { + minX = Math.min(minX, p.x); + minY = Math.min(minY, p.y); + maxX = Math.max(maxX, p.x); + maxY = Math.max(maxY, p.y); + } + return [ + new Point(minX, minY), + new Point(maxX, minY), + new Point(maxX, maxY), + new Point(minX, maxY), + new Point(minX, minY), + ]; + } + } + /** + * Return the distance to the camera in clip space from a LngLat. + * This can be compared to the value from the depth buffer (terrain.depthAtPoint) + * to determine whether a point is occluded. + * @param lngLat - the point + * @param elevation - the point's elevation + * @returns depth value in clip space (between 0 and 1) + */ + lngLatToCameraDepth(lngLat: LngLat, elevation: number) { + const coord = this.locationCoordinate(lngLat); + const p = [coord.x * this.worldSize, coord.y * this.worldSize, elevation, 1] as vec4; + vec4.transformMat4(p, p, this.projMatrix); + return p[2] / p[3]; + } +} diff --git a/packages/map/src/map/handler/box_zoom.ts b/packages/map/src/map/handler/box_zoom.ts new file mode 100644 index 00000000000..0a627211132 --- /dev/null +++ b/packages/map/src/map/handler/box_zoom.ts @@ -0,0 +1,178 @@ +import { DOM } from '../util/dom'; + +import { Event } from '../util/evented'; +import { TransformProvider } from './transform-provider'; + +import type Point from '@mapbox/point-geometry'; +import type { Handler } from '../handler_manager'; +import type { Map } from '../map'; + +/** + * The `BoxZoomHandler` allows the user to zoom the map to fit within a bounding box. + * The bounding box is defined by clicking and holding `shift` while dragging the cursor. + * + * @group Handlers + */ +export class BoxZoomHandler implements Handler { + _map: Map; + _tr: TransformProvider; + _el: HTMLElement; + _container: HTMLElement; + _enabled: boolean; + _active: boolean; + _startPos: Point; + _lastPos: Point; + _box: HTMLElement; + _clickTolerance: number; + + /** @internal */ + constructor( + map: Map, + options: { + clickTolerance: number; + }, + ) { + this._map = map; + this._tr = new TransformProvider(map); + this._el = map.getCanvasContainer(); + this._container = map.getContainer(); + this._clickTolerance = options.clickTolerance || 1; + } + + /** + * Returns a Boolean indicating whether the "box zoom" interaction is enabled. + * + * @returns `true` if the "box zoom" interaction is enabled. + */ + isEnabled() { + return !!this._enabled; + } + + /** + * Returns a Boolean indicating whether the "box zoom" interaction is active, i.e. currently being used. + * + * @returns `true` if the "box zoom" interaction is active. + */ + isActive() { + return !!this._active; + } + + /** + * Enables the "box zoom" interaction. + * + * @example + * ```ts + * map.boxZoom.enable(); + * ``` + */ + enable() { + if (this.isEnabled()) return; + this._enabled = true; + } + + /** + * Disables the "box zoom" interaction. + * + * @example + * ```ts + * map.boxZoom.disable(); + * ``` + */ + disable() { + if (!this.isEnabled()) return; + this._enabled = false; + } + + mousedown(e: MouseEvent, point: Point) { + if (!this.isEnabled()) return; + if (!(e.shiftKey && e.button === 0)) return; + + DOM.disableDrag(); + this._startPos = this._lastPos = point; + this._active = true; + } + + mousemoveWindow(e: MouseEvent, point: Point) { + if (!this._active) return; + + const pos = point; + + if ( + this._lastPos.equals(pos) || + (!this._box && pos.dist(this._startPos) < this._clickTolerance) + ) { + return; + } + + const p0 = this._startPos; + this._lastPos = pos; + + if (!this._box) { + this._box = DOM.create('div', 'l7-boxzoom', this._container); + this._container.classList.add('l7-crosshair'); + this._fireEvent('boxzoomstart', e); + } + + const minX = Math.min(p0.x, pos.x), + maxX = Math.max(p0.x, pos.x), + minY = Math.min(p0.y, pos.y), + maxY = Math.max(p0.y, pos.y); + + DOM.setTransform(this._box, `translate(${minX}px,${minY}px)`); + + this._box.style.width = `${maxX - minX}px`; + this._box.style.height = `${maxY - minY}px`; + } + + mouseupWindow(e: MouseEvent, point: Point) { + if (!this._active) return; + + if (e.button !== 0) return; + + const p0 = this._startPos, + p1 = point; + + this.reset(); + + DOM.suppressClick(); + + if (p0.x === p1.x && p0.y === p1.y) { + this._fireEvent('boxzoomcancel', e); + } else { + this._map.fire(new Event('boxzoomend', { originalEvent: e })); + return { + cameraAnimation: (map) => + map.fitScreenCoordinates(p0, p1, this._tr.bearing, { linear: true }), + }; + } + } + + keydown(e: KeyboardEvent) { + if (!this._active) return; + + if (e.keyCode === 27) { + this.reset(); + this._fireEvent('boxzoomcancel', e); + } + } + + reset() { + this._active = false; + + this._container.classList.remove('l7-crosshair'); + + if (this._box) { + DOM.remove(this._box); + this._box = null; + } + + DOM.enableDrag(); + + delete this._startPos; + delete this._lastPos; + } + + _fireEvent(type: string, e: any) { + return this._map.fire(new Event(type, { originalEvent: e })); + } +} diff --git a/packages/map/src/map/handler/click_zoom.ts b/packages/map/src/map/handler/click_zoom.ts new file mode 100644 index 00000000000..8502b0746a7 --- /dev/null +++ b/packages/map/src/map/handler/click_zoom.ts @@ -0,0 +1,57 @@ +import type Point from '@mapbox/point-geometry'; +import type { Handler } from '../handler_manager'; +import type { Map } from '../map'; +import { TransformProvider } from './transform-provider'; + +/** + * The `ClickZoomHandler` allows the user to zoom the map at a point by double clicking + * It is used by other handlers + */ +export class ClickZoomHandler implements Handler { + _tr: TransformProvider; + _enabled: boolean; + _active: boolean; + + /** @internal */ + constructor(map: Map) { + this._tr = new TransformProvider(map); + this.reset(); + } + + reset() { + this._active = false; + } + + dblclick(e: MouseEvent, point: Point) { + e.preventDefault(); + return { + cameraAnimation: (map: Map) => { + map.easeTo( + { + duration: 300, + zoom: this._tr.zoom + (e.shiftKey ? -1 : 1), + around: this._tr.unproject(point), + }, + { originalEvent: e }, + ); + }, + }; + } + + enable() { + this._enabled = true; + } + + disable() { + this._enabled = false; + this.reset(); + } + + isEnabled() { + return this._enabled; + } + + isActive() { + return this._active; + } +} diff --git a/packages/map/src/map/handler/cooperative_gestures.ts b/packages/map/src/map/handler/cooperative_gestures.ts new file mode 100644 index 00000000000..d867e629297 --- /dev/null +++ b/packages/map/src/map/handler/cooperative_gestures.ts @@ -0,0 +1,107 @@ +import type { Handler } from '../handler_manager'; +import { DOM } from '../util/dom'; + +import type { Map } from '../map'; + +/** + * The {@link CooperativeGesturesHandler} options object for the gesture settings + */ +export type GestureOptions = boolean; + +/** + * A `CooperativeGestureHandler` is a control that adds cooperative gesture info when user tries to zoom in/out. + * + * @group Handlers + * + * @example + * ```ts + * const map = new Map({ + * cooperativeGestures: true + * }); + * ``` + * @see [Example: cooperative gestures](https://maplibre.org/maplibre-gl-js-docs/example/cooperative-gestures/) + **/ +export class CooperativeGesturesHandler implements Handler { + _options: GestureOptions; + _map: Map; + _container: HTMLElement; + /** + * This is the key that will allow to bypass the cooperative gesture protection + */ + _bypassKey: 'metaKey' | 'ctrlKey' = + navigator.userAgent.indexOf('Mac') !== -1 ? 'metaKey' : 'ctrlKey'; + _enabled: boolean; + + constructor(map: Map, options: GestureOptions) { + this._map = map; + this._options = options; + this._enabled = false; + } + isActive(): boolean { + return false; + } + reset(): void {} + + _setupUI() { + if (this._container) return; + const mapCanvasContainer = this._map.getCanvasContainer(); + // Add a cooperative gestures class (enable touch-action: pan-x pan-y;) + mapCanvasContainer.classList.add('l7-cooperative-gestures'); + this._container = DOM.create('div', 'l7-cooperative-gesture-screen', mapCanvasContainer); + // Create and append the desktop message div + const desktopDiv = document.createElement('div'); + desktopDiv.className = 'l7-desktop-message'; + desktopDiv.textContent = 'Missing UI string'; + this._container.appendChild(desktopDiv); + // Create and append the mobile message div + const mobileDiv = document.createElement('div'); + mobileDiv.className = 'l7-mobile-message'; + mobileDiv.textContent = 'Missing UI string'; + this._container.appendChild(mobileDiv); + // Remove cooperative gesture screen from the accessibility tree since screenreaders cannot interact with the map using gestures + this._container.setAttribute('aria-hidden', 'true'); + } + + _destoryUI() { + if (this._container) { + DOM.remove(this._container); + const mapCanvasContainer = this._map.getCanvasContainer(); + mapCanvasContainer.classList.remove('l7-cooperative-gestures'); + } + delete this._container; + } + + enable() { + this._setupUI(); + this._enabled = true; + } + + disable() { + this._enabled = false; + this._destoryUI(); + } + + isEnabled() { + return this._enabled; + } + + touchmove(e: TouchEvent) { + this._onCooperativeGesture(e.touches.length === 1); + } + + wheel(e: WheelEvent) { + if (!this._map.scrollZoom.isEnabled()) { + return; + } + this._onCooperativeGesture(!e[this._bypassKey]); + } + + _onCooperativeGesture(showNotification: boolean) { + if (!this._enabled || !showNotification) return; + // Alert user how to scroll/pan + this._container.classList.add('l7-show'); + setTimeout(() => { + this._container.classList.remove('l7-show'); + }, 100); + } +} diff --git a/packages/map/src/map/handler/drag_handler.ts b/packages/map/src/map/handler/drag_handler.ts new file mode 100644 index 00000000000..79a2ff16ebd --- /dev/null +++ b/packages/map/src/map/handler/drag_handler.ts @@ -0,0 +1,176 @@ +import type Point from '@mapbox/point-geometry'; +import type { Handler } from '../handler_manager'; +import { DOM } from '../util/dom'; +import type { DragMoveStateManager } from './drag_move_state_manager'; + +interface DragMovementResult { + bearingDelta?: number; + pitchDelta?: number; + around?: Point; + panDelta?: Point; +} + +export interface DragPanResult extends DragMovementResult { + around: Point; + panDelta: Point; +} + +export interface DragRotateResult extends DragMovementResult { + bearingDelta: number; +} + +export interface DragPitchResult extends DragMovementResult { + pitchDelta: number; +} + +type DragMoveFunction = (lastPoint: Point, point: Point) => T; + +export interface DragMoveHandler extends Handler { + dragStart: (e: E, point: Point) => void; + dragMove: (e: E, point: Point) => T | void; + dragEnd: (e: E) => void; + getClickTolerance: () => number; +} + +export type DragMoveHandlerOptions = { + /** + * If the movement is shorter than this value, consider it a click. + */ + clickTolerance: number; + /** + * The move function to run on a valid movement. + */ + move: DragMoveFunction; + /** + * A class used to manage the state of the drag event - start, checking valid moves, end. See the class documentation for more details. + */ + moveStateManager: DragMoveStateManager; + /** + * A method used to assign the dragStart, dragMove, and dragEnd methods to the relevant event handlers, as well as assigning the contextmenu handler + * @param handler - the handler + */ + assignEvents: (handler: DragMoveHandler) => void; + /** + * Should the move start on the "start" event, or should it start on the first valid move. + */ + activateOnStart?: boolean; + /** + * If true, handler will be enabled during construction + */ + enable?: boolean; +}; + +/** + * A generic class to create handlers for drag events, from both mouse and touch events. + */ +export class DragHandler + implements DragMoveHandler +{ + // Event handlers that may be assigned by the implementations of this class + contextmenu?: Handler['contextmenu']; + mousedown?: Handler['mousedown']; + mousemoveWindow?: Handler['mousemoveWindow']; + mouseup?: Handler['mouseup']; + touchstart?: Handler['touchstart']; + touchmoveWindow?: Handler['touchmoveWindow']; + touchend?: Handler['touchend']; + + _clickTolerance: number; + _moveFunction: DragMoveFunction; + _activateOnStart: boolean; + _active: boolean; + _enabled: boolean; + _moved: boolean; + _lastPoint: Point | null; + _moveStateManager: DragMoveStateManager; + + constructor(options: DragMoveHandlerOptions) { + this._enabled = !!options.enable; + this._moveStateManager = options.moveStateManager; + this._clickTolerance = options.clickTolerance || 1; + this._moveFunction = options.move; + this._activateOnStart = !!options.activateOnStart; + + options.assignEvents(this); + + this.reset(); + } + + reset(e?: E) { + this._active = false; + this._moved = false; + delete this._lastPoint; + this._moveStateManager.endMove(e); + } + + _move(...params: Parameters>) { + const move = this._moveFunction(...params); + if (move.bearingDelta || move.pitchDelta || move.around || move.panDelta) { + this._active = true; + return move; + } + } + + dragStart(e: E, point: Point); + dragStart(e: E, point: Point[]); + dragStart(e: E, point: Point | Point[]) { + if (!this.isEnabled() || this._lastPoint) return; + + if (!this._moveStateManager.isValidStartEvent(e)) return; + this._moveStateManager.startMove(e); + + this._lastPoint = point['length'] ? point[0] : point; + + if (this._activateOnStart && this._lastPoint) this._active = true; + } + + dragMove(e: E, point: Point); + dragMove(e: E, point: Point[]); + dragMove(e: E, point: Point | Point[]) { + if (!this.isEnabled()) return; + const lastPoint = this._lastPoint; + if (!lastPoint) return; + e.preventDefault(); + + if (!this._moveStateManager.isValidMoveEvent(e)) { + this.reset(e); + return; + } + + const movePoint = point['length'] ? point[0] : point; + + if (!this._moved && movePoint.dist(lastPoint) < this._clickTolerance) return; + this._moved = true; + this._lastPoint = movePoint; + + return this._move(lastPoint, movePoint); + } + + dragEnd(e: E) { + if (!this.isEnabled() || !this._lastPoint) return; + if (!this._moveStateManager.isValidEndEvent(e)) return; + if (this._moved) DOM.suppressClick(); + this.reset(e); + } + + enable() { + this._enabled = true; + } + + disable() { + this._enabled = false; + this.reset(); + } + + isEnabled() { + return this._enabled; + } + + isActive() { + return this._active; + } + + getClickTolerance() { + return this._clickTolerance; + } +} diff --git a/packages/map/src/map/handler/drag_move_state_manager.ts b/packages/map/src/map/handler/drag_move_state_manager.ts new file mode 100644 index 00000000000..c098aeb806c --- /dev/null +++ b/packages/map/src/map/handler/drag_move_state_manager.ts @@ -0,0 +1,115 @@ +import { DOM } from '../util/dom'; + +const LEFT_BUTTON = 0; +const RIGHT_BUTTON = 2; + +// the values for each button in MouseEvent.buttons +const BUTTONS_FLAGS = { + [LEFT_BUTTON]: 1, + [RIGHT_BUTTON]: 2, +}; + +function buttonNoLongerPressed(e: MouseEvent, button: number) { + const flag = BUTTONS_FLAGS[button]; + return e.buttons === undefined || (e.buttons & flag) !== flag; +} + +/* + * Drag events are initiated by specific interaction which needs to be tracked until it ends. + * This requires some state management: + * 1. registering the initiating event, + * 2. tracking that it was not canceled / not confusing it with another event firing. + * 3. recognizing the ending event and cleaning up any internal state + * + * Concretely, we implement two state managers: + * 1. MouseMoveStateManager + * Receives a functions that is used to recognize mouse events that should be registered as the + * relevant drag interactions - i.e. dragging with the right mouse button, or while CTRL is pressed. + * 2. OneFingerTouchMoveStateManager + * Checks if a drag event is using one finger, and continuously tracking that this is the same event + * (i.e. to make sure not additional finger has started interacting with the screen before raising + * the first finger). + */ +export interface DragMoveStateManager { + startMove: (e: E) => void; + endMove: (e?: E) => void; + isValidStartEvent: (e: E) => boolean; + isValidMoveEvent: (e: E) => boolean; + isValidEndEvent: (e?: E) => boolean; +} + +export class MouseMoveStateManager implements DragMoveStateManager { + _eventButton: number | undefined; + _correctEvent: (e: MouseEvent) => boolean; + + constructor(options: { checkCorrectEvent: (e: MouseEvent) => boolean }) { + this._correctEvent = options.checkCorrectEvent; + } + + startMove(e: MouseEvent) { + const eventButton = DOM.mouseButton(e); + this._eventButton = eventButton; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + endMove(_e?: MouseEvent) { + delete this._eventButton; + } + + isValidStartEvent(e: MouseEvent) { + return this._correctEvent(e); + } + + isValidMoveEvent(e: MouseEvent) { + // Some browsers don't fire a `mouseup` when the mouseup occurs outside + // the window or iframe: + // https://github.com/mapbox/mapbox-gl-js/issues/4622 + // + // If the button is no longer pressed during this `mousemove` it may have + // been released outside of the window or iframe. + return !buttonNoLongerPressed(e, this._eventButton); + } + + isValidEndEvent(e: MouseEvent) { + const eventButton = DOM.mouseButton(e); + return eventButton === this._eventButton; + } +} + +export class OneFingerTouchMoveStateManager implements DragMoveStateManager { + _firstTouch: number | undefined; + + constructor() { + this._firstTouch = undefined; + } + + _isOneFingerTouch(e: TouchEvent) { + return e.targetTouches.length === 1; + } + + _isSameTouchEvent(e: TouchEvent) { + return e.targetTouches[0].identifier === this._firstTouch; + } + + startMove(e: TouchEvent) { + const firstTouch = e.targetTouches[0].identifier; + this._firstTouch = firstTouch; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + endMove(_e?: TouchEvent) { + delete this._firstTouch; + } + + isValidStartEvent(e: TouchEvent) { + return this._isOneFingerTouch(e); + } + + isValidMoveEvent(e: TouchEvent) { + return this._isOneFingerTouch(e) && this._isSameTouchEvent(e); + } + + isValidEndEvent(e: TouchEvent) { + return this._isOneFingerTouch(e) && this._isSameTouchEvent(e); + } +} diff --git a/packages/map/src/map/handler/handler_util.ts b/packages/map/src/map/handler/handler_util.ts new file mode 100644 index 00000000000..a653968e673 --- /dev/null +++ b/packages/map/src/map/handler/handler_util.ts @@ -0,0 +1,13 @@ +import type Point from '@mapbox/point-geometry'; + +export function indexTouches(touches: Array, points: Array) { + if (touches.length !== points.length) + throw new Error( + `The number of touches and points are not equal - touches ${touches.length}, points ${points.length}`, + ); + const obj = {}; + for (let i = 0; i < touches.length; i++) { + obj[touches[i].identifier] = points[i]; + } + return obj; +} diff --git a/packages/map/src/map/handler/keyboard.ts b/packages/map/src/map/handler/keyboard.ts new file mode 100644 index 00000000000..06afe16eaf4 --- /dev/null +++ b/packages/map/src/map/handler/keyboard.ts @@ -0,0 +1,215 @@ +import type { Handler } from '../handler_manager'; +import type { Map } from '../map'; +import { TransformProvider } from './transform-provider'; + +const defaultOptions = { + panStep: 100, + bearingStep: 15, + pitchStep: 10, +}; + +/** + * The `KeyboardHandler` allows the user to zoom, rotate, and pan the map using + * the following keyboard shortcuts: + * + * - `=` / `+`: Increase the zoom level by 1. + * - `Shift-=` / `Shift-+`: Increase the zoom level by 2. + * - `-`: Decrease the zoom level by 1. + * - `Shift--`: Decrease the zoom level by 2. + * - Arrow keys: Pan by 100 pixels. + * - `Shift+⇢`: Increase the rotation by 15 degrees. + * - `Shift+⇠`: Decrease the rotation by 15 degrees. + * - `Shift+⇡`: Increase the pitch by 10 degrees. + * - `Shift+⇣`: Decrease the pitch by 10 degrees. + * + * @group Handlers + */ +export class KeyboardHandler implements Handler { + _tr: TransformProvider; + _enabled: boolean; + _active: boolean; + _panStep: number; + _bearingStep: number; + _pitchStep: number; + _rotationDisabled: boolean; + + /** @internal */ + constructor(map: Map) { + this._tr = new TransformProvider(map); + const stepOptions = defaultOptions; + this._panStep = stepOptions.panStep; + this._bearingStep = stepOptions.bearingStep; + this._pitchStep = stepOptions.pitchStep; + this._rotationDisabled = false; + } + + reset() { + this._active = false; + } + + keydown(e: KeyboardEvent) { + if (e.altKey || e.ctrlKey || e.metaKey) return; + + let zoomDir = 0; + let bearingDir = 0; + let pitchDir = 0; + let xDir = 0; + let yDir = 0; + + switch (e.keyCode) { + case 61: + case 107: + case 171: + case 187: + zoomDir = 1; + break; + + case 189: + case 109: + case 173: + zoomDir = -1; + break; + + case 37: + if (e.shiftKey) { + bearingDir = -1; + } else { + e.preventDefault(); + xDir = -1; + } + break; + + case 39: + if (e.shiftKey) { + bearingDir = 1; + } else { + e.preventDefault(); + xDir = 1; + } + break; + + case 38: + if (e.shiftKey) { + pitchDir = 1; + } else { + e.preventDefault(); + yDir = -1; + } + break; + + case 40: + if (e.shiftKey) { + pitchDir = -1; + } else { + e.preventDefault(); + yDir = 1; + } + break; + + default: + return; + } + + if (this._rotationDisabled) { + bearingDir = 0; + pitchDir = 0; + } + + return { + cameraAnimation: (map: Map) => { + const tr = this._tr; + map.easeTo( + { + duration: 300, + easeId: 'keyboardHandler', + easing: easeOut, + + zoom: zoomDir ? Math.round(tr.zoom) + zoomDir * (e.shiftKey ? 2 : 1) : tr.zoom, + bearing: tr.bearing + bearingDir * this._bearingStep, + pitch: tr.pitch + pitchDir * this._pitchStep, + offset: [-xDir * this._panStep, -yDir * this._panStep], + center: tr.center, + }, + { originalEvent: e }, + ); + }, + }; + } + + /** + * Enables the "keyboard rotate and zoom" interaction. + * + * @example + * ```ts + * map.keyboard.enable(); + * ``` + */ + enable() { + this._enabled = true; + } + + /** + * Disables the "keyboard rotate and zoom" interaction. + * + * @example + * ```ts + * map.keyboard.disable(); + * ``` + */ + disable() { + this._enabled = false; + this.reset(); + } + + /** + * Returns a Boolean indicating whether the "keyboard rotate and zoom" + * interaction is enabled. + * + * @returns `true` if the "keyboard rotate and zoom" + * interaction is enabled. + */ + isEnabled() { + return this._enabled; + } + + /** + * Returns true if the handler is enabled and has detected the start of a + * zoom/rotate gesture. + * + * @returns `true` if the handler is enabled and has detected the + * start of a zoom/rotate gesture. + */ + isActive() { + return this._active; + } + + /** + * Disables the "keyboard pan/rotate" interaction, leaving the + * "keyboard zoom" interaction enabled. + * + * @example + * ```ts + * map.keyboard.disableRotation(); + * ``` + */ + disableRotation() { + this._rotationDisabled = true; + } + + /** + * Enables the "keyboard pan/rotate" interaction. + * + * @example + * ```ts + * map.keyboard.enable(); + * map.keyboard.enableRotation(); + * ``` + */ + enableRotation() { + this._rotationDisabled = false; + } +} + +function easeOut(t: number) { + return t * (2 - t); +} diff --git a/packages/map/src/map/handler/map_event.ts b/packages/map/src/map/handler/map_event.ts new file mode 100644 index 00000000000..65ca90c787e --- /dev/null +++ b/packages/map/src/map/handler/map_event.ts @@ -0,0 +1,163 @@ +import type Point from '@mapbox/point-geometry'; +import { MapMouseEvent, MapTouchEvent, MapWheelEvent } from '../events'; +import type { Handler } from '../handler_manager'; +import type { Map } from '../map'; + +export class MapEventHandler implements Handler { + _mousedownPos: Point; + _clickTolerance: number; + _map: Map; + + constructor( + map: Map, + options: { + clickTolerance: number; + }, + ) { + this._map = map; + this._clickTolerance = options.clickTolerance; + } + + reset() { + delete this._mousedownPos; + } + + wheel(e: WheelEvent) { + // If mapEvent.preventDefault() is called by the user, prevent handlers such as: + // - ScrollZoom + return this._firePreventable(new MapWheelEvent(e.type, this._map, e)); + } + + mousedown(e: MouseEvent, point: Point) { + this._mousedownPos = point; + // If mapEvent.preventDefault() is called by the user, prevent handlers such as: + // - MousePan + // - MouseRotate + // - MousePitch + // - DblclickHandler + return this._firePreventable(new MapMouseEvent(e.type, this._map, e)); + } + + mouseup(e: MouseEvent) { + this._map.fire(new MapMouseEvent(e.type, this._map, e)); + } + + click(e: MouseEvent, point: Point) { + if (this._mousedownPos && this._mousedownPos.dist(point) >= this._clickTolerance) return; + this._map.fire(new MapMouseEvent(e.type, this._map, e)); + } + + dblclick(e: MouseEvent) { + // If mapEvent.preventDefault() is called by the user, prevent handlers such as: + // - DblClickZoom + return this._firePreventable(new MapMouseEvent(e.type, this._map, e)); + } + + mouseover(e: MouseEvent) { + this._map.fire(new MapMouseEvent(e.type, this._map, e)); + } + + mouseout(e: MouseEvent) { + this._map.fire(new MapMouseEvent(e.type, this._map, e)); + } + + touchstart(e: TouchEvent) { + // If mapEvent.preventDefault() is called by the user, prevent handlers such as: + // - TouchPan + // - TouchZoom + // - TouchRotate + // - TouchPitch + // - TapZoom + // - SwipeZoom + return this._firePreventable(new MapTouchEvent(e.type, this._map, e)); + } + + touchmove(e: TouchEvent) { + this._map.fire(new MapTouchEvent(e.type, this._map, e)); + } + + touchend(e: TouchEvent) { + this._map.fire(new MapTouchEvent(e.type, this._map, e)); + } + + touchcancel(e: TouchEvent) { + this._map.fire(new MapTouchEvent(e.type, this._map, e)); + } + + _firePreventable(mapEvent: MapMouseEvent | MapTouchEvent | MapWheelEvent) { + this._map.fire(mapEvent); + if (mapEvent.defaultPrevented) { + // returning an object marks the handler as active and resets other handlers + return {}; + } + } + + isEnabled() { + return true; + } + + isActive() { + return false; + } + enable() {} + disable() {} +} + +export class BlockableMapEventHandler { + _map: Map; + _delayContextMenu: boolean; + _ignoreContextMenu: boolean; + _contextMenuEvent: MouseEvent; + + constructor(map: Map) { + this._map = map; + } + + reset() { + this._delayContextMenu = false; + this._ignoreContextMenu = true; + delete this._contextMenuEvent; + } + + mousemove(e: MouseEvent) { + // mousemove map events should not be fired when interaction handlers (pan, rotate, etc) are active + this._map.fire(new MapMouseEvent(e.type, this._map, e)); + } + + mousedown() { + this._delayContextMenu = true; + this._ignoreContextMenu = false; + } + + mouseup() { + this._delayContextMenu = false; + if (this._contextMenuEvent) { + this._map.fire(new MapMouseEvent('contextmenu', this._map, this._contextMenuEvent)); + delete this._contextMenuEvent; + } + } + contextmenu(e: MouseEvent) { + if (this._delayContextMenu) { + // Mac: contextmenu fired on mousedown; we save it until mouseup for consistency's sake + this._contextMenuEvent = e; + } else if (!this._ignoreContextMenu) { + // Windows: contextmenu fired on mouseup, so fire event now + this._map.fire(new MapMouseEvent(e.type, this._map, e)); + } + + // prevent browser context menu when necessary + if (this._map.listens('contextmenu')) { + e.preventDefault(); + } + } + + isEnabled() { + return true; + } + + isActive() { + return false; + } + enable() {} + disable() {} +} diff --git a/packages/map/src/map/handler/mouse.ts b/packages/map/src/map/handler/mouse.ts new file mode 100644 index 00000000000..31bbf8d3657 --- /dev/null +++ b/packages/map/src/map/handler/mouse.ts @@ -0,0 +1,108 @@ +import type Point from '@mapbox/point-geometry'; + +import { DOM } from '../util/dom'; +import type { + DragMoveHandler, + DragPanResult, + DragPitchResult, + DragRotateResult, +} from './drag_handler'; +import { DragHandler } from './drag_handler'; +import { MouseMoveStateManager } from './drag_move_state_manager'; + +/** + * `MousePanHandler` allows the user to pan the map by clicking and dragging + */ +export interface MousePanHandler extends DragMoveHandler {} +/** + * `MouseRotateHandler` allows the user to rotate the map by clicking and dragging + */ +export interface MouseRotateHandler extends DragMoveHandler {} +/** + * `MousePitchHandler` allows the user to zoom the map by pitching + */ +export interface MousePitchHandler extends DragMoveHandler {} + +const LEFT_BUTTON = 0; +const RIGHT_BUTTON = 2; + +const assignEvents = (handler: DragHandler) => { + handler.mousedown = handler.dragStart; + handler.mousemoveWindow = handler.dragMove; + handler.mouseup = handler.dragEnd; + handler.contextmenu = (e: MouseEvent) => { + e.preventDefault(); + }; +}; + +export const generateMousePanHandler = ({ + enable, + clickTolerance, +}: { + clickTolerance: number; + enable?: boolean; +}): MousePanHandler => { + const mouseMoveStateManager = new MouseMoveStateManager({ + checkCorrectEvent: (e: MouseEvent) => DOM.mouseButton(e) === LEFT_BUTTON && !e.ctrlKey, + }); + return new DragHandler({ + clickTolerance, + move: (lastPoint: Point, point: Point) => ({ around: point, panDelta: point.sub(lastPoint) }), + activateOnStart: true, + moveStateManager: mouseMoveStateManager, + enable, + assignEvents, + }); +}; + +export const generateMouseRotationHandler = ({ + enable, + clickTolerance, + bearingDegreesPerPixelMoved = 0.8, +}: { + clickTolerance: number; + bearingDegreesPerPixelMoved?: number; + enable?: boolean; +}): MouseRotateHandler => { + const mouseMoveStateManager = new MouseMoveStateManager({ + checkCorrectEvent: (e: MouseEvent): boolean => + (DOM.mouseButton(e) === LEFT_BUTTON && e.ctrlKey) || DOM.mouseButton(e) === RIGHT_BUTTON, + }); + return new DragHandler({ + clickTolerance, + move: (lastPoint: Point, point: Point) => ({ + bearingDelta: (point.x - lastPoint.x) * bearingDegreesPerPixelMoved, + }), + // prevent browser context menu when necessary; we don't allow it with rotation + // because we can't discern rotation gesture start from contextmenu on Mac + moveStateManager: mouseMoveStateManager, + enable, + assignEvents, + }); +}; + +export const generateMousePitchHandler = ({ + enable, + clickTolerance, + pitchDegreesPerPixelMoved = -0.5, +}: { + clickTolerance: number; + pitchDegreesPerPixelMoved?: number; + enable?: boolean; +}): MousePitchHandler => { + const mouseMoveStateManager = new MouseMoveStateManager({ + checkCorrectEvent: (e: MouseEvent): boolean => + (DOM.mouseButton(e) === LEFT_BUTTON && e.ctrlKey) || DOM.mouseButton(e) === RIGHT_BUTTON, + }); + return new DragHandler({ + clickTolerance, + move: (lastPoint: Point, point: Point) => ({ + pitchDelta: (point.y - lastPoint.y) * pitchDegreesPerPixelMoved, + }), + // prevent browser context menu when necessary; we don't allow it with rotation + // because we can't discern rotation gesture start from contextmenu on Mac + moveStateManager: mouseMoveStateManager, + enable, + assignEvents, + }); +}; diff --git a/packages/map/src/map/handler/one_finger_touch_drag.ts b/packages/map/src/map/handler/one_finger_touch_drag.ts new file mode 100644 index 00000000000..1d085c455a3 --- /dev/null +++ b/packages/map/src/map/handler/one_finger_touch_drag.ts @@ -0,0 +1,57 @@ +import type Point from '@mapbox/point-geometry'; + +import type { DragMoveHandler, DragPitchResult, DragRotateResult } from './drag_handler'; +import { DragHandler } from './drag_handler'; +import { OneFingerTouchMoveStateManager } from './drag_move_state_manager'; + +export interface OneFingerTouchRotateHandler + extends DragMoveHandler {} +export interface OneFingerTouchPitchHandler extends DragMoveHandler {} + +const assignEvents = (handler: DragHandler) => { + handler.touchstart = handler.dragStart; + handler.touchmoveWindow = handler.dragMove; + handler.touchend = handler.dragEnd; +}; + +export const generateOneFingerTouchRotationHandler = ({ + enable, + clickTolerance, + bearingDegreesPerPixelMoved = 0.8, +}: { + clickTolerance: number; + bearingDegreesPerPixelMoved?: number; + enable?: boolean; +}): OneFingerTouchRotateHandler => { + const touchMoveStateManager = new OneFingerTouchMoveStateManager(); + return new DragHandler({ + clickTolerance, + move: (lastPoint: Point, point: Point) => ({ + bearingDelta: (point.x - lastPoint.x) * bearingDegreesPerPixelMoved, + }), + moveStateManager: touchMoveStateManager, + enable, + assignEvents, + }); +}; + +export const generateOneFingerTouchPitchHandler = ({ + enable, + clickTolerance, + pitchDegreesPerPixelMoved = -0.5, +}: { + clickTolerance: number; + pitchDegreesPerPixelMoved?: number; + enable?: boolean; +}): OneFingerTouchPitchHandler => { + const touchMoveStateManager = new OneFingerTouchMoveStateManager(); + return new DragHandler({ + clickTolerance, + move: (lastPoint: Point, point: Point) => ({ + pitchDelta: (point.y - lastPoint.y) * pitchDegreesPerPixelMoved, + }), + moveStateManager: touchMoveStateManager, + enable, + assignEvents, + }); +}; diff --git a/packages/map/src/map/handler/scroll_zoom.ts b/packages/map/src/map/handler/scroll_zoom.ts new file mode 100644 index 00000000000..4d612e1df25 --- /dev/null +++ b/packages/map/src/map/handler/scroll_zoom.ts @@ -0,0 +1,363 @@ +import { DOM } from '../util/dom'; + +import { LngLat } from '../geo/lng_lat'; +import { browser } from '../util/browser'; +import { bezier, defaultEasing, interpolates } from '../util/util'; +import { TransformProvider } from './transform-provider'; + +import type Point from '@mapbox/point-geometry'; +import type { Handler } from '../handler_manager'; +import type { Map } from '../map'; +import type { AroundCenterOptions } from './two_fingers_touch'; + +// deltaY value for mouse scroll wheel identification +const wheelZoomDelta = 4.000244140625; + +// These magic numbers control the rate of zoom. Trackpad events fire at a greater +// frequency than mouse scroll wheel, so reduce the zoom rate per wheel tick +const defaultZoomRate = 1 / 100; +const wheelZoomRate = 1 / 450; + +// upper bound on how much we scale the map in any single render frame; this +// is used to limit zoom rate in the case of very fast scrolling +const maxScalePerFrame = 2; + +/** + * The `ScrollZoomHandler` allows the user to zoom the map by scrolling. + * + * @group Handlers + */ +export class ScrollZoomHandler implements Handler { + _map: Map; + _tr: TransformProvider; + _enabled: boolean; + _active: boolean; + _zooming: boolean; + _aroundCenter: boolean; + _around: LngLat; + _aroundPoint: Point; + _type: 'wheel' | 'trackpad' | null; + _lastValue: number; + _timeout: ReturnType; // used for delayed-handling of a single wheel movement + _finishTimeout: ReturnType; // used to delay final '{move,zoom}end' events + + _lastWheelEvent: any; + _lastWheelEventTime: number; + + _startZoom: number; + _targetZoom: number; + _delta: number; + _easing: (a: number) => number; + _prevEase: { + start: number; + duration: number; + easing: (_: number) => number; + }; + + _frameId: boolean; + _triggerRenderFrame: () => void; + + _defaultZoomRate: number; + _wheelZoomRate: number; + + /** @internal */ + constructor(map: Map, triggerRenderFrame: () => void) { + this._map = map; + this._tr = new TransformProvider(map); + this._triggerRenderFrame = triggerRenderFrame; + + this._delta = 0; + + this._defaultZoomRate = defaultZoomRate; + this._wheelZoomRate = wheelZoomRate; + } + + /** + * Set the zoom rate of a trackpad + * @param zoomRate - 1/100 The rate used to scale trackpad movement to a zoom value. + * @example + * Speed up trackpad zoom + * ```ts + * map.scrollZoom.setZoomRate(1/25); + * ``` + */ + setZoomRate(zoomRate: number) { + this._defaultZoomRate = zoomRate; + } + + /** + * Set the zoom rate of a mouse wheel + * @param wheelZoomRate - 1/450 The rate used to scale mouse wheel movement to a zoom value. + * @example + * Slow down zoom of mouse wheel + * ```ts + * map.scrollZoom.setWheelZoomRate(1/600); + * ``` + */ + setWheelZoomRate(wheelZoomRate: number) { + this._wheelZoomRate = wheelZoomRate; + } + + /** + * Returns a Boolean indicating whether the "scroll to zoom" interaction is enabled. + * @returns `true` if the "scroll to zoom" interaction is enabled. + */ + isEnabled() { + return !!this._enabled; + } + + /* + * Active state is turned on and off with every scroll wheel event and is set back to false before the map + * render is called, so _active is not a good candidate for determining if a scroll zoom animation is in + * progress. + */ + isActive() { + return !!this._active || this._finishTimeout !== undefined; + } + + isZooming() { + return !!this._zooming; + } + + /** + * Enables the "scroll to zoom" interaction. + * + * @param options - Options object. + * @example + * ```ts + * map.scrollZoom.enable(); + * map.scrollZoom.enable({ around: 'center' }) + * ``` + */ + enable(options?: AroundCenterOptions | boolean) { + if (this.isEnabled()) return; + this._enabled = true; + this._aroundCenter = !!options && (options as AroundCenterOptions).around === 'center'; + } + + /** + * Disables the "scroll to zoom" interaction. + * + * @example + * ```ts + * map.scrollZoom.disable(); + * ``` + */ + disable() { + if (!this.isEnabled()) return; + this._enabled = false; + } + + wheel(e: WheelEvent) { + if (!this.isEnabled()) return; + if (this._map.cooperativeGestures.isEnabled() && !e[this._map.cooperativeGestures._bypassKey]) { + return; + } + let value = e.deltaMode === WheelEvent.DOM_DELTA_LINE ? e.deltaY * 40 : e.deltaY; + const now = browser.now(), + timeDelta = now - (this._lastWheelEventTime || 0); + + this._lastWheelEventTime = now; + + if (value !== 0 && value % wheelZoomDelta === 0) { + // This one is definitely a mouse wheel event. + this._type = 'wheel'; + } else if (value !== 0 && Math.abs(value) < 4) { + // This one is definitely a trackpad event because it is so small. + this._type = 'trackpad'; + } else if (timeDelta > 400) { + // This is likely a new scroll action. + this._type = null; + this._lastValue = value; + + // Start a timeout in case this was a singular event, and dely it by up to 40ms. + this._timeout = setTimeout(this._onTimeout, 40, e); + } else if (!this._type) { + // This is a repeating event, but we don't know the type of event just yet. + // If the delta per time is small, we assume it's a fast trackpad; otherwise we switch into wheel mode. + this._type = Math.abs(timeDelta * value) < 200 ? 'trackpad' : 'wheel'; + + // Make sure our delayed event isn't fired again, because we accumulate + // the previous event (which was less than 40ms ago) into this event. + if (this._timeout) { + clearTimeout(this._timeout); + this._timeout = null; + value += this._lastValue; + } + } + + // Slow down zoom if shift key is held for more precise zooming + if (e.shiftKey && value) value = value / 4; + + // Only fire the callback if we actually know what type of scrolling device the user uses. + if (this._type) { + this._lastWheelEvent = e; + this._delta -= value; + if (!this._active) { + this._start(e); + } + } + + e.preventDefault(); + } + + _onTimeout = (initialEvent: MouseEvent) => { + this._type = 'wheel'; + this._delta -= this._lastValue; + if (!this._active) { + this._start(initialEvent); + } + }; + + _start(e: MouseEvent) { + if (!this._delta) return; + + if (this._frameId) { + this._frameId = null; + } + + this._active = true; + if (!this.isZooming()) { + this._zooming = true; + } + + if (this._finishTimeout) { + clearTimeout(this._finishTimeout); + delete this._finishTimeout; + } + + const pos = DOM.mousePos(this._map.getCanvasContainer(), e); + const tr = this._tr; + + if (pos.y > tr.transform.height / 2 - tr.transform.getHorizon()) { + this._around = LngLat.convert(this._aroundCenter ? tr.center : tr.unproject(pos)); + } else { + // Do not use current cursor position if above the horizon to avoid 'unproject' this point + // as it is not mapped into 'coords' framebuffer or inversible with 'pixelMatrixInverse'. + this._around = LngLat.convert(tr.center); + } + + this._aroundPoint = tr.transform.locationPoint(this._around); + if (!this._frameId) { + this._frameId = true; + this._triggerRenderFrame(); + } + } + + renderFrame() { + if (!this._frameId) return; + this._frameId = null; + + if (!this.isActive()) return; + const tr = this._tr.transform; + + // if we've had scroll events since the last render frame, consume the + // accumulated delta, and update the target zoom level accordingly + if (this._delta !== 0) { + // For trackpad events and single mouse wheel ticks, use the default zoom rate + const zoomRate = + this._type === 'wheel' && Math.abs(this._delta) > wheelZoomDelta + ? this._wheelZoomRate + : this._defaultZoomRate; + // Scale by sigmoid of scroll wheel delta. + let scale = maxScalePerFrame / (1 + Math.exp(-Math.abs(this._delta * zoomRate))); + + if (this._delta < 0 && scale !== 0) { + scale = 1 / scale; + } + + const fromScale = + typeof this._targetZoom === 'number' ? tr.zoomScale(this._targetZoom) : tr.scale; + this._targetZoom = Math.min( + tr.maxZoom, + Math.max(tr.minZoom, tr.scaleZoom(fromScale * scale)), + ); + + // if this is a mouse wheel, refresh the starting zoom and easing + // function we're using to smooth out the zooming between wheel + // events + if (this._type === 'wheel') { + this._startZoom = tr.zoom; + this._easing = this._smoothOutEasing(200); + } + + this._delta = 0; + } + + const targetZoom = typeof this._targetZoom === 'number' ? this._targetZoom : tr.zoom; + const startZoom = this._startZoom; + const easing = this._easing; + + let finished = false; + let zoom; + if (this._type === 'wheel' && startZoom && easing) { + const t = Math.min((browser.now() - this._lastWheelEventTime) / 200, 1); + const k = easing(t); + zoom = interpolates.number(startZoom, targetZoom, k); + if (t < 1) { + if (!this._frameId) { + this._frameId = true; + } + } else { + finished = true; + } + } else { + zoom = targetZoom; + finished = true; + } + + this._active = true; + + if (finished) { + this._active = false; + this._finishTimeout = setTimeout(() => { + this._zooming = false; + this._triggerRenderFrame(); + delete this._targetZoom; + delete this._finishTimeout; + }, 200); + } + + return { + noInertia: true, + needsRenderFrame: !finished, + zoomDelta: zoom - tr.zoom, + around: this._aroundPoint, + originalEvent: this._lastWheelEvent, + }; + } + + _smoothOutEasing(duration: number) { + let easing = defaultEasing; + + if (this._prevEase) { + const currentEase = this._prevEase; + const t = (browser.now() - currentEase.start) / currentEase.duration; + const speed = currentEase.easing(t + 0.01) - currentEase.easing(t); + + // Quick hack to make new bezier that is continuous with last + const x = (0.27 / Math.sqrt(speed * speed + 0.0001)) * 0.01; + const y = Math.sqrt(0.27 * 0.27 - x * x); + + easing = bezier(x, y, 0.25, 1); + } + + this._prevEase = { + start: browser.now(), + duration, + easing, + }; + + return easing; + } + + reset() { + this._active = false; + this._zooming = false; + delete this._targetZoom; + if (this._finishTimeout) { + clearTimeout(this._finishTimeout); + delete this._finishTimeout; + } + } +} diff --git a/packages/map/src/map/handler/shim/dblclick_zoom.ts b/packages/map/src/map/handler/shim/dblclick_zoom.ts new file mode 100644 index 00000000000..ee977c7b812 --- /dev/null +++ b/packages/map/src/map/handler/shim/dblclick_zoom.ts @@ -0,0 +1,63 @@ +import type { ClickZoomHandler } from '../click_zoom'; +import type { TapZoomHandler } from '../tap_zoom'; + +/** + * The `DoubleClickZoomHandler` allows the user to zoom the map at a point by + * double clicking or double tapping. + * + * @group Handlers + */ +export class DoubleClickZoomHandler { + _clickZoom: ClickZoomHandler; + _tapZoom: TapZoomHandler; + + /** @internal */ + constructor(clickZoom: ClickZoomHandler, TapZoom: TapZoomHandler) { + this._clickZoom = clickZoom; + this._tapZoom = TapZoom; + } + + /** + * Enables the "double click to zoom" interaction. + * + * @example + * ```ts + * map.doubleClickZoom.enable(); + * ``` + */ + enable() { + this._clickZoom.enable(); + this._tapZoom.enable(); + } + + /** + * Disables the "double click to zoom" interaction. + * + * @example + * ```ts + * map.doubleClickZoom.disable(); + * ``` + */ + disable() { + this._clickZoom.disable(); + this._tapZoom.disable(); + } + + /** + * Returns a Boolean indicating whether the "double click to zoom" interaction is enabled. + * + * @returns `true` if the "double click to zoom" interaction is enabled. + */ + isEnabled() { + return this._clickZoom.isEnabled() && this._tapZoom.isEnabled(); + } + + /** + * Returns a Boolean indicating whether the "double click to zoom" interaction is active, i.e. currently being used. + * + * @returns `true` if the "double click to zoom" interaction is active. + */ + isActive() { + return this._clickZoom.isActive() || this._tapZoom.isActive(); + } +} diff --git a/packages/map/src/map/handler/shim/drag_pan.ts b/packages/map/src/map/handler/shim/drag_pan.ts new file mode 100644 index 00000000000..271fd4129e1 --- /dev/null +++ b/packages/map/src/map/handler/shim/drag_pan.ts @@ -0,0 +1,103 @@ +import type { MousePanHandler } from '../mouse'; +import type { TouchPanHandler } from '../touch_pan'; + +/** + * A {@link DragPanHandler} options object + */ +export type DragPanOptions = { + /** + * factor used to scale the drag velocity + * @defaultValue 0 + */ + linearity?: number; + /** + * easing function applied to `map.panTo` when applying the drag. + * @param t - the easing function + * @defaultValue bezier(0, 0, 0.3, 1) + */ + easing?: (t: number) => number; + /** + * the maximum value of the drag velocity. + * @defaultValue 1400 + */ + deceleration?: number; + /** + * the rate at which the speed reduces after the pan ends. + * @defaultValue 2500 + */ + maxSpeed?: number; +}; + +/** + * The `DragPanHandler` allows the user to pan the map by clicking and dragging + * the cursor. + * + * @group Handlers + */ +export class DragPanHandler { + _el: HTMLElement; + _mousePan: MousePanHandler; + _touchPan: TouchPanHandler; + _inertiaOptions: DragPanOptions | boolean; + + /** @internal */ + constructor(el: HTMLElement, mousePan: MousePanHandler, touchPan: TouchPanHandler) { + this._el = el; + this._mousePan = mousePan; + this._touchPan = touchPan; + } + + /** + * Enables the "drag to pan" interaction. + * + * @param options - Options object + * @example + * ```ts + * map.dragPan.enable(); + * map.dragPan.enable({ + * linearity: 0.3, + * easing: bezier(0, 0, 0.3, 1), + * maxSpeed: 1400, + * deceleration: 2500, + * }); + * ``` + */ + enable(options?: DragPanOptions | boolean) { + this._inertiaOptions = options || {}; + this._mousePan.enable(); + this._touchPan.enable(); + this._el.classList.add('l7-touch-drag-pan'); + } + + /** + * Disables the "drag to pan" interaction. + * + * @example + * ```ts + * map.dragPan.disable(); + * ``` + */ + disable() { + this._mousePan.disable(); + this._touchPan.disable(); + this._el.classList.remove('l7-touch-drag-pan'); + } + + /** + * Returns a Boolean indicating whether the "drag to pan" interaction is enabled. + * + * @returns `true` if the "drag to pan" interaction is enabled. + */ + isEnabled() { + return this._mousePan.isEnabled() && this._touchPan.isEnabled(); + } + + /** + * Returns a Boolean indicating whether the "drag to pan" interaction is active, i.e. currently being used. + * + * @returns `true` if the "drag to pan" interaction is active. + */ + isActive() { + return this._mousePan.isActive() || this._touchPan.isActive(); + } +} diff --git a/packages/map/src/map/handler/shim/drag_rotate.ts b/packages/map/src/map/handler/shim/drag_rotate.ts new file mode 100644 index 00000000000..c5a022197fb --- /dev/null +++ b/packages/map/src/map/handler/shim/drag_rotate.ts @@ -0,0 +1,81 @@ +import type { MousePitchHandler, MouseRotateHandler } from '../mouse'; + +/** + * Options object for `DragRotateHandler`. + */ +export type DragRotateHandlerOptions = { + /** + * Control the map pitch in addition to the bearing + * @defaultValue true + */ + pitchWithRotate: boolean; +}; + +/** + * The `DragRotateHandler` allows the user to rotate the map by clicking and + * dragging the cursor while holding the right mouse button or `ctrl` key. + * + * @group Handlers + */ +export class DragRotateHandler { + _mouseRotate: MouseRotateHandler; + _mousePitch: MousePitchHandler; + _pitchWithRotate: boolean; + + /** @internal */ + constructor( + options: DragRotateHandlerOptions, + mouseRotate: MouseRotateHandler, + mousePitch: MousePitchHandler, + ) { + this._pitchWithRotate = options.pitchWithRotate; + this._mouseRotate = mouseRotate; + this._mousePitch = mousePitch; + } + + /** + * Enables the "drag to rotate" interaction. + * + * @example + * ```ts + * map.dragRotate.enable(); + * ``` + */ + enable() { + this._mouseRotate.enable(); + if (this._pitchWithRotate) this._mousePitch.enable(); + } + + /** + * Disables the "drag to rotate" interaction. + * + * @example + * ```ts + * map.dragRotate.disable(); + * ``` + */ + disable() { + this._mouseRotate.disable(); + this._mousePitch.disable(); + } + + /** + * Returns a Boolean indicating whether the "drag to rotate" interaction is enabled. + * + * @returns `true` if the "drag to rotate" interaction is enabled. + */ + isEnabled() { + return ( + this._mouseRotate.isEnabled() && (!this._pitchWithRotate || this._mousePitch.isEnabled()) + ); + } + + /** + * Returns a Boolean indicating whether the "drag to rotate" interaction is active, i.e. currently being used. + * + * @returns `true` if the "drag to rotate" interaction is active. + */ + isActive() { + return this._mouseRotate.isActive() || this._mousePitch.isActive(); + } +} diff --git a/packages/map/src/map/handler/shim/two_fingers_touch.ts b/packages/map/src/map/handler/shim/two_fingers_touch.ts new file mode 100644 index 00000000000..023be4f14b9 --- /dev/null +++ b/packages/map/src/map/handler/shim/two_fingers_touch.ts @@ -0,0 +1,124 @@ +import type { TapDragZoomHandler } from '../tap_drag_zoom'; +import type { + AroundCenterOptions, + TwoFingersTouchRotateHandler, + TwoFingersTouchZoomHandler, +} from '../two_fingers_touch'; + +/** + * The `TwoFingersTouchZoomRotateHandler` allows the user to zoom and rotate the map by + * pinching on a touchscreen. + * + * They can zoom with one finger by double tapping and dragging. On the second tap, + * hold the finger down and drag up or down to zoom in or out. + * + * @group Handlers + */ +export class TwoFingersTouchZoomRotateHandler { + _el: HTMLElement; + _touchZoom: TwoFingersTouchZoomHandler; + _touchRotate: TwoFingersTouchRotateHandler; + _tapDragZoom: TapDragZoomHandler; + _rotationDisabled: boolean; + _enabled: boolean; + + /** @internal */ + constructor( + el: HTMLElement, + touchZoom: TwoFingersTouchZoomHandler, + touchRotate: TwoFingersTouchRotateHandler, + tapDragZoom: TapDragZoomHandler, + ) { + this._el = el; + this._touchZoom = touchZoom; + this._touchRotate = touchRotate; + this._tapDragZoom = tapDragZoom; + this._rotationDisabled = false; + this._enabled = true; + } + + /** + * Enables the "pinch to rotate and zoom" interaction. + * + * @param options - Options object. + * + * @example + * ```ts + * map.touchZoomRotate.enable(); + * map.touchZoomRotate.enable({ around: 'center' }); + * ``` + */ + enable(options?: AroundCenterOptions | boolean | null) { + this._touchZoom.enable(options); + if (!this._rotationDisabled) this._touchRotate.enable(options); + this._tapDragZoom.enable(); + this._el.classList.add('l7-touch-zoom-rotate'); + } + + /** + * Disables the "pinch to rotate and zoom" interaction. + * + * @example + * ```ts + * map.touchZoomRotate.disable(); + * ``` + */ + disable() { + this._touchZoom.disable(); + this._touchRotate.disable(); + this._tapDragZoom.disable(); + this._el.classList.remove('l7-touch-zoom-rotate'); + } + + /** + * Returns a Boolean indicating whether the "pinch to rotate and zoom" interaction is enabled. + * + * @returns `true` if the "pinch to rotate and zoom" interaction is enabled. + */ + isEnabled() { + return ( + this._touchZoom.isEnabled() && + (this._rotationDisabled || this._touchRotate.isEnabled()) && + this._tapDragZoom.isEnabled() + ); + } + + /** + * Returns true if the handler is enabled and has detected the start of a zoom/rotate gesture. + * + * @returns `true` if the handler is active, `false` otherwise + */ + isActive() { + return ( + this._touchZoom.isActive() || this._touchRotate.isActive() || this._tapDragZoom.isActive() + ); + } + + /** + * Disables the "pinch to rotate" interaction, leaving the "pinch to zoom" + * interaction enabled. + * + * @example + * ```ts + * map.touchZoomRotate.disableRotation(); + * ``` + */ + disableRotation() { + this._rotationDisabled = true; + this._touchRotate.disable(); + } + + /** + * Enables the "pinch to rotate" interaction. + * + * @example + * ```ts + * map.touchZoomRotate.enable(); + * map.touchZoomRotate.enableRotation(); + * ``` + */ + enableRotation() { + this._rotationDisabled = false; + if (this._touchZoom.isEnabled()) this._touchRotate.enable(); + } +} diff --git a/packages/map/src/map/handler/tap_drag_zoom.ts b/packages/map/src/map/handler/tap_drag_zoom.ts new file mode 100644 index 00000000000..20c2350830b --- /dev/null +++ b/packages/map/src/map/handler/tap_drag_zoom.ts @@ -0,0 +1,110 @@ +import type Point from '@mapbox/point-geometry'; +import type { Handler } from '../handler_manager'; +import { MAX_DIST, MAX_TAP_INTERVAL, TapRecognizer } from './tap_recognizer'; + +/** + * A `TapDragZoomHandler` allows the user to zoom the map at a point by double tapping. It also allows the user pan the map by dragging. + */ +export class TapDragZoomHandler implements Handler { + _enabled: boolean; + _active: boolean; + _swipePoint: Point; + _swipeTouch: number; + _tapTime: number; + _tapPoint: Point; + _tap: TapRecognizer; + + constructor() { + this._tap = new TapRecognizer({ + numTouches: 1, + numTaps: 1, + }); + + this.reset(); + } + + reset() { + this._active = false; + delete this._swipePoint; + delete this._swipeTouch; + delete this._tapTime; + delete this._tapPoint; + this._tap.reset(); + } + + touchstart(e: TouchEvent, points: Array, mapTouches: Array) { + if (this._swipePoint) return; + + if (!this._tapTime) { + this._tap.touchstart(e, points, mapTouches); + } else { + const swipePoint = points[0]; + + const soonEnough = e.timeStamp - this._tapTime < MAX_TAP_INTERVAL; + const closeEnough = this._tapPoint.dist(swipePoint) < MAX_DIST; + + if (!soonEnough || !closeEnough) { + this.reset(); + } else if (mapTouches.length > 0) { + this._swipePoint = swipePoint; + this._swipeTouch = mapTouches[0].identifier; + } + } + } + + touchmove(e: TouchEvent, points: Array, mapTouches: Array) { + if (!this._tapTime) { + this._tap.touchmove(e, points, mapTouches); + } else if (this._swipePoint) { + if (mapTouches[0].identifier !== this._swipeTouch) { + return; + } + + const newSwipePoint = points[0]; + const dist = newSwipePoint.y - this._swipePoint.y; + this._swipePoint = newSwipePoint; + + e.preventDefault(); + this._active = true; + + return { + zoomDelta: dist / 128, + }; + } + } + + touchend(e: TouchEvent, points: Array, mapTouches: Array) { + if (!this._tapTime) { + const point = this._tap.touchend(e, points, mapTouches); + if (point) { + this._tapTime = e.timeStamp; + this._tapPoint = point; + } + } else if (this._swipePoint) { + if (mapTouches.length === 0) { + this.reset(); + } + } + } + + touchcancel() { + this.reset(); + } + + enable() { + this._enabled = true; + } + + disable() { + this._enabled = false; + this.reset(); + } + + isEnabled() { + return this._enabled; + } + + isActive() { + return this._active; + } +} diff --git a/packages/map/src/map/handler/tap_recognizer.ts b/packages/map/src/map/handler/tap_recognizer.ts new file mode 100644 index 00000000000..4caa08b6cd9 --- /dev/null +++ b/packages/map/src/map/handler/tap_recognizer.ts @@ -0,0 +1,129 @@ +import Point from '@mapbox/point-geometry'; +import { indexTouches } from './handler_util'; + +function getCentroid(points: Array) { + const sum = new Point(0, 0); + for (const point of points) { + sum._add(point); + } + return sum.div(points.length); +} + +export const MAX_TAP_INTERVAL = 500; +const MAX_TOUCH_TIME = 500; +export const MAX_DIST = 30; + +export class SingleTapRecognizer { + numTouches: number; + centroid: Point; + startTime: number; + aborted: boolean; + touches: { + [k in number | string]: Point; + }; + + constructor(options: { numTouches: number }) { + this.reset(); + this.numTouches = options.numTouches; + } + + reset() { + delete this.centroid; + delete this.startTime; + delete this.touches; + this.aborted = false; + } + + touchstart(e: TouchEvent, points: Array, mapTouches: Array) { + if (this.centroid || mapTouches.length > this.numTouches) { + this.aborted = true; + } + if (this.aborted) { + return; + } + + if (this.startTime === undefined) { + this.startTime = e.timeStamp; + } + + if (mapTouches.length === this.numTouches) { + this.centroid = getCentroid(points); + this.touches = indexTouches(mapTouches, points); + } + } + + touchmove(e: TouchEvent, points: Array, mapTouches: Array) { + if (this.aborted || !this.centroid) return; + + const newTouches = indexTouches(mapTouches, points); + for (const id in this.touches) { + const prevPos = this.touches[id]; + const pos = newTouches[id]; + if (!pos || pos.dist(prevPos) > MAX_DIST) { + this.aborted = true; + } + } + } + + touchend(e: TouchEvent, points: Array, mapTouches: Array) { + if (!this.centroid || e.timeStamp - this.startTime > MAX_TOUCH_TIME) { + this.aborted = true; + } + + if (mapTouches.length === 0) { + const centroid = !this.aborted && this.centroid; + this.reset(); + if (centroid) return centroid; + } + } +} + +export class TapRecognizer { + singleTap: SingleTapRecognizer; + numTaps: number; + lastTime: number; + lastTap: Point; + count: number; + + constructor(options: { numTaps: number; numTouches: number }) { + this.singleTap = new SingleTapRecognizer(options); + this.numTaps = options.numTaps; + this.reset(); + } + + reset() { + this.lastTime = Infinity; + delete this.lastTap; + this.count = 0; + this.singleTap.reset(); + } + + touchstart(e: TouchEvent, points: Array, mapTouches: Array) { + this.singleTap.touchstart(e, points, mapTouches); + } + + touchmove(e: TouchEvent, points: Array, mapTouches: Array) { + this.singleTap.touchmove(e, points, mapTouches); + } + + touchend(e: TouchEvent, points: Array, mapTouches: Array) { + const tap = this.singleTap.touchend(e, points, mapTouches); + if (tap) { + const soonEnough = e.timeStamp - this.lastTime < MAX_TAP_INTERVAL; + const closeEnough = !this.lastTap || this.lastTap.dist(tap) < MAX_DIST; + + if (!soonEnough || !closeEnough) { + this.reset(); + } + + this.count++; + this.lastTime = e.timeStamp; + this.lastTap = tap; + + if (this.count === this.numTaps) { + this.reset(); + return tap; + } + } + } +} diff --git a/packages/map/src/map/handler/tap_zoom.ts b/packages/map/src/map/handler/tap_zoom.ts new file mode 100644 index 00000000000..ecbfbe43240 --- /dev/null +++ b/packages/map/src/map/handler/tap_zoom.ts @@ -0,0 +1,106 @@ +import type Point from '@mapbox/point-geometry'; +import type { Handler } from '../handler_manager'; +import type { Map } from '../map'; +import { TapRecognizer } from './tap_recognizer'; +import { TransformProvider } from './transform-provider'; + +/** + * A `TapZoomHandler` allows the user to zoom the map at a point by double tapping + */ +export class TapZoomHandler implements Handler { + _tr: TransformProvider; + _enabled: boolean; + _active: boolean; + _zoomIn: TapRecognizer; + _zoomOut: TapRecognizer; + + constructor(map: Map) { + this._tr = new TransformProvider(map); + this._zoomIn = new TapRecognizer({ + numTouches: 1, + numTaps: 2, + }); + + this._zoomOut = new TapRecognizer({ + numTouches: 2, + numTaps: 1, + }); + + this.reset(); + } + + reset() { + this._active = false; + this._zoomIn.reset(); + this._zoomOut.reset(); + } + + touchstart(e: TouchEvent, points: Array, mapTouches: Array) { + this._zoomIn.touchstart(e, points, mapTouches); + this._zoomOut.touchstart(e, points, mapTouches); + } + + touchmove(e: TouchEvent, points: Array, mapTouches: Array) { + this._zoomIn.touchmove(e, points, mapTouches); + this._zoomOut.touchmove(e, points, mapTouches); + } + + touchend(e: TouchEvent, points: Array, mapTouches: Array) { + const zoomInPoint = this._zoomIn.touchend(e, points, mapTouches); + const zoomOutPoint = this._zoomOut.touchend(e, points, mapTouches); + const tr = this._tr; + + if (zoomInPoint) { + this._active = true; + e.preventDefault(); + setTimeout(() => this.reset(), 0); + return { + cameraAnimation: (map: Map) => + map.easeTo( + { + duration: 300, + zoom: tr.zoom + 1, + around: tr.unproject(zoomInPoint), + }, + { originalEvent: e }, + ), + }; + } else if (zoomOutPoint) { + this._active = true; + e.preventDefault(); + setTimeout(() => this.reset(), 0); + return { + cameraAnimation: (map: Map) => + map.easeTo( + { + duration: 300, + zoom: tr.zoom - 1, + around: tr.unproject(zoomOutPoint), + }, + { originalEvent: e }, + ), + }; + } + } + + touchcancel() { + this.reset(); + } + + enable() { + this._enabled = true; + } + + disable() { + this._enabled = false; + this.reset(); + } + + isEnabled() { + return this._enabled; + } + + isActive() { + return this._active; + } +} diff --git a/packages/map/src/map/handler/touch_pan.ts b/packages/map/src/map/handler/touch_pan.ts new file mode 100644 index 00000000000..adc4c15b591 --- /dev/null +++ b/packages/map/src/map/handler/touch_pan.ts @@ -0,0 +1,109 @@ +import Point from '@mapbox/point-geometry'; +import type { Handler } from '../handler_manager'; +import type { Map } from '../map'; +import { indexTouches } from './handler_util'; + +/** + * A `TouchPanHandler` allows the user to pan the map using touch gestures. + */ +export class TouchPanHandler implements Handler { + _enabled: boolean; + _active: boolean; + _touches: { + [k in string | number]: Point; + }; + _clickTolerance: number; + _sum: Point; + _map: Map; + + constructor(options: { clickTolerance: number }, map: Map) { + this._clickTolerance = options.clickTolerance || 1; + this._map = map; + this.reset(); + } + + reset() { + this._active = false; + this._touches = {}; + this._sum = new Point(0, 0); + } + + minTouchs() { + return this._map.cooperativeGestures.isEnabled() ? 2 : 1; + } + + touchstart(e: TouchEvent, points: Array, mapTouches: Array) { + return this._calculateTransform(e, points, mapTouches); + } + + touchmove(e: TouchEvent, points: Array, mapTouches: Array) { + if (!this._active || mapTouches.length < this.minTouchs()) return; + e.preventDefault(); + return this._calculateTransform(e, points, mapTouches); + } + + touchend(e: TouchEvent, points: Array, mapTouches: Array) { + this._calculateTransform(e, points, mapTouches); + + if (this._active && mapTouches.length < this.minTouchs()) { + this.reset(); + } + } + + touchcancel() { + this.reset(); + } + + _calculateTransform(e: TouchEvent, points: Array, mapTouches: Array) { + if (mapTouches.length > 0) this._active = true; + + const touches = indexTouches(mapTouches, points); + + const touchPointSum = new Point(0, 0); + const touchDeltaSum = new Point(0, 0); + let touchDeltaCount = 0; + + for (const identifier in touches) { + const point = touches[identifier]; + const prevPoint = this._touches[identifier]; + if (prevPoint) { + touchPointSum._add(point); + touchDeltaSum._add(point.sub(prevPoint)); + touchDeltaCount++; + touches[identifier] = point; + } + } + + this._touches = touches; + + if (touchDeltaCount < this.minTouchs() || !touchDeltaSum.mag()) return; + + const panDelta = touchDeltaSum.div(touchDeltaCount); + this._sum._add(panDelta); + if (this._sum.mag() < this._clickTolerance) return; + + const around = touchPointSum.div(touchDeltaCount); + + return { + around, + panDelta, + }; + } + + enable() { + this._enabled = true; + } + + disable() { + this._enabled = false; + this.reset(); + } + + isEnabled() { + return this._enabled; + } + + isActive() { + return this._active; + } +} diff --git a/packages/map/src/map/handler/transform-provider.ts b/packages/map/src/map/handler/transform-provider.ts new file mode 100644 index 00000000000..b6a3b6240e7 --- /dev/null +++ b/packages/map/src/map/handler/transform-provider.ts @@ -0,0 +1,43 @@ +import Point from '@mapbox/point-geometry'; +import type { PointLike } from '../camera'; +import type { LngLat } from '../geo/lng_lat'; +import type { Transform } from '../geo/transform'; +import type { Map } from '../map'; + +/** + * @internal + * Shared utilities for the Handler classes to access the correct camera state. + * If Camera.transformCameraUpdate is specified, the "desired state" of camera may differ from the state used for rendering. + * The handlers need the "desired state" to track accumulated changes. + */ +export class TransformProvider { + _map: Map; + + constructor(map: Map) { + this._map = map; + } + + get transform(): Transform { + return this._map._requestedCameraState || this._map.transform; + } + + get center() { + return { lng: this.transform.center.lng, lat: this.transform.center.lat }; + } + + get zoom() { + return this.transform.zoom; + } + + get pitch() { + return this.transform.pitch; + } + + get bearing() { + return this.transform.bearing; + } + + unproject(point: PointLike): LngLat { + return this.transform.pointLocation(Point.convert(point)); + } +} diff --git a/packages/map/src/map/handler/two_fingers_touch.ts b/packages/map/src/map/handler/two_fingers_touch.ts new file mode 100644 index 00000000000..96b1c1c0eb3 --- /dev/null +++ b/packages/map/src/map/handler/two_fingers_touch.ts @@ -0,0 +1,342 @@ +import type Point from '@mapbox/point-geometry'; +import type { Handler, HandlerResult } from '../handler_manager'; +import type { Map } from '../map'; +import { DOM } from '../util/dom'; + +/** + * An options object sent to the enable function of some of the handlers + */ +export type AroundCenterOptions = { + /** + * If "center" is passed, map will zoom around the center of map + */ + around: 'center'; +}; + +/** + * The `TwoFingersTouchHandler`s allows the user to zoom, pitch and rotate the map using two fingers + * + */ +abstract class TwoFingersTouchHandler implements Handler { + _enabled?: boolean; + _active?: boolean; + _firstTwoTouches?: [number, number]; + _vector?: Point; + _startVector?: Point; + _aroundCenter?: boolean; + + /** @internal */ + constructor() { + this.reset(); + } + + reset(): void { + this._active = false; + delete this._firstTwoTouches; + } + + abstract _start(points: [Point, Point]): void; + abstract _move( + points: [Point, Point], + pinchAround: Point | null, + e: TouchEvent, + ): HandlerResult | void; + + touchstart(e: TouchEvent, points: Array, mapTouches: Array): void { + //log('touchstart', points, e.target.innerHTML, e.targetTouches.length ? e.targetTouches[0].target.innerHTML: undefined); + if (this._firstTwoTouches || mapTouches.length < 2) return; + + this._firstTwoTouches = [mapTouches[0].identifier, mapTouches[1].identifier]; + + // implemented by child classes + this._start([points[0], points[1]]); + } + + touchmove(e: TouchEvent, points: Array, mapTouches: Array): HandlerResult | void { + if (!this._firstTwoTouches) return; + + e.preventDefault(); + + const [idA, idB] = this._firstTwoTouches; + const a = getTouchById(mapTouches, points, idA); + const b = getTouchById(mapTouches, points, idB); + if (!a || !b) return; + const pinchAround = this._aroundCenter ? null : a.add(b).div(2); + + // implemented by child classes + return this._move([a, b], pinchAround, e); + } + + touchend(e: TouchEvent, points: Array, mapTouches: Array): void { + if (!this._firstTwoTouches) return; + + const [idA, idB] = this._firstTwoTouches; + const a = getTouchById(mapTouches, points, idA); + const b = getTouchById(mapTouches, points, idB); + if (a && b) return; + + if (this._active) DOM.suppressClick(); + + this.reset(); + } + + touchcancel(): void { + this.reset(); + } + + /** + * Enables the "drag to pitch" interaction. + * + * @example + * ```ts + * map.touchPitch.enable(); + * ``` + */ + enable(options?: AroundCenterOptions | boolean | null): void { + this._enabled = true; + this._aroundCenter = !!options && (options as AroundCenterOptions).around === 'center'; + } + + /** + * Disables the "drag to pitch" interaction. + * + * @example + * ```ts + * map.touchPitch.disable(); + * ``` + */ + disable(): void { + this._enabled = false; + this.reset(); + } + + /** + * Returns a Boolean indicating whether the "drag to pitch" interaction is enabled. + * + * @returns `true` if the "drag to pitch" interaction is enabled. + */ + isEnabled(): boolean { + return !!this._enabled; + } + + /** + * Returns a Boolean indicating whether the "drag to pitch" interaction is active, i.e. currently being used. + * + * @returns `true` if the "drag to pitch" interaction is active. + */ + isActive(): boolean { + return !!this._active; + } +} + +function getTouchById( + mapTouches: Array, + points: Array, + identifier: number, +): Point | undefined { + for (let i = 0; i < mapTouches.length; i++) { + if (mapTouches[i].identifier === identifier) return points[i]; + } + return undefined; +} + +/* ZOOM */ + +const ZOOM_THRESHOLD = 0.1; + +function getZoomDelta(distance: number, lastDistance: number): number { + return Math.log(distance / lastDistance) / Math.LN2; +} + +/** + * The `TwoFingersTouchHandler`s allows the user to zoom the map two fingers + * + * @group Handlers + */ +export class TwoFingersTouchZoomHandler extends TwoFingersTouchHandler { + _distance?: number; + _startDistance?: number; + + reset() { + super.reset(); + delete this._distance; + delete this._startDistance; + } + + _start(points: [Point, Point]): void { + this._startDistance = this._distance = points[0].dist(points[1]); + } + + _move(points: [Point, Point], pinchAround: Point | null): HandlerResult | void { + const lastDistance = this._distance!; + this._distance = points[0].dist(points[1]); + if ( + !this._active && + Math.abs(getZoomDelta(this._distance, this._startDistance!)) < ZOOM_THRESHOLD + ) + return; + this._active = true; + return { + zoomDelta: getZoomDelta(this._distance, lastDistance), + pinchAround, + }; + } +} + +/* ROTATE */ + +const ROTATION_THRESHOLD = 25; // pixels along circumference of touch circle + +function getBearingDelta(a: Point, b: Point): number { + return (a.angleWith(b) * 180) / Math.PI; +} + +/** + * The `TwoFingersTouchHandler`s allows the user to rotate the map two fingers + * + * @group Handlers + */ +export class TwoFingersTouchRotateHandler extends TwoFingersTouchHandler { + _minDiameter?: number; + + reset(): void { + super.reset(); + delete this._minDiameter; + delete this._startVector; + delete this._vector; + } + + _start(points: [Point, Point]): void { + this._startVector = this._vector = points[0].sub(points[1]); + this._minDiameter = points[0].dist(points[1]); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _move(points: [Point, Point], pinchAround: Point | null, _e: TouchEvent): HandlerResult | void { + const lastVector = this._vector!; + this._vector = points[0].sub(points[1]); + + if (!this._active && this._isBelowThreshold(this._vector)) return; + this._active = true; + + return { + bearingDelta: getBearingDelta(this._vector, lastVector), + pinchAround, + }; + } + + _isBelowThreshold(vector: Point): boolean { + /* + * The threshold before a rotation actually happens is configured in + * pixels along the circumference of the circle formed by the two fingers. + * This makes the threshold in degrees larger when the fingers are close + * together and smaller when the fingers are far apart. + * + * Use the smallest diameter from the whole gesture to reduce sensitivity + * when pinching in and out. + */ + + this._minDiameter = Math.min(this._minDiameter!, vector.mag()); + const circumference = Math.PI * this._minDiameter; + const threshold = (ROTATION_THRESHOLD / circumference) * 360; + + const bearingDeltaSinceStart = getBearingDelta(vector, this._startVector!); + return Math.abs(bearingDeltaSinceStart) < threshold; + } +} + +/* PITCH */ + +function isVertical(vector: Point): boolean { + return Math.abs(vector.y) > Math.abs(vector.x); +} + +const ALLOWED_SINGLE_TOUCH_TIME = 100; + +/** + * The `TwoFingersTouchPitchHandler` allows the user to pitch the map by dragging up and down with two fingers. + * + * @group Handlers + */ +export class TwoFingersTouchPitchHandler extends TwoFingersTouchHandler { + _valid?: boolean; + _firstMove?: number; + _lastPoints?: [Point, Point]; + _map: Map; + _currentTouchCount: number = 0; + + constructor(map: Map) { + super(); + this._map = map; + } + + reset(): void { + super.reset(); + this._valid = undefined; + delete this._firstMove; + delete this._lastPoints; + } + + touchstart(e: TouchEvent, points: Array, mapTouches: Array): void { + super.touchstart(e, points, mapTouches); + this._currentTouchCount = mapTouches.length; + } + + _start(points: [Point, Point]): void { + this._lastPoints = points; + if (isVertical(points[0].sub(points[1]))) { + // fingers are more horizontal than vertical + this._valid = false; + } + } + + _move(points: [Point, Point], center: Point | null, e: TouchEvent): HandlerResult | void { + // If cooperative gestures is enabled, we need a 3-finger minimum for this gesture to register + if (this._map.cooperativeGestures.isEnabled() && this._currentTouchCount < 3) { + return; + } + + const vectorA = points[0].sub(this._lastPoints![0]); + const vectorB = points[1].sub(this._lastPoints![1]); + + this._valid = this.gestureBeginsVertically(vectorA, vectorB, e.timeStamp); + if (!this._valid) return; + + this._lastPoints = points; + this._active = true; + const yDeltaAverage = (vectorA.y + vectorB.y) / 2; + const degreesPerPixelMoved = -0.5; + return { + pitchDelta: yDeltaAverage * degreesPerPixelMoved, + }; + } + + gestureBeginsVertically(vectorA: Point, vectorB: Point, timeStamp: number): boolean | undefined { + if (this._valid !== undefined) return this._valid; + + const threshold = 2; + const movedA = vectorA.mag() >= threshold; + const movedB = vectorB.mag() >= threshold; + + // neither finger has moved a meaningful amount, wait + if (!movedA && !movedB) return; + + // One finger has moved and the other has not. + // If enough time has passed, decide it is not a pitch. + if (!movedA || !movedB) { + if (this._firstMove === undefined) { + this._firstMove = timeStamp; + } + + if (timeStamp - this._firstMove < ALLOWED_SINGLE_TOUCH_TIME) { + // still waiting for a movement from the second finger + return undefined; + } else { + return false; + } + } + + const isSameDirection = vectorA.y > 0 === vectorB.y > 0; + return isVertical(vectorA) && isVertical(vectorB) && isSameDirection; + } +} diff --git a/packages/map/src/map/handler_inertia.ts b/packages/map/src/map/handler_inertia.ts new file mode 100644 index 00000000000..89cb25fd72d --- /dev/null +++ b/packages/map/src/map/handler_inertia.ts @@ -0,0 +1,168 @@ +import Point from '@mapbox/point-geometry'; +import type { DragPanOptions } from './handler/shim/drag_pan'; +import type { Map } from './map'; +import { browser } from './util/browser'; +import { bezier, clamp, extend } from './util/util'; + +const defaultInertiaOptions = { + linearity: 0.3, + easing: bezier(0, 0, 0.3, 1), +}; + +const defaultPanInertiaOptions = extend( + { + deceleration: 2500, + maxSpeed: 1400, + }, + defaultInertiaOptions, +); + +const defaultZoomInertiaOptions = extend( + { + deceleration: 20, + maxSpeed: 1400, + }, + defaultInertiaOptions, +); + +const defaultBearingInertiaOptions = extend( + { + deceleration: 1000, + maxSpeed: 360, + }, + defaultInertiaOptions, +); + +const defaultPitchInertiaOptions = extend( + { + deceleration: 1000, + maxSpeed: 90, + }, + defaultInertiaOptions, +); + +export type InertiaOptions = { + linearity: number; + easing: (t: number) => number; + deceleration: number; + maxSpeed: number; +}; + +export class HandlerInertia { + _map: Map; + _inertiaBuffer: Array<{ + time: number; + settings: any; + }>; + + constructor(map: Map) { + this._map = map; + this.clear(); + } + + clear() { + this._inertiaBuffer = []; + } + + record(settings: any) { + this._drainInertiaBuffer(); + this._inertiaBuffer.push({ time: browser.now(), settings }); + } + + _drainInertiaBuffer() { + const inertia = this._inertiaBuffer, + now = browser.now(), + cutoff = 160; //msec + + while (inertia.length > 0 && now - inertia[0].time > cutoff) inertia.shift(); + } + + _onMoveEnd(panInertiaOptions?: DragPanOptions | boolean) { + this._drainInertiaBuffer(); + if (this._inertiaBuffer.length < 2) { + return; + } + + const deltas = { + zoom: 0, + bearing: 0, + pitch: 0, + pan: new Point(0, 0), + pinchAround: undefined, + around: undefined, + }; + + for (const { settings } of this._inertiaBuffer) { + deltas.zoom += settings.zoomDelta || 0; + deltas.bearing += settings.bearingDelta || 0; + deltas.pitch += settings.pitchDelta || 0; + if (settings.panDelta) deltas.pan._add(settings.panDelta); + if (settings.around) deltas.around = settings.around; + if (settings.pinchAround) deltas.pinchAround = settings.pinchAround; + } + + const lastEntry = this._inertiaBuffer[this._inertiaBuffer.length - 1]; + const duration = lastEntry.time - this._inertiaBuffer[0].time; + + const easeOptions = {} as any; + + if (deltas.pan.mag()) { + const result = calculateEasing( + deltas.pan.mag(), + duration, + extend({}, defaultPanInertiaOptions, panInertiaOptions || {}), + ); + easeOptions.offset = deltas.pan.mult(result.amount / deltas.pan.mag()); + easeOptions.center = this._map.transform.center; + extendDuration(easeOptions, result); + } + + if (deltas.zoom) { + const result = calculateEasing(deltas.zoom, duration, defaultZoomInertiaOptions); + easeOptions.zoom = this._map.transform.zoom + result.amount; + extendDuration(easeOptions, result); + } + + if (deltas.bearing) { + const result = calculateEasing(deltas.bearing, duration, defaultBearingInertiaOptions); + easeOptions.bearing = this._map.transform.bearing + clamp(result.amount, -179, 179); + extendDuration(easeOptions, result); + } + + if (deltas.pitch) { + const result = calculateEasing(deltas.pitch, duration, defaultPitchInertiaOptions); + easeOptions.pitch = this._map.transform.pitch + result.amount; + extendDuration(easeOptions, result); + } + + if (easeOptions.zoom || easeOptions.bearing) { + const last = deltas.pinchAround === undefined ? deltas.around : deltas.pinchAround; + easeOptions.around = last ? this._map.unproject(last) : this._map.getCenter(); + } + + this.clear(); + return extend(easeOptions, { + noMoveStart: true, + }); + } +} + +// Unfortunately zoom, bearing, etc can't have different durations and easings so +// we need to choose one. We use the longest duration and it's corresponding easing. +function extendDuration(easeOptions, result) { + if (!easeOptions.duration || easeOptions.duration < result.duration) { + easeOptions.duration = result.duration; + easeOptions.easing = result.easing; + } +} + +function calculateEasing(amount, inertiaDuration: number, inertiaOptions) { + const { maxSpeed, linearity, deceleration } = inertiaOptions; + const speed = clamp((amount * linearity) / (inertiaDuration / 1000), -maxSpeed, maxSpeed); + const duration = Math.abs(speed) / (deceleration * linearity); + return { + easing: inertiaOptions.easing, + duration: duration * 1000, + amount: speed * (duration / 2), + }; +} diff --git a/packages/map/src/map/handler_manager.ts b/packages/map/src/map/handler_manager.ts new file mode 100644 index 00000000000..bdff7a8a2a9 --- /dev/null +++ b/packages/map/src/map/handler_manager.ts @@ -0,0 +1,690 @@ +import Point from '@mapbox/point-geometry'; +import { BoxZoomHandler } from './handler/box_zoom'; +import { ClickZoomHandler } from './handler/click_zoom'; +import { CooperativeGesturesHandler } from './handler/cooperative_gestures'; +import { KeyboardHandler } from './handler/keyboard'; +import { BlockableMapEventHandler, MapEventHandler } from './handler/map_event'; +import { + generateMousePanHandler, + generateMousePitchHandler, + generateMouseRotationHandler, +} from './handler/mouse'; +import { ScrollZoomHandler } from './handler/scroll_zoom'; +import { DoubleClickZoomHandler } from './handler/shim/dblclick_zoom'; +import { DragPanHandler } from './handler/shim/drag_pan'; +import { DragRotateHandler } from './handler/shim/drag_rotate'; +import { TwoFingersTouchZoomRotateHandler } from './handler/shim/two_fingers_touch'; +import { TapDragZoomHandler } from './handler/tap_drag_zoom'; +import { TapZoomHandler } from './handler/tap_zoom'; +import { TouchPanHandler } from './handler/touch_pan'; +import { + TwoFingersTouchPitchHandler, + TwoFingersTouchRotateHandler, + TwoFingersTouchZoomHandler, +} from './handler/two_fingers_touch'; +import { HandlerInertia } from './handler_inertia'; +import type { CompleteMapOptions, Map } from './map'; +import { browser } from './util/browser'; +import { DOM } from './util/dom'; +import { Event } from './util/evented'; +import { extend } from './util/util'; + +const isMoving = (p: EventsInProgress) => p.zoom || p.drag || p.pitch || p.rotate; + +class RenderFrameEvent extends Event { + public type: string = 'renderFrame'; + public timeStamp: number; + + constructor(type: string, timeStamp: number) { + super(type); + this.timeStamp = timeStamp; + } +} + +/** + * Handlers interpret dom events and return camera changes that should be + * applied to the map (`HandlerResult`s). The camera changes are all deltas. + * The handler itself should have no knowledge of the map's current state. + * This makes it easier to merge multiple results and keeps handlers simpler. + * For example, if there is a mousedown and mousemove, the mousePan handler + * would return a `panDelta` on the mousemove. + */ +export interface Handler { + enable(): void; + disable(): void; + isEnabled(): boolean; + /** + * This is used to indicate if the handler is currently active or not. + * In case a handler is active, it will block other handlers from getting the relevant events. + * There is an allow list of handlers that can be active at the same time, which is configured when adding a handler. + */ + isActive(): boolean; + /** + * `reset` can be called by the manager at any time and must reset everything to it's original state + */ + reset(): void; + // Handlers can optionally implement these methods. + // They are called with dom events whenever those dom evens are received. + readonly touchstart?: ( + e: TouchEvent, + points: Array, + mapTouches: Array, + ) => HandlerResult | void; + readonly touchmove?: ( + e: TouchEvent, + points: Array, + mapTouches: Array, + ) => HandlerResult | void; + readonly touchmoveWindow?: ( + e: TouchEvent, + points: Array, + mapTouches: Array, + ) => HandlerResult | void; + readonly touchend?: ( + e: TouchEvent, + points: Array, + mapTouches: Array, + ) => HandlerResult | void; + readonly touchcancel?: ( + e: TouchEvent, + points: Array, + mapTouches: Array, + ) => HandlerResult | void; + readonly mousedown?: (e: MouseEvent, point: Point) => HandlerResult | void; + readonly mousemove?: (e: MouseEvent, point: Point) => HandlerResult | void; + readonly mousemoveWindow?: (e: MouseEvent, point: Point) => HandlerResult | void; + readonly mouseup?: (e: MouseEvent, point: Point) => HandlerResult | void; + readonly mouseupWindow?: (e: MouseEvent, point: Point) => HandlerResult | void; + readonly dblclick?: (e: MouseEvent, point: Point) => HandlerResult | void; + readonly contextmenu?: (e: MouseEvent) => HandlerResult | void; + readonly wheel?: (e: WheelEvent, point: Point) => HandlerResult | void; + readonly keydown?: (e: KeyboardEvent) => HandlerResult | void; + readonly keyup?: (e: KeyboardEvent) => HandlerResult | void; + /** + * `renderFrame` is the only non-dom event. It is called during render + * frames and can be used to smooth camera changes (see scroll handler). + */ + readonly renderFrame?: () => HandlerResult | void; +} + +/** + * All handler methods that are called with events can optionally return a `HandlerResult`. + */ +export type HandlerResult = { + panDelta?: Point; + zoomDelta?: number; + bearingDelta?: number; + pitchDelta?: number; + /** + * the point to not move when changing the camera + */ + around?: Point | null; + /** + * same as above, except for pinch actions, which are given higher priority + */ + pinchAround?: Point | null; + /** + * A method that can fire a one-off easing by directly changing the map's camera. + */ + cameraAnimation?: (map: Map) => any; + /** + * The last three properties are needed by only one handler: scrollzoom. + * The DOM event to be used as the `originalEvent` on any camera change events. + */ + originalEvent?: Event; + /** + * Makes the manager trigger a frame, allowing the handler to return multiple results over time (see scrollzoom). + */ + needsRenderFrame?: boolean; + /** + * The camera changes won't get recorded for inertial zooming. + */ + noInertia?: boolean; +}; + +export type EventInProgress = { + handlerName: string; + originalEvent: Event; +}; + +export type EventsInProgress = { + zoom?: EventInProgress; + pitch?: EventInProgress; + rotate?: EventInProgress; + drag?: EventInProgress; +}; + +function hasChange(result: HandlerResult) { + return ( + (result.panDelta && result.panDelta.mag()) || + result.zoomDelta || + result.bearingDelta || + result.pitchDelta + ); +} + +export class HandlerManager { + _map: Map; + _el: HTMLElement; + _handlers: Array<{ + handlerName: string; + handler: Handler; + allowed: Array; + }>; + _eventsInProgress: EventsInProgress; + _frameId: number; + _inertia: HandlerInertia; + _bearingSnap: number; + _handlersById: { [x: string]: Handler }; + _updatingCamera: boolean; + _changes: Array<[HandlerResult, EventsInProgress, { [handlerName: string]: Event }]>; + _zoom: { handlerName: string }; + _previousActiveHandlers: { [x: string]: Handler }; + _listeners: Array< + [ + Window | Document | HTMLElement, + string, + ( + | { + passive?: boolean; + capture?: boolean; + } + | undefined + ), + ] + >; + + constructor(map: Map, options: CompleteMapOptions) { + this._map = map; + this._el = this._map.getCanvasContainer(); + this._handlers = []; + this._handlersById = {}; + this._changes = []; + + this._inertia = new HandlerInertia(map); + this._bearingSnap = options.bearingSnap || 7; + this._previousActiveHandlers = {}; + + // Track whether map is currently moving, to compute start/move/end events + this._eventsInProgress = {}; + + this._addDefaultHandlers(options); + + const el = this._el; + + this._listeners = [ + // This needs to be `passive: true` so that a double tap fires two + // pairs of touchstart/end events in iOS Safari 13. If this is set to + // `passive: false` then the second pair of events is only fired if + // preventDefault() is called on the first touchstart. Calling preventDefault() + // undesirably prevents click events. + [el, 'touchstart', { passive: true }], + // This needs to be `passive: false` so that scrolls and pinches can be + // prevented in browsers that don't support `touch-actions: none`, for example iOS Safari 12. + [el, 'touchmove', { passive: false }], + [el, 'touchend', undefined], + [el, 'touchcancel', undefined], + + [el, 'mousedown', undefined], + [el, 'mousemove', undefined], + [el, 'mouseup', undefined], + + // Bind window-level event listeners for move and up/end events. In the absence of + // the pointer capture API, which is not supported by all necessary platforms, + // window-level event listeners give us the best shot at capturing events that + // fall outside the map canvas element. Use `{capture: true}` for the move event + // to prevent map move events from being fired during a drag. + [document, 'mousemove', { capture: true }], + [document, 'mouseup', undefined], + + [el, 'mouseover', undefined], + [el, 'mouseout', undefined], + [el, 'dblclick', undefined], + [el, 'click', undefined], + + [el, 'keydown', { capture: false }], + [el, 'keyup', undefined], + + [el, 'wheel', { passive: false }], + [el, 'contextmenu', undefined], + + [window, 'blur', undefined], + ]; + + for (const [target, type, listenerOptions] of this._listeners) { + DOM.addEventListener( + target, + type, + target === document ? this.handleWindowEvent : this.handleEvent, + listenerOptions, + ); + } + } + + destroy() { + for (const [target, type, listenerOptions] of this._listeners) { + DOM.removeEventListener( + target, + type, + target === document ? this.handleWindowEvent : this.handleEvent, + listenerOptions, + ); + } + } + + _addDefaultHandlers(options: CompleteMapOptions) { + const map = this._map; + const el = map.getCanvasContainer(); + this._add('mapEvent', new MapEventHandler(map, options)); + + const boxZoom = (map.boxZoom = new BoxZoomHandler(map, options)); + this._add('boxZoom', boxZoom); + if (options.interactive && options.boxZoom) { + boxZoom.enable(); + } + + const cooperativeGestures = (map.cooperativeGestures = new CooperativeGesturesHandler( + map, + options.cooperativeGestures, + )); + this._add('cooperativeGestures', cooperativeGestures); + if (options.cooperativeGestures) { + cooperativeGestures.enable(); + } + + const tapZoom = new TapZoomHandler(map); + const clickZoom = new ClickZoomHandler(map); + map.doubleClickZoom = new DoubleClickZoomHandler(clickZoom, tapZoom); + this._add('tapZoom', tapZoom); + this._add('clickZoom', clickZoom); + if (options.interactive && options.doubleClickZoom) { + map.doubleClickZoom.enable(); + } + + const tapDragZoom = new TapDragZoomHandler(); + this._add('tapDragZoom', tapDragZoom); + + const touchPitch = (map.touchPitch = new TwoFingersTouchPitchHandler(map)); + this._add('touchPitch', touchPitch); + if (options.interactive && options.touchPitch) { + map.touchPitch.enable(options.touchPitch); + } + + const mouseRotate = generateMouseRotationHandler(options); + const mousePitch = generateMousePitchHandler(options); + map.dragRotate = new DragRotateHandler(options, mouseRotate, mousePitch); + this._add('mouseRotate', mouseRotate, ['mousePitch']); + this._add('mousePitch', mousePitch, ['mouseRotate']); + if (options.interactive && options.dragRotate) { + map.dragRotate.enable(); + } + + const mousePan = generateMousePanHandler(options); + const touchPan = new TouchPanHandler(options, map); + map.dragPan = new DragPanHandler(el, mousePan, touchPan); + this._add('mousePan', mousePan); + this._add('touchPan', touchPan, ['touchZoom', 'touchRotate']); + if (options.interactive && options.dragPan) { + map.dragPan.enable(options.dragPan); + } + + const touchRotate = new TwoFingersTouchRotateHandler(); + const touchZoom = new TwoFingersTouchZoomHandler(); + map.touchZoomRotate = new TwoFingersTouchZoomRotateHandler( + el, + touchZoom, + touchRotate, + tapDragZoom, + ); + this._add('touchRotate', touchRotate, ['touchPan', 'touchZoom']); + this._add('touchZoom', touchZoom, ['touchPan', 'touchRotate']); + if (options.interactive && options.touchZoomRotate) { + map.touchZoomRotate.enable(options.touchZoomRotate); + } + + const scrollZoom = (map.scrollZoom = new ScrollZoomHandler(map, () => + this._triggerRenderFrame(), + )); + this._add('scrollZoom', scrollZoom, ['mousePan']); + if (options.interactive && options.scrollZoom) { + map.scrollZoom.enable(options.scrollZoom); + } + + const keyboard = (map.keyboard = new KeyboardHandler(map)); + this._add('keyboard', keyboard); + if (options.interactive && options.keyboard) { + map.keyboard.enable(); + } + + this._add('blockableMapEvent', new BlockableMapEventHandler(map)); + } + + _add(handlerName: string, handler: Handler, allowed?: Array) { + this._handlers.push({ handlerName, handler, allowed }); + this._handlersById[handlerName] = handler; + } + + stop(allowEndAnimation: boolean) { + // do nothing if this method was triggered by a gesture update + if (this._updatingCamera) return; + + for (const { handler } of this._handlers) { + handler.reset(); + } + this._inertia.clear(); + this._fireEvents({}, {}, allowEndAnimation); + this._changes = []; + } + + isActive() { + for (const { handler } of this._handlers) { + if (handler.isActive()) return true; + } + return false; + } + + isZooming() { + return !!this._eventsInProgress.zoom || this._map.scrollZoom.isZooming(); + } + isRotating() { + return !!this._eventsInProgress.rotate; + } + + isMoving() { + return Boolean(isMoving(this._eventsInProgress)) || this.isZooming(); + } + + _blockedByActive( + activeHandlers: { [x: string]: Handler }, + allowed: Array, + myName: string, + ) { + for (const name in activeHandlers) { + if (name === myName) continue; + if (!allowed || allowed.indexOf(name) < 0) { + return true; + } + } + return false; + } + + handleWindowEvent = (e: { type: 'mousemove' | 'mouseup' | 'touchmove' }) => { + this.handleEvent(e, `${e.type}Window`); + }; + + _getMapTouches(touches: TouchList) { + const mapTouches = []; + for (const t of touches) { + const target = t.target as any as Node; + if (this._el.contains(target)) { + mapTouches.push(t); + } + } + return mapTouches as any as TouchList; + } + + handleEvent = (e: Event, eventName?: keyof Handler) => { + if (e.type === 'blur') { + this.stop(true); + return; + } + + this._updatingCamera = true; + + const inputEvent = e.type === 'renderFrame' ? undefined : (e as UIEvent); + + /* + * We don't call e.preventDefault() for any events by default. + * Handlers are responsible for calling it where necessary. + */ + + const mergedHandlerResult: HandlerResult = { needsRenderFrame: false }; + const eventsInProgress: EventsInProgress = {}; + const activeHandlers = {}; + const eventTouches = (e as TouchEvent).touches; + + const mapTouches = eventTouches ? this._getMapTouches(eventTouches) : undefined; + const points = mapTouches + ? DOM.touchPos(this._map.getCanvasContainer(), mapTouches) + : DOM.mousePos(this._map.getCanvasContainer(), e as MouseEvent); + + for (const { handlerName, handler, allowed } of this._handlers) { + if (!handler.isEnabled()) continue; + + let data: HandlerResult; + if (this._blockedByActive(activeHandlers, allowed, handlerName)) { + handler.reset(); + } else { + if (handler[eventName || e.type]) { + data = handler[eventName || e.type](e, points, mapTouches); + this.mergeHandlerResult( + mergedHandlerResult, + eventsInProgress, + data, + handlerName, + inputEvent, + ); + if (data && data.needsRenderFrame) { + this._triggerRenderFrame(); + } + } + } + + if (data || handler.isActive()) { + activeHandlers[handlerName] = handler; + } + } + + const deactivatedHandlers: { [handlerName: string]: Event } = {}; + for (const name in this._previousActiveHandlers) { + if (!activeHandlers[name]) { + deactivatedHandlers[name] = inputEvent; + } + } + this._previousActiveHandlers = activeHandlers; + + if (Object.keys(deactivatedHandlers).length || hasChange(mergedHandlerResult)) { + this._changes.push([mergedHandlerResult, eventsInProgress, deactivatedHandlers]); + this._triggerRenderFrame(); + } + + if (Object.keys(activeHandlers).length || hasChange(mergedHandlerResult)) { + this._map._stop(true); + } + + this._updatingCamera = false; + + const { cameraAnimation } = mergedHandlerResult; + if (cameraAnimation) { + this._inertia.clear(); + this._fireEvents({}, {}, true); + this._changes = []; + cameraAnimation(this._map); + } + }; + + mergeHandlerResult( + mergedHandlerResult: HandlerResult, + eventsInProgress: EventsInProgress, + handlerResult: HandlerResult, + name: string, + e?: UIEvent, + ) { + if (!handlerResult) return; + + extend(mergedHandlerResult, handlerResult); + + const eventData = { handlerName: name, originalEvent: handlerResult.originalEvent || e }; + + // track which handler changed which camera property + if (handlerResult.zoomDelta !== undefined) { + eventsInProgress.zoom = eventData; + } + if (handlerResult.panDelta !== undefined) { + eventsInProgress.drag = eventData; + } + if (handlerResult.pitchDelta !== undefined) { + eventsInProgress.pitch = eventData; + } + if (handlerResult.bearingDelta !== undefined) { + eventsInProgress.rotate = eventData; + } + } + + _applyChanges() { + const combined: HandlerResult = {}; + const combinedEventsInProgress: EventsInProgress = {}; + const combinedDeactivatedHandlers = {}; + + for (const [change, eventsInProgress, deactivatedHandlers] of this._changes) { + if (change.panDelta) + combined.panDelta = (combined.panDelta || new Point(0, 0))._add(change.panDelta); + if (change.zoomDelta) combined.zoomDelta = (combined.zoomDelta || 0) + change.zoomDelta; + if (change.bearingDelta) + combined.bearingDelta = (combined.bearingDelta || 0) + change.bearingDelta; + if (change.pitchDelta) combined.pitchDelta = (combined.pitchDelta || 0) + change.pitchDelta; + if (change.around !== undefined) combined.around = change.around; + if (change.pinchAround !== undefined) combined.pinchAround = change.pinchAround; + if (change.noInertia) combined.noInertia = change.noInertia; + + extend(combinedEventsInProgress, eventsInProgress); + extend(combinedDeactivatedHandlers, deactivatedHandlers); + } + + this._updateMapTransform(combined, combinedEventsInProgress, combinedDeactivatedHandlers); + this._changes = []; + } + + _updateMapTransform( + combinedResult: HandlerResult, + combinedEventsInProgress: EventsInProgress, + deactivatedHandlers: { [handlerName: string]: Event }, + ) { + const map = this._map; + const tr = map._getTransformForUpdate(); + + if (!hasChange(combinedResult)) { + return this._fireEvents(combinedEventsInProgress, deactivatedHandlers, true); + } + + const { panDelta, zoomDelta, bearingDelta, pitchDelta, pinchAround } = combinedResult; + let { around } = combinedResult; + + if (pinchAround !== undefined) { + around = pinchAround; + } + + // stop any ongoing camera animations (easeTo, flyTo) + map._stop(true); + + around = around || map.transform.centerPoint; + const loc = tr.pointLocation(panDelta ? around.sub(panDelta) : around); + if (bearingDelta) tr.bearing += bearingDelta; + if (pitchDelta) tr.pitch += pitchDelta; + if (zoomDelta) tr.zoom += zoomDelta; + + tr.setLocationAtPoint(loc, around); + + map._applyUpdatedTransform(tr); + + this._map._update(); + if (!combinedResult.noInertia) this._inertia.record(combinedResult); + this._fireEvents(combinedEventsInProgress, deactivatedHandlers, true); + } + + _fireEvents( + newEventsInProgress: EventsInProgress, + deactivatedHandlers: { [handlerName: string]: Event }, + allowEndAnimation: boolean, + ) { + const wasMoving = isMoving(this._eventsInProgress); + const nowMoving = isMoving(newEventsInProgress); + + const startEvents = {}; + + for (const eventName in newEventsInProgress) { + const { originalEvent } = newEventsInProgress[eventName]; + if (!this._eventsInProgress[eventName]) { + startEvents[`${eventName}start`] = originalEvent; + } + this._eventsInProgress[eventName] = newEventsInProgress[eventName]; + } + + // fire start events only after this._eventsInProgress has been updated + if (!wasMoving && nowMoving) { + this._fireEvent('movestart', nowMoving.originalEvent); + } + + for (const name in startEvents) { + this._fireEvent(name, startEvents[name]); + } + + if (nowMoving) { + this._fireEvent('move', nowMoving.originalEvent); + } + + for (const eventName in newEventsInProgress) { + const { originalEvent } = newEventsInProgress[eventName]; + this._fireEvent(eventName, originalEvent); + } + + const endEvents = {}; + + let originalEndEvent; + for (const eventName in this._eventsInProgress) { + const { handlerName, originalEvent } = this._eventsInProgress[eventName]; + if (!this._handlersById[handlerName].isActive()) { + delete this._eventsInProgress[eventName]; + originalEndEvent = deactivatedHandlers[handlerName] || originalEvent; + endEvents[`${eventName}end`] = originalEndEvent; + } + } + + for (const name in endEvents) { + this._fireEvent(name, endEvents[name]); + } + + const stillMoving = isMoving(this._eventsInProgress); + const finishedMoving = (wasMoving || nowMoving) && !stillMoving; + if (allowEndAnimation && finishedMoving) { + this._updatingCamera = true; + const inertialEase = this._inertia._onMoveEnd(this._map.dragPan._inertiaOptions); + + const shouldSnapToNorth = (bearing) => + bearing !== 0 && -this._bearingSnap < bearing && bearing < this._bearingSnap; + + if (inertialEase && (inertialEase.essential || !browser.prefersReducedMotion)) { + if (shouldSnapToNorth(inertialEase.bearing || this._map.getBearing())) { + inertialEase.bearing = 0; + } + inertialEase.freezeElevation = true; + this._map.easeTo(inertialEase, { originalEvent: originalEndEvent }); + } else { + this._map.fire(new Event('moveend', { originalEvent: originalEndEvent })); + if (shouldSnapToNorth(this._map.getBearing())) { + this._map.resetNorth(); + } + } + this._updatingCamera = false; + } + } + + _fireEvent(type: string, e?: Event) { + this._map.fire(new Event(type, e ? { originalEvent: e } : {})); + } + + _requestFrame() { + this._map.triggerRepaint(); + return this._map._renderTaskQueue.add((timeStamp) => { + delete this._frameId; + this.handleEvent(new RenderFrameEvent('renderFrame', timeStamp)); + this._applyChanges(); + }); + } + + _triggerRenderFrame() { + if (this._frameId === undefined) { + this._frameId = this._requestFrame(); + } + } +} diff --git a/packages/map/src/map/map.ts b/packages/map/src/map/map.ts new file mode 100644 index 00000000000..83ee8707638 --- /dev/null +++ b/packages/map/src/map/map.ts @@ -0,0 +1,1039 @@ +import Point from '@mapbox/point-geometry'; +import { Camera } from './camera'; +import type { MapEventType } from './events'; +import { LngLat } from './geo/lng_lat'; +import { LngLatBounds } from './geo/lng_lat_bounds'; +import { Transform } from './geo/transform'; +import { HandlerManager } from './handler_manager'; +import { browser } from './util/browser'; +import { DOM } from './util/dom'; +import type { Listener } from './util/evented'; +import { Event } from './util/evented'; +import { TaskQueue } from './util/task_queue'; +import type { Complete } from './util/util'; +import { extend, uniqueId } from './util/util'; + +import './css/l7.css'; + +import { lodashUtil } from '@antv/l7-utils'; +import type { CameraOptions, FitBoundsOptions, PointLike } from './camera'; +import type { LngLatLike } from './geo/lng_lat'; +import type { LngLatBoundsLike } from './geo/lng_lat_bounds'; +import type { BoxZoomHandler } from './handler/box_zoom'; +import type { CooperativeGesturesHandler, GestureOptions } from './handler/cooperative_gestures'; +import type { KeyboardHandler } from './handler/keyboard'; +import type { ScrollZoomHandler } from './handler/scroll_zoom'; +import type { DoubleClickZoomHandler } from './handler/shim/dblclick_zoom'; +import type { DragPanHandler, DragPanOptions } from './handler/shim/drag_pan'; +import type { DragRotateHandler } from './handler/shim/drag_rotate'; +import type { TwoFingersTouchZoomRotateHandler } from './handler/shim/two_fingers_touch'; +import type { AroundCenterOptions, TwoFingersTouchPitchHandler } from './handler/two_fingers_touch'; +import type { TaskID } from './util/task_queue'; + +/** + * The {@link Map} options object. + */ +export type MapOptions = { + /** + * If `false`, no mouse, touch, or keyboard listeners will be attached to the map, so it will not respond to interaction. + * @defaultValue true + */ + interactive?: boolean; + /** + * The HTML element in which MapLibre GL JS will render the map, or the element's string `id`. The specified element must have no children. + */ + container: HTMLElement | string; + /** + * The threshold, measured in degrees, that determines when the map's + * bearing will snap to north. For example, with a `bearingSnap` of 7, if the user rotates + * the map within 7 degrees of north, the map will automatically snap to exact north. + * @defaultValue 7 + */ + bearingSnap?: number; + /** + * If set, the map will be constrained to the given bounds. + */ + maxBounds?: LngLatBoundsLike; + /** + * If `true`, the "scroll to zoom" interaction is enabled. {@link AroundCenterOptions} are passed as options to {@link ScrollZoomHandler#enable}. + * @defaultValue true + */ + scrollZoom?: boolean | AroundCenterOptions; + /** + * The minimum zoom level of the map (0-24). + * @defaultValue 0 + */ + minZoom?: number | null; + /** + * The maximum zoom level of the map (0-24). + * @defaultValue 22 + */ + maxZoom?: number | null; + /** + * The minimum pitch of the map (0-85). Values greater than 60 degrees are experimental and may result in rendering issues. If you encounter any, please raise an issue with details in the MapLibre project. + * @defaultValue 0 + */ + minPitch?: number | null; + /** + * The maximum pitch of the map (0-85). Values greater than 60 degrees are experimental and may result in rendering issues. If you encounter any, please raise an issue with details in the MapLibre project. + * @defaultValue 60 + */ + maxPitch?: number | null; + /** + * If `true`, the "box zoom" interaction is enabled (see {@link BoxZoomHandler}). + * @defaultValue true + */ + boxZoom?: boolean; + /** + * If `true`, the "drag to rotate" interaction is enabled (see {@link DragRotateHandler}). + * @defaultValue true + */ + dragRotate?: boolean; + /** + * If `true`, the "drag to pan" interaction is enabled. An `Object` value is passed as options to {@link DragPanHandler#enable}. + * @defaultValue true + */ + dragPan?: boolean | DragPanOptions; + /** + * If `true`, keyboard shortcuts are enabled (see {@link KeyboardHandler}). + * @defaultValue true + */ + keyboard?: boolean; + /** + * If `true`, the "double click to zoom" interaction is enabled (see {@link DoubleClickZoomHandler}). + * @defaultValue true + */ + doubleClickZoom?: boolean; + /** + * If `true`, the "pinch to rotate and zoom" interaction is enabled. An `Object` value is passed as options to {@link TwoFingersTouchZoomRotateHandler#enable}. + * @defaultValue true + */ + touchZoomRotate?: boolean | AroundCenterOptions; + /** + * If `true`, the "drag to pitch" interaction is enabled. An `Object` value is passed as options to {@link TwoFingersTouchPitchHandler#enable}. + * @defaultValue true + */ + touchPitch?: boolean | AroundCenterOptions; + /** + * If `true` or set to an options object, the map is only accessible on desktop while holding Command/Ctrl and only accessible on mobile with two fingers. Interacting with the map using normal gestures will trigger an informational screen. With this option enabled, "drag to pitch" requires a three-finger gesture. Cooperative gestures are disabled when a map enters fullscreen using {@link FullscreenControl}. + * @defaultValue false + */ + cooperativeGestures?: GestureOptions; + /** + * If `true`, the map will automatically resize when the browser window resizes. + * @defaultValue true + */ + trackResize?: boolean; + /** + * The initial geographical centerpoint of the map. If `center` is not specified in the constructor options, MapLibre GL JS will look for it in the map's style object. If it is not specified in the style, either, it will default to `[0, 0]` Note: MapLibre GL JS uses longitude, latitude coordinate order (as opposed to latitude, longitude) to match GeoJSON. + * @defaultValue [0, 0] + */ + center?: LngLatLike; + /** + * The initial zoom level of the map. If `zoom` is not specified in the constructor options, MapLibre GL JS will look for it in the map's style object. If it is not specified in the style, either, it will default to `0`. + * @defaultValue 0 + */ + zoom?: number; + /** + * The initial bearing (rotation) of the map, measured in degrees counter-clockwise from north. If `bearing` is not specified in the constructor options, MapLibre GL JS will look for it in the map's style object. If it is not specified in the style, either, it will default to `0`. + * @defaultValue 0 + */ + bearing?: number; + /** + * The initial pitch (tilt) of the map, measured in degrees away from the plane of the screen (0-85). If `pitch` is not specified in the constructor options, MapLibre GL JS will look for it in the map's style object. If it is not specified in the style, either, it will default to `0`. Values greater than 60 degrees are experimental and may result in rendering issues. If you encounter any, please raise an issue with details in the MapLibre project. + * @defaultValue 0 + */ + pitch?: number; + /** + * If `true`, multiple copies of the world will be rendered side by side beyond -180 and 180 degrees longitude. If set to `false`: + * + * - When the map is zoomed out far enough that a single representation of the world does not fill the map's entire + * container, there will be blank space beyond 180 and -180 degrees longitude. + * - Features that cross 180 and -180 degrees longitude will be cut in two (with one portion on the right edge of the + * map and the other on the left edge of the map) at every zoom level. + * @defaultValue true + */ + renderWorldCopies?: boolean; + /** + * Controls the duration of the fade-in/fade-out animation for label collisions after initial map load, in milliseconds. This setting affects all symbol layers. This setting does not affect the duration of runtime styling transitions or raster tile cross-fading. + * @defaultValue 300 + */ + fadeDuration?: number; + /** + * The max number of pixels a user can shift the mouse pointer during a click for it to be considered a valid click (as opposed to a mouse drag). + * @defaultValue 3 + */ + clickTolerance?: number; + /** + * The initial bounds of the map. If `bounds` is specified, it overrides `center` and `zoom` constructor options. + */ + bounds?: LngLatBoundsLike; + /** + * A {@link FitBoundsOptions} options object to use _only_ when fitting the initial `bounds` provided above. + */ + fitBoundsOptions?: FitBoundsOptions; + /** + * If `false`, the map's pitch (tilt) control with "drag to rotate" interaction will be disabled. + * @defaultValue true + */ + pitchWithRotate?: boolean; +}; + +// This type is used inside map since all properties are assigned a default value. +export type CompleteMapOptions = Complete; + +const defaultMinZoom = -2; +const defaultMaxZoom = 22; + +// the default values, but also the valid range +const defaultMinPitch = 0; +const defaultMaxPitch = 60; + +// use this variable to check maxPitch for validity +const maxPitchThreshold = 85; + +const defaultOptions: Readonly> = { + interactive: true, + bearingSnap: 7, + + scrollZoom: true, + minZoom: defaultMinZoom, + maxZoom: defaultMaxZoom, + minPitch: defaultMinPitch, + maxPitch: defaultMaxPitch, + + boxZoom: true, + dragRotate: true, + dragPan: true, + keyboard: true, + doubleClickZoom: true, + touchZoomRotate: true, + touchPitch: true, + cooperativeGestures: false, + + trackResize: true, + + center: [0, 0], + zoom: 0, + bearing: 0, + pitch: 0, + + renderWorldCopies: true, + fadeDuration: 300, + clickTolerance: 3, + pitchWithRotate: true, +}; + +/** + * The `Map` object represents the map on your page. It exposes methods + * and properties that enable you to programmatically change the map, + * and fires events as users interact with it. + * + * You create a `Map` by specifying a `container` and other options, see {@link MapOptions} for the full list. + * Then MapLibre GL JS initializes the map on the page and returns your `Map` object. + * + * @group Main + * + * @example + * ```ts + * let map = new Map({ + * container: 'map', + * center: [-122.420679, 37.772537], + * zoom: 13, + * }); + * ``` + */ +export class Map extends Camera { + _container: HTMLElement; + _canvasContainer: HTMLElement; + _interactive: boolean; + _frameRequest: AbortController; + + _loaded: boolean; + _idleTriggered = false; + // accounts for placement finishing as well + _fullyLoaded: boolean; + _trackResize: boolean; + _resizeObserver: ResizeObserver; + _preserveDrawingBuffer: boolean; + _failIfMajorPerformanceCaveat: boolean; + _fadeDuration: number; + _crossSourceCollisions: boolean; + _crossFadingFactor = 1; + _collectResourceTiming: boolean; + _renderTaskQueue = new TaskQueue(); + _mapId = uniqueId(); + _removed: boolean; + _clickTolerance: number; + + /** + * The map's {@link ScrollZoomHandler}, which implements zooming in and out with a scroll wheel or trackpad. + * Find more details and examples using `scrollZoom` in the {@link ScrollZoomHandler} section. + */ + scrollZoom: ScrollZoomHandler; + + /** + * The map's {@link BoxZoomHandler}, which implements zooming using a drag gesture with the Shift key pressed. + * Find more details and examples using `boxZoom` in the {@link BoxZoomHandler} section. + */ + boxZoom: BoxZoomHandler; + + /** + * The map's {@link DragRotateHandler}, which implements rotating the map while dragging with the right + * mouse button or with the Control key pressed. Find more details and examples using `dragRotate` + * in the {@link DragRotateHandler} section. + */ + dragRotate: DragRotateHandler; + + /** + * The map's {@link DragPanHandler}, which implements dragging the map with a mouse or touch gesture. + * Find more details and examples using `dragPan` in the {@link DragPanHandler} section. + */ + dragPan: DragPanHandler; + + /** + * The map's {@link KeyboardHandler}, which allows the user to zoom, rotate, and pan the map using keyboard + * shortcuts. Find more details and examples using `keyboard` in the {@link KeyboardHandler} section. + */ + keyboard: KeyboardHandler; + + /** + * The map's {@link DoubleClickZoomHandler}, which allows the user to zoom by double clicking. + * Find more details and examples using `doubleClickZoom` in the {@link DoubleClickZoomHandler} section. + */ + doubleClickZoom: DoubleClickZoomHandler; + + /** + * The map's {@link TwoFingersTouchZoomRotateHandler}, which allows the user to zoom or rotate the map with touch gestures. + * Find more details and examples using `touchZoomRotate` in the {@link TwoFingersTouchZoomRotateHandler} section. + */ + touchZoomRotate: TwoFingersTouchZoomRotateHandler; + + /** + * The map's {@link TwoFingersTouchPitchHandler}, which allows the user to pitch the map with touch gestures. + * Find more details and examples using `touchPitch` in the {@link TwoFingersTouchPitchHandler} section. + */ + touchPitch: TwoFingersTouchPitchHandler; + + /** + * The map's {@link CooperativeGesturesHandler}, which allows the user to see cooperative gesture info when user tries to zoom in/out. + * Find more details and examples using `cooperativeGestures` in the {@link CooperativeGesturesHandler} section. + */ + cooperativeGestures: CooperativeGesturesHandler; + + constructor(options: MapOptions) { + const resolvedOptions = { ...defaultOptions, ...options } as CompleteMapOptions; + + if ( + resolvedOptions.minZoom != null && + resolvedOptions.maxZoom != null && + resolvedOptions.minZoom > resolvedOptions.maxZoom + ) { + throw new Error('maxZoom must be greater than or equal to minZoom'); + } + + if ( + resolvedOptions.minPitch != null && + resolvedOptions.maxPitch != null && + resolvedOptions.minPitch > resolvedOptions.maxPitch + ) { + throw new Error('maxPitch must be greater than or equal to minPitch'); + } + + if (resolvedOptions.minPitch != null && resolvedOptions.minPitch < defaultMinPitch) { + throw new Error(`minPitch must be greater than or equal to ${defaultMinPitch}`); + } + + if (resolvedOptions.maxPitch != null && resolvedOptions.maxPitch > maxPitchThreshold) { + throw new Error(`maxPitch must be less than or equal to ${maxPitchThreshold}`); + } + + const transform = new Transform( + resolvedOptions.minZoom!, + resolvedOptions.maxZoom!, + resolvedOptions.minPitch!, + resolvedOptions.maxPitch!, + resolvedOptions.renderWorldCopies, + ); + super(transform, { bearingSnap: resolvedOptions.bearingSnap! }); + + this._interactive = resolvedOptions.interactive!; + this._trackResize = resolvedOptions.trackResize === true; + this._bearingSnap = resolvedOptions.bearingSnap!; + this._fadeDuration = resolvedOptions.fadeDuration!; + this._clickTolerance = resolvedOptions.clickTolerance!; + + if (typeof resolvedOptions.container === 'string') { + this._container = document.getElementById(resolvedOptions.container)!; + if (!this._container) { + throw new Error(`Container '${resolvedOptions.container}' not found.`); + } + } else if (resolvedOptions.container instanceof HTMLElement) { + this._container = resolvedOptions.container; + } else { + throw new Error("Invalid type: 'container' must be a String or HTMLElement."); + } + + if (resolvedOptions.maxBounds) { + this.setMaxBounds(resolvedOptions.maxBounds); + } + + this._setupContainer(); + + this.on('move', () => this._update()) + .on('moveend', () => this._update()) + .on('zoom', () => this._update()) + .once('idle', () => { + this._idleTriggered = true; + }); + + if (typeof window !== 'undefined') { + let initialResizeEventCaptured = false; + const throttledResizeCallback = lodashUtil.throttle((entries: ResizeObserverEntry[]) => { + if (this._trackResize && !this._removed) { + this.resize(entries)._update(); + } + }, 50); + this._resizeObserver = new ResizeObserver((entries) => { + if (!initialResizeEventCaptured) { + initialResizeEventCaptured = true; + return; + } + throttledResizeCallback(entries); + }); + this._resizeObserver.observe(this._container); + } + + this.handlers = new HandlerManager(this, resolvedOptions); + + this.jumpTo({ + center: resolvedOptions.center, + zoom: resolvedOptions.zoom, + bearing: resolvedOptions.bearing, + pitch: resolvedOptions.pitch, + }); + + if (resolvedOptions.bounds) { + this.resize(); + this.fitBounds( + resolvedOptions.bounds, + extend({}, resolvedOptions.fitBoundsOptions, { duration: 0 }), + ); + } + + this.resize(); + } + + /** + * @internal + * Returns a unique number for this map instance which is used for the MapLoadEvent + * to make sure we only fire one event per instantiated map object. + * @returns the uniq map ID + */ + _getMapId() { + return this._mapId; + } + + calculateCameraOptionsFromTo( + from: LngLat, + altitudeFrom: number, + to: LngLat, + altitudeTo?: number, + ): CameraOptions { + return super.calculateCameraOptionsFromTo(from, altitudeFrom, to, altitudeTo); + } + + /** + * Resizes the map according to the dimensions of its + * `container` element. + * + * Checks if the map container size changed and updates the map if it has changed. + * This method must be called after the map's `container` is resized programmatically + * or when the map is shown after being initially hidden with CSS. + * + * Triggers the following events: `movestart`, `move`, `moveend`, and `resize`. + * + * @param eventData - Additional properties to be passed to `movestart`, `move`, `resize`, and `moveend` + * events that get triggered as a result of resize. This can be useful for differentiating the + * source of an event (for example, user-initiated or programmatically-triggered events). + * @example + * Resize the map when the map container is shown after being initially hidden with CSS. + * ```ts + * let mapDiv = document.getElementById('map'); + * if (mapDiv.style.visibility === true) map.resize(); + * ``` + */ + resize(eventData?: any): Map { + const dimensions = this._containerDimensions(); + const width = dimensions[0]; + const height = dimensions[1]; + + this.transform.resize(width, height); + this._requestedCameraState?.resize(width, height); + + const fireMoving = !this._moving; + if (fireMoving) { + this.stop(); + this.fire(new Event('movestart', eventData)).fire(new Event('move', eventData)); + } + + this.fire(new Event('resize', eventData)); + + if (fireMoving) this.fire(new Event('moveend', eventData)); + + return this; + } + + /** + * Returns the map's geographical bounds. When the bearing or pitch is non-zero, the visible region is not + * an axis-aligned rectangle, and the result is the smallest bounds that encompasses the visible region. + * @returns The geographical bounds of the map as {@link LngLatBounds}. + * @example + * ```ts + * let bounds = map.getBounds(); + * ``` + */ + getBounds(): LngLatBounds { + return this.transform.getBounds(); + } + + /** + * Returns the maximum geographical bounds the map is constrained to, or `null` if none set. + * @returns The map object. + * @example + * ```ts + * let maxBounds = map.getMaxBounds(); + * ``` + */ + getMaxBounds(): LngLatBounds | null { + return this.transform.getMaxBounds(); + } + + /** + * Sets or clears the map's geographical bounds. + * + * Pan and zoom operations are constrained within these bounds. + * If a pan or zoom is performed that would + * display regions outside these bounds, the map will + * instead display a position and zoom level + * as close as possible to the operation's request while still + * remaining within the bounds. + * + * @param bounds - The maximum bounds to set. If `null` or `undefined` is provided, the function removes the map's maximum bounds. + * @example + * Define bounds that conform to the `LngLatBoundsLike` object as set the max bounds. + * ```ts + * let bounds = [ + * [-74.04728, 40.68392], // [west, south] + * [-73.91058, 40.87764] // [east, north] + * ]; + * map.setMaxBounds(bounds); + * ``` + */ + setMaxBounds(bounds?: LngLatBoundsLike | null): Map { + this.transform.setMaxBounds(bounds && LngLatBounds.convert(bounds)); + return this._update(); + } + + /** + * Sets or clears the map's minimum zoom level. + * If the map's current zoom level is lower than the new minimum, + * the map will zoom to the new minimum. + * + * It is not always possible to zoom out and reach the set `minZoom`. + * Other factors such as map height may restrict zooming. For example, + * if the map is 512px tall it will not be possible to zoom below zoom 0 + * no matter what the `minZoom` is set to. + * + * A {@link ErrorEvent} event will be fired if minZoom is out of bounds. + * + * @param minZoom - The minimum zoom level to set (-2 - 24). + * If `null` or `undefined` is provided, the function removes the current minimum zoom (i.e. sets it to -2). + * @example + * ```ts + * map.setMinZoom(12.25); + * ``` + */ + setMinZoom(minZoom?: number | null): Map { + minZoom = minZoom === null || minZoom === undefined ? defaultMinZoom : minZoom; + + if (minZoom >= defaultMinZoom && minZoom <= this.transform.maxZoom) { + this.transform.minZoom = minZoom; + this._update(); + + if (this.getZoom() < minZoom) this.setZoom(minZoom); + + return this; + } else + throw new Error( + `minZoom must be between ${defaultMinZoom} and the current maxZoom, inclusive`, + ); + } + + /** + * Returns the map's minimum allowable zoom level. + * + * @returns minZoom + * @example + * ```ts + * let minZoom = map.getMinZoom(); + * ``` + */ + getMinZoom(): number { + return this.transform.minZoom; + } + + /** + * Sets or clears the map's maximum zoom level. + * If the map's current zoom level is higher than the new maximum, + * the map will zoom to the new maximum. + * + * A {@link ErrorEvent} event will be fired if minZoom is out of bounds. + * + * @param maxZoom - The maximum zoom level to set. + * If `null` or `undefined` is provided, the function removes the current maximum zoom (sets it to 22). + * @example + * ```ts + * map.setMaxZoom(18.75); + * ``` + */ + setMaxZoom(maxZoom?: number | null): Map { + maxZoom = maxZoom === null || maxZoom === undefined ? defaultMaxZoom : maxZoom; + + if (maxZoom >= this.transform.minZoom) { + this.transform.maxZoom = maxZoom; + + if (this.getZoom() > maxZoom) this.setZoom(maxZoom); + + return this; + } else throw new Error('maxZoom must be greater than the current minZoom'); + } + + /** + * Returns the map's maximum allowable zoom level. + * + * @returns The maxZoom + * @example + * ```ts + * let maxZoom = map.getMaxZoom(); + * ``` + */ + getMaxZoom(): number { + return this.transform.maxZoom; + } + + /** + * Sets or clears the map's minimum pitch. + * If the map's current pitch is lower than the new minimum, + * the map will pitch to the new minimum. + * + * A {@link ErrorEvent} event will be fired if minPitch is out of bounds. + * + * @param minPitch - The minimum pitch to set (0-85). Values greater than 60 degrees are experimental and may result in rendering issues. If you encounter any, please raise an issue with details in the MapLibre project. + * If `null` or `undefined` is provided, the function removes the current minimum pitch (i.e. sets it to 0). + */ + setMinPitch(minPitch?: number | null): Map { + minPitch = minPitch === null || minPitch === undefined ? defaultMinPitch : minPitch; + + if (minPitch < defaultMinPitch) { + throw new Error(`minPitch must be greater than or equal to ${defaultMinPitch}`); + } + + if (minPitch >= defaultMinPitch && minPitch <= this.transform.maxPitch) { + this.transform.minPitch = minPitch; + + if (this.getPitch() < minPitch) this.setPitch(minPitch); + + return this; + } else + throw new Error( + `minPitch must be between ${defaultMinPitch} and the current maxPitch, inclusive`, + ); + } + + /** + * Returns the map's minimum allowable pitch. + * + * @returns The minPitch + */ + getMinPitch(): number { + return this.transform.minPitch; + } + + /** + * Sets or clears the map's maximum pitch. + * If the map's current pitch is higher than the new maximum, + * the map will pitch to the new maximum. + * + * A {@link ErrorEvent} event will be fired if maxPitch is out of bounds. + * + * @param maxPitch - The maximum pitch to set (0-85). Values greater than 60 degrees are experimental and may result in rendering issues. If you encounter any, please raise an issue with details in the MapLibre project. + * If `null` or `undefined` is provided, the function removes the current maximum pitch (sets it to 60). + */ + setMaxPitch(maxPitch?: number | null): Map { + maxPitch = maxPitch === null || maxPitch === undefined ? defaultMaxPitch : maxPitch; + + if (maxPitch > maxPitchThreshold) { + throw new Error(`maxPitch must be less than or equal to ${maxPitchThreshold}`); + } + + if (maxPitch >= this.transform.minPitch) { + this.transform.maxPitch = maxPitch; + + if (this.getPitch() > maxPitch) this.setPitch(maxPitch); + + return this; + } else throw new Error('maxPitch must be greater than the current minPitch'); + } + + /** + * Returns the map's maximum allowable pitch. + * + * @returns The maxPitch + */ + getMaxPitch(): number { + return this.transform.maxPitch; + } + + /** + * Returns the state of `renderWorldCopies`. If `true`, multiple copies of the world will be rendered side by side beyond -180 and 180 degrees longitude. If set to `false`: + * + * - When the map is zoomed out far enough that a single representation of the world does not fill the map's entire + * container, there will be blank space beyond 180 and -180 degrees longitude. + * - Features that cross 180 and -180 degrees longitude will be cut in two (with one portion on the right edge of the + * map and the other on the left edge of the map) at every zoom level. + * @returns The renderWorldCopies + * @example + * ```ts + * let worldCopiesRendered = map.getRenderWorldCopies(); + * ``` + * @see [Render world copies](https://maplibre.org/maplibre-gl-js/docs/examples/render-world-copies/) + */ + getRenderWorldCopies(): boolean { + return this.transform.renderWorldCopies; + } + + /** + * Sets the state of `renderWorldCopies`. + * + * @param renderWorldCopies - If `true`, multiple copies of the world will be rendered side by side beyond -180 and 180 degrees longitude. If set to `false`: + * + * - When the map is zoomed out far enough that a single representation of the world does not fill the map's entire + * container, there will be blank space beyond 180 and -180 degrees longitude. + * - Features that cross 180 and -180 degrees longitude will be cut in two (with one portion on the right edge of the + * map and the other on the left edge of the map) at every zoom level. + * + * `undefined` is treated as `true`, `null` is treated as `false`. + * @example + * ```ts + * map.setRenderWorldCopies(true); + * ``` + */ + setRenderWorldCopies(renderWorldCopies?: boolean | null) { + this.transform.renderWorldCopies = renderWorldCopies; + } + + /** + * Returns a [Point](https://github.com/mapbox/point-geometry) representing pixel coordinates, relative to the map's `container`, + * that correspond to the specified geographical location. + * + * @param lnglat - The geographical location to project. + * @returns The [Point](https://github.com/mapbox/point-geometry) corresponding to `lnglat`, relative to the map's `container`. + * @example + * ```ts + * let coordinate = [-122.420679, 37.772537]; + * let point = map.project(coordinate); + * ``` + */ + project(lnglat: LngLatLike): Point { + return this.transform.locationPoint(LngLat.convert(lnglat)); + } + + /** + * Returns a {@link LngLat} representing geographical coordinates that correspond + * to the specified pixel coordinates. + * + * @param point - The pixel coordinates to unproject. + * @returns The {@link LngLat} corresponding to `point`. + * @example + * ```ts + * map.on('click', (e) => { + * // When the map is clicked, get the geographic coordinate. + * let coordinate = map.unproject(e.point); + * }); + * ``` + */ + unproject(point: PointLike): LngLat { + return this.transform.pointLocation(Point.convert(point)); + } + + /** + * Returns true if the map is panning, zooming, rotating, or pitching due to a camera animation or user gesture. + * @returns true if the map is moving. + * @example + * ```ts + * let isMoving = map.isMoving(); + * ``` + */ + isMoving(): boolean { + return this._moving || this.handlers?.isMoving(); + } + + /** + * Returns true if the map is zooming due to a camera animation or user gesture. + * @returns true if the map is zooming. + * @example + * ```ts + * let isZooming = map.isZooming(); + * ``` + */ + isZooming(): boolean { + return this._zooming || this.handlers?.isZooming(); + } + + /** + * Returns true if the map is rotating due to a camera animation or user gesture. + * @returns true if the map is rotating. + * @example + * ```ts + * map.isRotating(); + * ``` + */ + isRotating(): boolean { + return this._rotating || this.handlers?.isRotating(); + } + + /** + * Overload of the `on` method that allows to listen to events without specifying a layer. + * @event + * @param type - The type of the event. + * @param listener - The listener callback. + */ + on(type: T, listener: (ev: MapEventType[T] & Object) => void): this; + /** + * Overload of the `on` method that allows to listen to events without specifying a layer. + * @event + * @param type - The type of the event. + * @param listener - The listener callback. + */ + on(type: keyof MapEventType | string, listener: Listener): this; + on(type: keyof MapEventType | string, listener: Listener): this { + return super.on(type, listener); + } + + /** + * Overload of the `once` method that allows to listen to events without specifying a layer. + * @event + * @param type - The type of the event. + * @param listener - The listener callback. + */ + once( + type: T, + listener?: (ev: MapEventType[T] & Object) => void, + ): this | Promise; + /** + * Overload of the `once` method that allows to listen to events without specifying a layer. + * @event + * @param type - The type of the event. + * @param listener - The listener callback. + */ + once(type: keyof MapEventType | string, listener?: Listener): this | Promise; + once(type: keyof MapEventType | string, listener?: Listener): this | Promise { + return super.once(type, listener); + } + + /** + * Overload of the `off` method that allows to listen to events without specifying a layer. + * @event + * @param type - The type of the event. + * @param listener - The function previously installed as a listener. + */ + off( + type: T, + listener: (ev: MapEventType[T] & Object) => void, + ): this; + /** + * Overload of the `off` method that allows to listen to events without specifying a layer. + * @event + * @param type - The type of the event. + * @param listener - The function previously installed as a listener. + */ + off(type: keyof MapEventType | string, listener: Listener): this; + off(type: keyof MapEventType | string, listener: Listener): this { + return super.off(type, listener); + } + + /** + * Returns the map's containing HTML element. + * + * @returns The map's container. + */ + getContainer(): HTMLElement { + return this._container; + } + + /** + * Returns the HTML element containing the map's `` element. + * + * If you want to add non-GL overlays to the map, you should append them to this element. + * + * This is the element to which event bindings for map interactivity (such as panning and zooming) are + * attached. It will receive bubbled events from child elements such as the ``, but not from + * map controls. + * + * @returns The container of the map's ``. + * @see [Create a draggable point](https://maplibre.org/maplibre-gl-js/docs/examples/drag-a-point/) + */ + getCanvasContainer(): HTMLElement { + return this._canvasContainer; + } + + _containerDimensions() { + let width = 0; + let height = 0; + + if (this._container) { + width = this._container.clientWidth || 400; + height = this._container.clientHeight || 300; + } + + return [width, height]; + } + + _setupContainer() { + const container = this._container; + container.classList.add('l7-map'); + + const canvasContainer = (this._canvasContainer = DOM.create( + 'div', + 'l7-canvas-container', + container, + )); + if (this._interactive) { + canvasContainer.classList.add('l7-interactive'); + } + + this._container.addEventListener('scroll', this._onMapScroll, false); + } + + _onMapScroll = (event: any) => { + if (event.target !== this._container) return; + + // Revert any scroll which would move the canvas outside of the view + this._container.scrollTop = 0; + this._container.scrollLeft = 0; + return false; + }; + + /** + * @internal + * Update this map's style and sources, and re-render the map. + * + * @param updateStyle - mark the map's style for reprocessing as + * well as its sources + */ + _update() { + this.triggerRepaint(); + + return this; + } + + /** + * @internal + * Request that the given callback be executed during the next render + * frame. Schedule a render frame if one is not already scheduled. + * + * @returns An id that can be used to cancel the callback + */ + _requestRenderFrame(callback: () => void): TaskID { + this._update(); + return this._renderTaskQueue.add(callback); + } + + _cancelRenderFrame(id: TaskID) { + this._renderTaskQueue.remove(id); + } + + /** + * @internal + * Call when a (re-)render of the map is required: + * + * - The style has changed (`setPaintProperty()`, etc.) + * - Source data has changed (e.g. tiles have finished loading) + * - The map has is moving (or just finished moving) + * - A transition is in progress + * + * @param paintStartTimeStamp - The time when the animation frame began executing. + */ + _render(paintStartTimeStamp: number) { + this._renderTaskQueue.run(paintStartTimeStamp); + // A task queue callback may have fired a user event which may have removed the map + if (this._removed) return; + + this.fire(new Event('render')); + + if (!this.isMoving()) { + this.fire(new Event('idle')); + } + + return this; + } + + /** + * Clean up and release all internal resources associated with this map. + * + * This includes DOM elements, event bindings, web workers, and WebGL resources. + * + * Use this method when you are done using the map and wish to ensure that it no + * longer consumes browser resources. Afterwards, you must not call any other + * methods on the map. + */ + remove() { + if (this._frameRequest) { + this._frameRequest.abort(); + this._frameRequest = null; + } + this._renderTaskQueue.clear(); + this.handlers.destroy(); + delete this.handlers; + + this._resizeObserver?.disconnect(); + DOM.remove(this._canvasContainer); + this._container.classList.remove('l7-map'); + + this._removed = true; + this.fire(new Event('remove')); + } + + /** + * Trigger the rendering of a single frame. Use this method with custom layers to + * repaint the map when the layer changes. Calling this multiple times before the + * next frame is rendered will still result in only a single frame being rendered. + * @example + * ```ts + * map.triggerRepaint(); + * ``` + */ + triggerRepaint() { + if (!this._frameRequest) { + this._frameRequest = new AbortController(); + browser + .frameAsync(this._frameRequest) + .then((paintStartTimeStamp: number) => { + this._frameRequest = null; + this._render(paintStartTimeStamp); + }) + .catch(() => {}); // ignore abort error + } + } + + /** + * Returns the elevation for the point where the camera is looking. + * This value corresponds to: + * "meters above sea level" * "exaggeration" + * @returns The elevation. + */ + getCameraTargetElevation(): number { + return this.transform.elevation; + } +} diff --git a/packages/map/src/map/util/abort_error.ts b/packages/map/src/map/util/abort_error.ts new file mode 100644 index 00000000000..a635b63d677 --- /dev/null +++ b/packages/map/src/map/util/abort_error.ts @@ -0,0 +1,21 @@ +/** + * An error message to use when an operation is aborted + */ +export const ABORT_ERROR = 'AbortError'; + +/** + * Check if an error is an abort error + * @param error - An error object + * @returns - true if the error is an abort error + */ +export function isAbortError(error: Error): boolean { + return error.message === ABORT_ERROR; +} + +/** + * Use this when you need to create an abort error. + * @returns An error object with the message "AbortError" + */ +export function createAbortError(): Error { + return new Error(ABORT_ERROR); +} diff --git a/packages/map/src/map/util/browser.ts b/packages/map/src/map/util/browser.ts new file mode 100755 index 00000000000..4989f3c05d8 --- /dev/null +++ b/packages/map/src/map/util/browser.ts @@ -0,0 +1,37 @@ +import { createAbortError } from './abort_error'; + +const now = + typeof performance !== 'undefined' && performance && performance.now + ? performance.now.bind(performance) + : Date.now.bind(Date); + +let reducedMotionQuery: MediaQueryList; + +/** */ +export const browser = { + /** + * Provides a function that outputs milliseconds: either performance.now() + * or a fallback to Date.now() + */ + now, + + frameAsync(abortController: AbortController): Promise { + return new Promise((resolve, reject) => { + const frame = requestAnimationFrame(resolve); + abortController.signal.addEventListener('abort', () => { + cancelAnimationFrame(frame); + reject(createAbortError()); + }); + }); + }, + + get prefersReducedMotion(): boolean { + // In case your test crashes when checking matchMedia, call setMatchMedia from 'src/util/test/util' + if (!window.matchMedia) return false; + //Lazily initialize media query + if (reducedMotionQuery == null) { + reducedMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); + } + return reducedMotionQuery.matches; + }, +}; diff --git a/packages/map/src/map/util/dom.ts b/packages/map/src/map/util/dom.ts new file mode 100644 index 00000000000..89f14983484 --- /dev/null +++ b/packages/map/src/map/util/dom.ts @@ -0,0 +1,155 @@ +import Point from '@mapbox/point-geometry'; + +type ScaleReturnValue = { + x: number; + y: number; + boundingClientRect: DOMRect; +}; + +export class DOM { + private static readonly docStyle = + typeof window !== 'undefined' && window.document && window.document.documentElement.style; + + private static userSelect: string; + + private static selectProp = DOM.testProp([ + 'userSelect', + 'MozUserSelect', + 'WebkitUserSelect', + 'msUserSelect', + ]); + + private static transformProp = DOM.testProp(['transform', 'WebkitTransform']); + + private static testProp(props: string[]): string { + if (!DOM.docStyle) return props[0]; + for (let i = 0; i < props.length; i++) { + if (props[i] in DOM.docStyle) { + return props[i]; + } + } + return props[0]; + } + + public static create( + tagName: K, + className?: string, + container?: HTMLElement, + ): HTMLElementTagNameMap[K] { + const el = window.document.createElement(tagName); + if (className !== undefined) el.className = className; + if (container) container.appendChild(el); + return el; + } + + public static createNS(namespaceURI: string, tagName: string) { + const el = window.document.createElementNS(namespaceURI, tagName); + return el; + } + + public static disableDrag() { + if (DOM.docStyle && DOM.selectProp) { + DOM.userSelect = DOM.docStyle[DOM.selectProp]; + DOM.docStyle[DOM.selectProp] = 'none'; + } + } + + public static enableDrag() { + if (DOM.docStyle && DOM.selectProp) { + DOM.docStyle[DOM.selectProp] = DOM.userSelect; + } + } + + public static setTransform(el: HTMLElement, value: string) { + el.style[DOM.transformProp] = value; + } + + public static addEventListener( + target: HTMLElement | Window | Document, + type: string, + callback: EventListenerOrEventListenerObject, + options: { + passive?: boolean; + capture?: boolean; + } = {}, + ) { + if ('passive' in options) { + target.addEventListener(type, callback, options); + } else { + target.addEventListener(type, callback, options.capture); + } + } + + public static removeEventListener( + target: HTMLElement | Window | Document, + type: string, + callback: EventListenerOrEventListenerObject, + options: { + passive?: boolean; + capture?: boolean; + } = {}, + ) { + if ('passive' in options) { + target.removeEventListener(type, callback, options); + } else { + target.removeEventListener(type, callback, options.capture); + } + } + + // Suppress the next click, but only if it's immediate. + private static suppressClickInternal(e) { + e.preventDefault(); + e.stopPropagation(); + window.removeEventListener('click', DOM.suppressClickInternal, true); + } + + public static suppressClick() { + window.addEventListener('click', DOM.suppressClickInternal, true); + window.setTimeout(() => { + window.removeEventListener('click', DOM.suppressClickInternal, true); + }, 0); + } + + private static getScale(element: HTMLElement): ScaleReturnValue { + const rect = element.getBoundingClientRect(); + return { + x: rect.width / element.offsetWidth || 1, + y: rect.height / element.offsetHeight || 1, + boundingClientRect: rect, + }; + } + + private static getPoint(el: HTMLElement, scale: ScaleReturnValue, e: MouseEvent | Touch): Point { + const rect = scale.boundingClientRect; + return new Point( + // rect.left/top values are in page scale (like clientX/Y), + // whereas clientLeft/Top (border width) values are the original values (before CSS scale applies). + (e.clientX - rect.left) / scale.x - el.clientLeft, + (e.clientY - rect.top) / scale.y - el.clientTop, + ); + } + + public static mousePos(el: HTMLElement, e: MouseEvent | Touch): Point { + const scale = DOM.getScale(el); + return DOM.getPoint(el, scale, e); + } + + public static touchPos(el: HTMLElement, touches: TouchList) { + const points: Point[] = []; + const scale = DOM.getScale(el); + for (let i = 0; i < touches.length; i++) { + points.push(DOM.getPoint(el, scale, touches[i])); + } + return points; + } + + public static mouseButton(e: MouseEvent) { + return e.button; + } + + public static remove(node: HTMLElement) { + if (node.parentNode) { + node.parentNode.removeChild(node); + } + } +} diff --git a/packages/map/src/map/util/evented.ts b/packages/map/src/map/util/evented.ts new file mode 100644 index 00000000000..efdcf59f0e6 --- /dev/null +++ b/packages/map/src/map/util/evented.ts @@ -0,0 +1,192 @@ +import { extend } from './util'; + +/** + * A listener method used as a callback to events + */ +export type Listener = (a: any) => any; + +type Listeners = { [_: string]: Array }; + +function _addEventListener(type: string, listener: Listener, listenerList: Listeners) { + const listenerExists = listenerList[type] && listenerList[type].indexOf(listener) !== -1; + if (!listenerExists) { + listenerList[type] = listenerList[type] || []; + listenerList[type].push(listener); + } +} + +function _removeEventListener(type: string, listener: Listener, listenerList: Listeners) { + if (listenerList && listenerList[type]) { + const index = listenerList[type].indexOf(listener); + if (index !== -1) { + listenerList[type].splice(index, 1); + } + } +} + +/** + * The event class + */ +export class Event { + public readonly type: string; + constructor(type: string, data: any = {}) { + extend(this, data); + this.type = type; + } +} + +interface ErrorLike { + message: string; +} + +/** + * An error event + */ +export class ErrorEvent extends Event { + error: ErrorLike; + + constructor(error: ErrorLike, data: any = {}) { + super('error', data); + this.error = error; + } +} + +/** + * Methods mixed in to other classes for event capabilities. + * + * @group Event Related + */ +export class Evented { + _listeners: Listeners; + _oneTimeListeners: Listeners; + _eventedParent: Evented; + _eventedParentData: any | (() => any); + + /** + * Adds a listener to a specified event type. + * + * @param type - The event type to add a listen for. + * @param listener - The function to be called when the event is fired. + * The listener function is called with the data object passed to `fire`, + * extended with `target` and `type` properties. + */ + on(type: string, listener: Listener): this { + this._listeners = this._listeners || {}; + _addEventListener(type, listener, this._listeners); + + return this; + } + + /** + * Removes a previously registered event listener. + * + * @param type - The event type to remove listeners for. + * @param listener - The listener function to remove. + */ + off(type: string, listener: Listener) { + _removeEventListener(type, listener, this._listeners); + _removeEventListener(type, listener, this._oneTimeListeners); + + return this; + } + + /** + * Adds a listener that will be called only once to a specified event type. + * + * The listener will be called first time the event fires after the listener is registered. + * + * @param type - The event type to listen for. + * @param listener - The function to be called when the event is fired the first time. + * @returns `this` or a promise if a listener is not provided + */ + once(type: string, listener?: Listener): this | Promise { + if (!listener) { + return new Promise((resolve) => this.once(type, resolve)); + } + this._oneTimeListeners = this._oneTimeListeners || {}; + _addEventListener(type, listener, this._oneTimeListeners); + + return this; + } + + fire(event: Event | string, properties?: any) { + // Compatibility with (type: string, properties: Object) signature from previous versions. + // See https://github.com/mapbox/mapbox-gl-js/issues/6522, + // https://github.com/mapbox/mapbox-gl-draw/issues/766 + if (typeof event === 'string') { + event = new Event(event, properties || {}); + } + + const type = event.type; + + if (this.listens(type)) { + (event as any).target = this; + + // make sure adding or removing listeners inside other listeners won't cause an infinite loop + const listeners = + this._listeners && this._listeners[type] ? this._listeners[type].slice() : []; + for (const listener of listeners) { + listener.call(this, event); + } + + const oneTimeListeners = + this._oneTimeListeners && this._oneTimeListeners[type] + ? this._oneTimeListeners[type].slice() + : []; + for (const listener of oneTimeListeners) { + _removeEventListener(type, listener, this._oneTimeListeners); + listener.call(this, event); + } + + const parent = this._eventedParent; + if (parent) { + extend( + event, + typeof this._eventedParentData === 'function' + ? this._eventedParentData() + : this._eventedParentData, + ); + parent.fire(event); + } + + // To ensure that no error events are dropped, print them to the + // console if they have no listeners. + } else if (event instanceof ErrorEvent) { + console.error(event.error); + } + + return this; + } + + emit(event: Event | string, properties?: any) { + return this.fire(event, properties); + } + + /** + * Returns a true if this instance of Evented or any forwardeed instances of Evented have a listener for the specified type. + * + * @param type - The event type + * @returns `true` if there is at least one registered listener for specified event type, `false` otherwise + */ + listens(type: string): boolean { + return ( + (this._listeners && this._listeners[type] && this._listeners[type].length > 0) || + (this._oneTimeListeners && + this._oneTimeListeners[type] && + this._oneTimeListeners[type].length > 0) || + (this._eventedParent && this._eventedParent.listens(type)) + ); + } + + /** + * Bubble all events fired by this instance of Evented to this parent instance of Evented. + */ + setEventedParent(parent?: Evented | null, data?: any | (() => any)) { + if (parent) { + this._eventedParent = parent; + } + this._eventedParentData = data; + + return this; + } +} diff --git a/packages/map/src/map/util/task_queue.ts b/packages/map/src/map/util/task_queue.ts new file mode 100644 index 00000000000..9b6d9f4d0ff --- /dev/null +++ b/packages/map/src/map/util/task_queue.ts @@ -0,0 +1,64 @@ +export type TaskID = number; + +type Task = { + callback: (timeStamp: number) => void; + id: TaskID; + cancelled: boolean; +}; + +export class TaskQueue { + _queue: Array; + _id: TaskID; + _cleared: boolean; + _currentlyRunning: Array | false; + + constructor() { + this._queue = []; + this._id = 0; + this._cleared = false; + this._currentlyRunning = false; + } + + add(callback: (timeStamp: number) => void): TaskID { + const id = ++this._id; + const queue = this._queue; + queue.push({ callback, id, cancelled: false }); + return id; + } + + remove(id: TaskID) { + const running = this._currentlyRunning; + const queue = running ? this._queue.concat(running) : this._queue; + for (const task of queue) { + if (task.id === id) { + task.cancelled = true; + return; + } + } + } + + run(timeStamp: number = 0) { + if (this._currentlyRunning) throw new Error('Attempting to run(), but is already running.'); + const queue = (this._currentlyRunning = this._queue); + + // Tasks queued by callbacks in the current queue should be executed + // on the next run, not the current run. + this._queue = []; + + for (const task of queue) { + if (task.cancelled) continue; + task.callback(timeStamp); + if (this._cleared) break; + } + + this._cleared = false; + this._currentlyRunning = false; + } + + clear() { + if (this._currentlyRunning) { + this._cleared = true; + } + this._queue = []; + } +} diff --git a/packages/map/src/map/util/util.ts b/packages/map/src/map/util/util.ts new file mode 100644 index 00000000000..cb0084d7e38 --- /dev/null +++ b/packages/map/src/map/util/util.ts @@ -0,0 +1,171 @@ +import UnitBezier from '@mapbox/unitbezier'; + +export const interpolates = { + number: function number(from: number, to: number, t: number) { + return from + t * (to - from); + }, +}; + +/** + * constrain n to the given range via min + max + * + * @param n - value + * @param min - the minimum value to be returned + * @param max - the maximum value to be returned + * @returns the clamped value + */ +export function clamp(n: number, min: number, max: number): number { + return Math.min(max, Math.max(min, n)); +} + +/** + * constrain n to the given range, excluding the minimum, via modular arithmetic + * + * @param n - value + * @param min - the minimum value to be returned, exclusive + * @param max - the maximum value to be returned, inclusive + * @returns constrained number + */ +export function wrap(n: number, min: number, max: number): number { + const d = max - min; + const w = ((((n - min) % d) + d) % d) + min; + return w === min ? max : w; +} + +let id = 1; + +/** + * Return a unique numeric id, starting at 1 and incrementing with + * each call. + * + * @returns unique numeric id. + */ +export function uniqueId(): number { + return id++; +} + +/** + * Given a destination object and optionally many source objects, + * copy all properties from the source objects into the destination. + * The last source object given overrides properties from previous + * source objects. + * + * @param dest - destination object + * @param sources - sources from which properties are pulled + */ +export function extend(dest: T, source: U): T & U; +export function extend(dest: T, source1: U, source2: V): T & U & V; +export function extend( + dest: T, + source1: U, + source2: V, + source3: W, +): T & U & V & W; +export function extend(dest: Record, ...sources: Array): any; +export function extend(dest: Record, ...sources: Array): any { + for (const src of sources) { + for (const k in src) { + dest[k] = src[k]; + } + } + return dest; +} + +// See https://stackoverflow.com/questions/49401866/all-possible-keys-of-an-union-type +type KeysOfUnion = T extends T ? keyof T : never; + +/** + * Given an object and a number of properties as strings, return version + * of that object with only those properties. + * + * @param src - the object + * @param properties - an array of property names chosen + * to appear on the resulting object. + * @returns object with limited properties. + * @example + * ```ts + * let foo = { name: 'Charlie', age: 10 }; + * let justName = pick(foo, ['name']); // justName = { name: 'Charlie' } + * ``` + */ +export function pick(src: T, properties: Array>): Partial { + const result: Partial = {}; + for (let i = 0; i < properties.length; i++) { + const k = properties[i]; + if (k in src) { + result[k] = src[k]; + } + } + return result; +} + +/** + * Makes optional keys required and add the the undefined type. + * + * ``` + * interface Test { + * foo: number; + * bar?: number; + * baz: number | undefined; + * } + * + * Complete { + * foo: number; + * bar: number | undefined; + * baz: number | undefined; + * } + * + * ``` + * + * See https://medium.com/terria/typescript-transforming-optional-properties-to-required-properties-that-may-be-undefined-7482cb4e1585 + */ + +export type Complete = { + [P in keyof Required]: Pick extends Required> ? T[P] : T[P] | undefined; +}; + +/** + * Given given (x, y), (x1, y1) control points for a bezier curve, + * return a function that interpolates along that curve. + * + * @param p1x - control point 1 x coordinate + * @param p1y - control point 1 y coordinate + * @param p2x - control point 2 x coordinate + * @param p2y - control point 2 y coordinate + */ +export function bezier(p1x: number, p1y: number, p2x: number, p2y: number): (t: number) => number { + const bezier = new UnitBezier(p1x, p1y, p2x, p2y); + return (t: number) => { + return bezier.solve(t); + }; +} + +/** + * A default bezier-curve powered easing function with + * control points (0.25, 0.1) and (0.25, 1) + */ +export const defaultEasing = bezier(0.25, 0.1, 0.25, 1); + +/** + * Print a warning message to the console and ensure duplicate warning messages + * are not printed. + */ +const warnOnceHistory: { [key: string]: boolean } = {}; + +export function warnOnce(message: string): void { + if (!warnOnceHistory[message]) { + // console isn't defined in some WebWorkers, see #2558 + if (typeof console !== 'undefined') console.warn(message); + warnOnceHistory[message] = true; + } +} + +/** + * This method converts degrees to radians. + * The return value is the radian value. + * @param degrees - The number of degrees + * @returns radians + */ +export function degreesToRadians(degrees: number): number { + return (degrees * Math.PI) / 180; +} diff --git a/packages/map/tsconfig.json b/packages/map/tsconfig.json index 4082f16a5d9..c5253f50b74 100644 --- a/packages/map/tsconfig.json +++ b/packages/map/tsconfig.json @@ -1,3 +1,6 @@ { - "extends": "../../tsconfig.json" + "extends": "../../tsconfig.json", + "compilerOptions": { + "strict": false + } } diff --git a/packages/maps/src/bmap/index.ts b/packages/maps/src/bmap/index.ts index 6abba1cbc1f..744d924b71b 100644 --- a/packages/maps/src/bmap/index.ts +++ b/packages/maps/src/bmap/index.ts @@ -1,7 +1,6 @@ -import type { Map } from '@antv/l7-map'; import BaseMapWrapper from '../utils/BaseMapWrapper'; import MapService from './map'; -export default class MapboxWrapper extends BaseMapWrapper { +export default class MapboxWrapper extends BaseMapWrapper { protected getServiceConstructor() { return MapService; } diff --git a/packages/maps/src/earth/index.ts b/packages/maps/src/earth/index.ts index 8374d687d70..bf6b5c5770b 100644 --- a/packages/maps/src/earth/index.ts +++ b/packages/maps/src/earth/index.ts @@ -1,7 +1,7 @@ -import type { Map } from '@antv/l7-map'; +import type { MapNext } from '@antv/l7-map'; import BaseMapWrapper from '../utils/BaseMapWrapper'; import MapService from './map'; -export default class EarthWrapper extends BaseMapWrapper { +export default class EarthWrapper extends BaseMapWrapper { protected getServiceConstructor() { return MapService; } diff --git a/packages/maps/src/earth/map.ts b/packages/maps/src/earth/map.ts index 9cd05b0801e..77c27175190 100644 --- a/packages/maps/src/earth/map.ts +++ b/packages/maps/src/earth/map.ts @@ -4,8 +4,7 @@ */ import type { IEarthService, IMercator, IViewport } from '@antv/l7-core'; import { CoordinateSystem, MapServiceEvent } from '@antv/l7-core'; -import type { Map } from '@antv/l7-map'; -import { EarthMap } from '@antv/l7-map'; +import { Map } from '@antv/l7-map'; import BaseMapService from '../utils/BaseMapService'; import Viewport from './Viewport'; const EventMap: { @@ -77,10 +76,8 @@ export default class L7EarthService extends BaseMapService implements IEart this.viewport = new Viewport(); this.$mapContainer = this.creatMapContainer(id); - // @ts-ignore - this.map = new EarthMap({ + this.map = new Map({ container: this.$mapContainer, - style: this.getMapStyleValue(style), bearing: rotation, ...rest, }); diff --git a/packages/maps/src/map/map.ts b/packages/maps/src/map/map.ts index 48dc28d6148..582ae587997 100644 --- a/packages/maps/src/map/map.ts +++ b/packages/maps/src/map/map.ts @@ -89,10 +89,8 @@ export default class DefaultMapService extends BaseMapService { this.$mapContainer = this.map.getContainer(); } else { this.$mapContainer = this.creatMapContainer(id); - // @ts-ignore this.map = new Map({ container: this.$mapContainer, - style: this.getMapStyleValue(style), bearing: rotation, ...rest, }); @@ -130,14 +128,11 @@ export default class DefaultMapService extends BaseMapService { } public exportMap(type: 'jpg' | 'png'): string { - const renderCanvas = this.map.getCanvas(); - const layersPng = - type === 'jpg' - ? (renderCanvas?.toDataURL('image/jpeg') as string) - : (renderCanvas?.toDataURL('image/png') as string); - return layersPng; + return ''; } + public setMapStyle(style: any): void {} + public getCanvasOverlays() { return this.getContainer(); } diff --git a/packages/maps/src/utils/BaseMapService.ts b/packages/maps/src/utils/BaseMapService.ts index 6ea3658f106..0e5216ddc69 100644 --- a/packages/maps/src/utils/BaseMapService.ts +++ b/packages/maps/src/utils/BaseMapService.ts @@ -18,7 +18,7 @@ import type { MapStyleName, } from '@antv/l7-core'; import { CoordinateSystem, MapServiceEvent } from '@antv/l7-core'; -import type { Map } from '@antv/l7-map'; +import type { MapNext } from '@antv/l7-map'; import { DOM } from '@antv/l7-utils'; import { EventEmitter } from 'eventemitter3'; import type { ISimpleMapCoord } from './simpleMapCoord'; @@ -35,9 +35,9 @@ const EventMap: { const LNGLAT_OFFSET_ZOOM_THRESHOLD = 12; -export default abstract class BaseMapService implements IMapService { +export default abstract class BaseMapService implements IMapService { public version: string = 'DEFAUlTMAP'; - public map: Map & T; + public map: MapNext & T; public simpleMapCoord: ISimpleMapCoord = new SimpleMapCoord(); // 背景色 public bgColor: string = 'rgba(0.0, 0.0, 0.0, 0.0)'; @@ -174,7 +174,6 @@ export default abstract class BaseMapService implements IMapService } public panBy(x: number = 0, y: number = 0): void { - // @ts-ignore this.map.panBy([x, y]); } @@ -230,7 +229,8 @@ export default abstract class BaseMapService implements IMapService } public setMapStyle(style: any): void { - this.map.setStyle(this.getMapStyleValue(style)); + // @ts-ignore + this.map?.setStyle(this.getMapStyleValue(style)); } // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -307,7 +307,8 @@ export default abstract class BaseMapService implements IMapService } public exportMap(type: 'jpg' | 'png'): string { - const renderCanvas = this.map.getCanvas(); + // @ts-ignore + const renderCanvas = this.map?.getCanvas(); const layersPng = type === 'jpg' ? (renderCanvas?.toDataURL('image/jpeg') as string)