maplibre-gl 6.2.0 → 6.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/dist/maplibre-gl-dev.mjs +1108 -427
  2. package/dist/maplibre-gl-dev.mjs.map +1 -1
  3. package/dist/maplibre-gl-shared-dev.mjs +359 -291
  4. package/dist/maplibre-gl-shared-dev.mjs.map +1 -1
  5. package/dist/maplibre-gl-shared.mjs +2 -2
  6. package/dist/maplibre-gl-shared.mjs.map +1 -1
  7. package/dist/maplibre-gl-worker-dev.mjs +23 -49
  8. package/dist/maplibre-gl-worker-dev.mjs.map +1 -1
  9. package/dist/maplibre-gl-worker.mjs +2 -2
  10. package/dist/maplibre-gl-worker.mjs.map +1 -1
  11. package/dist/maplibre-gl.css +1 -1
  12. package/dist/maplibre-gl.d.ts +467 -151
  13. package/dist/maplibre-gl.mjs +49 -49
  14. package/dist/maplibre-gl.mjs.map +1 -1
  15. package/package.json +12 -22
  16. package/src/data/bucket/fill_extrusion_bucket.ts +1 -13
  17. package/src/data/bucket/round_polygon_corners.test.ts +84 -36
  18. package/src/data/bucket/round_polygon_corners.ts +90 -39
  19. package/src/data/extent_bounds.ts +28 -1
  20. package/src/data/feature_index.bench.ts +21 -0
  21. package/src/data/segment.test.ts +2 -2
  22. package/src/geo/edge_insets.test.ts +1 -1
  23. package/src/geo/lng_lat.test.ts +7 -7
  24. package/src/geo/lng_lat_bounds.test.ts +4 -4
  25. package/src/geo/mercator_coordinate.test.ts +2 -2
  26. package/src/geo/projection/camera_helper.ts +5 -0
  27. package/src/geo/projection/globe_transform.test.ts +154 -1
  28. package/src/geo/projection/globe_transform.ts +7 -3
  29. package/src/geo/projection/globe_utils.ts +189 -2
  30. package/src/geo/projection/mercator_camera_helper.ts +1 -1
  31. package/src/geo/projection/mercator_transform.ts +21 -5
  32. package/src/geo/projection/vertical_perspective_camera_helper.test.ts +24 -0
  33. package/src/geo/projection/vertical_perspective_camera_helper.ts +71 -41
  34. package/src/geo/projection/vertical_perspective_transform.ts +7 -1
  35. package/src/geo/transform_interface.ts +13 -1
  36. package/src/index.ts +7 -2
  37. package/src/render/image_atlas.test.ts +180 -0
  38. package/src/render/image_atlas.ts +52 -6
  39. package/src/render/image_manager.test.ts +111 -0
  40. package/src/render/image_manager.ts +143 -144
  41. package/src/render/painter.ts +6 -14
  42. package/src/render/pattern_atlas.test.ts +131 -0
  43. package/src/render/pattern_atlas.ts +144 -0
  44. package/src/render/update_pattern_positions_in_program.test.ts +1 -1
  45. package/src/shaders/glsl/_prelude.vertex.glsl +10 -11
  46. package/src/shaders/glsl/_prelude.vertex.glsl.g.ts +1 -1
  47. package/src/shaders/glsl/circle.vertex.glsl +4 -4
  48. package/src/shaders/glsl/circle.vertex.glsl.g.ts +1 -1
  49. package/src/shaders/glsl/color_relief.fragment.glsl +1 -2
  50. package/src/shaders/glsl/color_relief.fragment.glsl.g.ts +1 -1
  51. package/src/shaders/glsl/fill_extrusion.vertex.glsl +3 -3
  52. package/src/shaders/glsl/fill_extrusion.vertex.glsl.g.ts +1 -1
  53. package/src/shaders/glsl/fill_extrusion_pattern.vertex.glsl +5 -5
  54. package/src/shaders/glsl/fill_extrusion_pattern.vertex.glsl.g.ts +1 -1
  55. package/src/shaders/glsl/heatmap.vertex.glsl +4 -4
  56. package/src/shaders/glsl/heatmap.vertex.glsl.g.ts +1 -1
  57. package/src/shaders/glsl/hillshade_prepare.fragment.glsl +12 -12
  58. package/src/shaders/glsl/hillshade_prepare.fragment.glsl.g.ts +1 -1
  59. package/src/shaders/glsl/line.vertex.glsl +7 -7
  60. package/src/shaders/glsl/line.vertex.glsl.g.ts +1 -1
  61. package/src/shaders/glsl/line_gradient.vertex.glsl +6 -6
  62. package/src/shaders/glsl/line_gradient.vertex.glsl.g.ts +1 -1
  63. package/src/shaders/glsl/line_gradient_sdf.vertex.glsl +7 -7
  64. package/src/shaders/glsl/line_gradient_sdf.vertex.glsl.g.ts +1 -1
  65. package/src/shaders/glsl/line_pattern.vertex.glsl +7 -7
  66. package/src/shaders/glsl/line_pattern.vertex.glsl.g.ts +1 -1
  67. package/src/shaders/glsl/line_sdf.vertex.glsl +7 -7
  68. package/src/shaders/glsl/line_sdf.vertex.glsl.g.ts +1 -1
  69. package/src/shaders/glsl/raster.fragment.glsl +4 -4
  70. package/src/shaders/glsl/raster.fragment.glsl.g.ts +1 -1
  71. package/src/shaders/glsl/raster.vertex.glsl +11 -6
  72. package/src/shaders/glsl/raster.vertex.glsl.g.ts +1 -1
  73. package/src/shaders/glsl/symbol_icon.vertex.glsl +5 -5
  74. package/src/shaders/glsl/symbol_icon.vertex.glsl.g.ts +1 -1
  75. package/src/shaders/glsl/symbol_sdf.vertex.glsl +5 -5
  76. package/src/shaders/glsl/symbol_sdf.vertex.glsl.g.ts +1 -1
  77. package/src/shaders/glsl/symbol_text_and_icon.vertex.glsl +6 -6
  78. package/src/shaders/glsl/symbol_text_and_icon.vertex.glsl.g.ts +1 -1
  79. package/src/source/canvas_source.test.ts +31 -0
  80. package/src/source/canvas_source.ts +2 -1
  81. package/src/source/geojson_worker_source.test.ts +88 -0
  82. package/src/source/geojson_worker_source.ts +22 -39
  83. package/src/source/image_source.test.ts +246 -38
  84. package/src/source/image_source.ts +132 -19
  85. package/src/source/raster_dem_tile_source.test.ts +37 -3
  86. package/src/source/raster_dem_tile_source.ts +1 -2
  87. package/src/source/raster_dem_tile_worker_source.test.ts +1 -1
  88. package/src/source/raster_tile_source.test.ts +5 -26
  89. package/src/source/raster_tile_source.ts +4 -3
  90. package/src/source/vector_tile_worker_source.test.ts +94 -2
  91. package/src/source/vector_tile_worker_source.ts +18 -27
  92. package/src/source/video_source.test.ts +12 -8
  93. package/src/source/worker_tile.bench.ts +10 -0
  94. package/src/source/worker_tile.ts +0 -3
  95. package/src/style/create_style_layer.bench.ts +15 -0
  96. package/src/style/properties.bench.ts +81 -0
  97. package/src/style/style.test.ts +75 -10
  98. package/src/style/style.ts +37 -64
  99. package/src/style/style_image.ts +67 -9
  100. package/src/style/style_layer.bench.ts +65 -0
  101. package/src/style/style_layer.test.ts +1 -1
  102. package/src/style/style_layer.ts +2 -2
  103. package/src/style/validate_style.bench.ts +11 -0
  104. package/src/style/validate_style.test.ts +2 -2
  105. package/src/symbol/anchor.test.ts +2 -2
  106. package/src/symbol/collision_index.bench.ts +116 -0
  107. package/src/symbol/cross_tile_symbol_index.bench.ts +49 -0
  108. package/src/symbol/cross_tile_symbol_index.test.ts +31 -0
  109. package/src/symbol/cross_tile_symbol_index.ts +67 -37
  110. package/src/symbol/shaping.test.ts +2 -1
  111. package/src/symbol/symbol_layout.bench.ts +30 -0
  112. package/src/tile/tile_id.test.ts +1 -1
  113. package/src/tile/tile_manager.test.ts +69 -1
  114. package/src/tile/tile_manager.ts +16 -11
  115. package/src/ui/control/fullscreen_control.test.ts +10 -10
  116. package/src/ui/control/geolocate_control.test.ts +3 -5
  117. package/src/ui/control/globe_control.test.ts +6 -6
  118. package/src/ui/events.ts +1 -1
  119. package/src/ui/handler/scroll_zoom.test.ts +50 -0
  120. package/src/ui/handler/two_fingers_touch.test.ts +17 -6
  121. package/src/ui/handler_inertia.test.ts +91 -0
  122. package/src/ui/handler_inertia.ts +65 -14
  123. package/src/ui/handler_manager.test.ts +243 -3
  124. package/src/ui/handler_manager.ts +79 -16
  125. package/src/ui/map.ts +17 -3
  126. package/src/ui/map_tests/map_cross_window.test.ts +2 -2
  127. package/src/ui/map_tests/map_events.test.ts +3 -3
  128. package/src/ui/map_tests/map_images.test.ts +55 -0
  129. package/src/ui/map_tests/map_style.test.ts +9 -8
  130. package/src/ui/map_tests/map_webgl.test.ts +30 -0
  131. package/src/ui/marker.test.ts +177 -42
  132. package/src/ui/marker.ts +120 -10
  133. package/src/ui/popup.test.ts +14 -14
  134. package/src/util/evented.test.ts +33 -28
  135. package/src/util/evented.ts +69 -43
  136. package/src/util/image_request.ts +31 -1
  137. package/src/util/test/null_gl.ts +10 -0
  138. package/src/util/test/util.ts +2 -1
  139. package/src/util/util.test.ts +1 -1
  140. package/src/util/web_worker_transfer.test.ts +5 -5
  141. package/src/webgl/context.ts +20 -0
  142. package/src/webgl/draw/draw_background.ts +1 -1
  143. package/src/webgl/draw/draw_raster.ts +11 -6
  144. package/src/webgl/program/pattern.ts +3 -3
  145. package/src/webgl/program/raster_program.ts +11 -0
  146. package/src/webgl/program.ts +31 -16
  147. package/src/webgl/texture.test.ts +16 -1
  148. package/src/webgl/texture.ts +3 -0
  149. package/src/webgl/vertex_buffer.test.ts +17 -2
  150. package/src/webgl/vertex_buffer.ts +24 -13
@@ -0,0 +1,91 @@
1
+ import {describe, beforeEach, afterEach, test, expect} from 'vitest';
2
+ import Point from '@mapbox/point-geometry';
3
+ import {DOM} from '../util/dom.ts';
4
+ import {beforeMapTest} from '../util/test/util.ts';
5
+ import {restoreNow, setNow} from '../util/time_control.ts';
6
+ import {Map} from './map.ts';
7
+ import type {EaseToOptions} from './camera.ts';
8
+ import {HandlerInertia} from './handler_inertia.ts';
9
+
10
+ type PanGesture = {
11
+ /**
12
+ * The pixels panned in each recorded frame, oldest first. `null` records a frame without
13
+ * any delta, which is what a terrain gesture does while the camera is not moving.
14
+ */
15
+ frames: Array<number | null>;
16
+ /**
17
+ * The milliseconds between the recorded frames.
18
+ */
19
+ frameTime?: number;
20
+ /**
21
+ * The milliseconds the gesture is held still between the last recorded frame and the release.
22
+ */
23
+ holdTime?: number;
24
+ };
25
+
26
+ /**
27
+ * Records a horizontal pan gesture and returns the inertial ease it produces, if any.
28
+ */
29
+ function panAndRelease(inertia: HandlerInertia, {frames, frameTime = 16, holdTime = 0}: PanGesture) {
30
+ let time = 1000;
31
+ setNow(time);
32
+ for (const pixels of frames) {
33
+ setNow(time += frameTime);
34
+ inertia.record(pixels === null ? {} : {panDelta: new Point(pixels, 0)});
35
+ }
36
+ setNow(time + holdTime);
37
+ return inertia._onMoveEnd();
38
+ }
39
+
40
+ /**
41
+ * Returns the horizontal distance the map keeps panning after the gesture ended.
42
+ */
43
+ function panOnDistance(ease: EaseToOptions) {
44
+ return (ease.offset as Point).x;
45
+ }
46
+
47
+ describe('HandlerInertia', () => {
48
+ let map: Map;
49
+ let inertia: HandlerInertia;
50
+
51
+ beforeEach(() => {
52
+ beforeMapTest();
53
+ map = new Map({container: DOM.create('div', '', window.document.body)});
54
+ inertia = new HandlerInertia(map);
55
+ });
56
+
57
+ afterEach(() => {
58
+ restoreNow();
59
+ map.remove();
60
+ });
61
+
62
+ test('pans on when the gesture is released while still moving', () => {
63
+ const ease = panAndRelease(inertia, {frames: [20, 20, 20, 20, 20]});
64
+
65
+ expect(panOnDistance(ease)).toBeGreaterThan(50);
66
+ });
67
+
68
+ test('barely pans on when the gesture is held still before being released', () => {
69
+ const ease = panAndRelease(inertia, {frames: [20, 20, 20, 20, 20], holdTime: 100});
70
+
71
+ expect(Math.abs(panOnDistance(ease))).toBeLessThan(5);
72
+ });
73
+
74
+ test('pans on at frame rates too low for two frames to fit into the velocity window', () => {
75
+ const ease = panAndRelease(inertia, {frames: [80, 80, 80], frameTime: 80});
76
+
77
+ expect(panOnDistance(ease)).toBeGreaterThan(20);
78
+ });
79
+
80
+ test('does not pan on when a single frame was recorded, twice in a row', () => {
81
+ expect(panAndRelease(inertia, {frames: [20]})).toBeUndefined();
82
+ // the frame left over by the first gesture must not be measured as part of the second
83
+ expect(panAndRelease(inertia, {frames: [20]})).toBeUndefined();
84
+ });
85
+
86
+ test('does not ease when the recorded frames hold no movement', () => {
87
+ const ease = panAndRelease(inertia, {frames: [20, 20, 20, null, null, null, null]});
88
+
89
+ expect(ease).toBeUndefined();
90
+ });
91
+ });
@@ -3,6 +3,7 @@ import {bezier, clamp, extend, evaluateZoomSnap} from '../util/util.ts';
3
3
  import Point from '@mapbox/point-geometry';
4
4
 
5
5
  import type {DragPanOptions} from './handler/shim/drag_pan.ts';
6
+ import type {HandlerResult} from './handler_manager.ts';
6
7
  import type {EaseToOptions} from './camera.ts';
7
8
  import type {Map} from './map.ts';
8
9
 
@@ -43,12 +44,24 @@ export type InertiaOptions = {
43
44
  maxSpeed: number;
44
45
  };
45
46
 
47
+ /**
48
+ * The maximum age of a recorded gesture update, in milliseconds.
49
+ */
50
+ const BUFFER_CUTOFF = 160;
51
+
52
+ /**
53
+ * The time window the gesture velocity is measured over, in milliseconds.
54
+ */
55
+ const VELOCITY_WINDOW = 60;
56
+
57
+ type InertiaBufferEntry = {
58
+ time: number;
59
+ settings: HandlerResult;
60
+ };
61
+
46
62
  export class HandlerInertia {
47
63
  _map: Map;
48
- _inertiaBuffer: Array<{
49
- time: number;
50
- settings: any;
51
- }>;
64
+ _inertiaBuffer: InertiaBufferEntry[];
52
65
 
53
66
  constructor(map: Map) {
54
67
  this._map = map;
@@ -59,23 +72,54 @@ export class HandlerInertia {
59
72
  this._inertiaBuffer = [];
60
73
  }
61
74
 
62
- record(settings: any): void {
75
+ record(settings: HandlerResult): void {
63
76
  this._drainInertiaBuffer();
64
77
  this._inertiaBuffer.push({time: now(), settings});
65
78
  }
66
79
 
67
80
  _drainInertiaBuffer(): void {
68
81
  const inertia = this._inertiaBuffer,
69
- currentTime = now(),
70
- cutoff = 160; //msec
82
+ currentTime = now();
71
83
 
72
- while (inertia.length > 0 && currentTime - inertia[0].time > cutoff)
84
+ while (inertia.length > 0 && currentTime - inertia[0].time > BUFFER_CUTOFF)
73
85
  inertia.shift();
74
86
  }
75
87
 
88
+ /**
89
+ * Returns the entries the velocity is measured from: everything recorded within
90
+ * {@link VELOCITY_WINDOW} before now, but never fewer than the last two, so that
91
+ * inertia also works below two recorded updates per window.
92
+ */
93
+ _getVelocityEntries(): InertiaBufferEntry[] {
94
+ const inertia = this._inertiaBuffer;
95
+ const windowStart = now() - VELOCITY_WINDOW;
96
+
97
+ let firstIndex = Math.max(0, inertia.length - 2);
98
+ while (firstIndex > 0 && inertia[firstIndex - 1].time >= windowStart) {
99
+ firstIndex--;
100
+ }
101
+
102
+ return inertia.slice(firstIndex);
103
+ }
104
+
105
+ /**
106
+ * Returns the ease which continues the gesture that has just ended.
107
+ *
108
+ * The velocity is measured over the time up to the release and not up to the last
109
+ * recorded movement, so that a gesture held still before being released has a lower
110
+ * velocity, down to no inertia at all. The delta of an entry happened before it was
111
+ * recorded, so the first entry only marks the start of the measured interval; anchors
112
+ * are the exception, as they describe the state at the moment of their entry.
113
+ *
114
+ * @param panInertiaOptions - overrides for the pan inertia defaults
115
+ * @returns the ease options, or `undefined` when the measured interval holds no
116
+ * movement, which terrain gestures produce by recording updates without any delta
117
+ */
76
118
  _onMoveEnd(panInertiaOptions?: DragPanOptions | boolean): EaseToOptions {
77
119
  this._drainInertiaBuffer();
78
- if (this._inertiaBuffer.length < 2) {
120
+ const entries = this._getVelocityEntries();
121
+ if (entries.length < 2) {
122
+ this.clear();
79
123
  return;
80
124
  }
81
125
 
@@ -89,18 +133,25 @@ export class HandlerInertia {
89
133
  around: undefined
90
134
  };
91
135
 
92
- for (const {settings} of this._inertiaBuffer) {
136
+ for (const {settings} of entries) {
137
+ if (settings.around) deltas.around = settings.around;
138
+ if (settings.pinchAround) deltas.pinchAround = settings.pinchAround;
139
+ }
140
+
141
+ for (const {settings} of entries.slice(1)) {
93
142
  deltas.zoom += settings.zoomDelta || 0;
94
143
  deltas.bearing += settings.bearingDelta || 0;
95
144
  deltas.pitch += settings.pitchDelta || 0;
96
145
  deltas.roll += settings.rollDelta || 0;
97
146
  if (settings.panDelta) deltas.pan._add(settings.panDelta);
98
- if (settings.around) deltas.around = settings.around;
99
- if (settings.pinchAround) deltas.pinchAround = settings.pinchAround;
100
147
  }
101
148
 
102
- const lastEntry = this._inertiaBuffer[this._inertiaBuffer.length - 1];
103
- const duration = (lastEntry.time - this._inertiaBuffer[0].time);
149
+ if (!deltas.pan.mag() && !deltas.zoom && !deltas.bearing && !deltas.pitch && !deltas.roll) {
150
+ this.clear();
151
+ return;
152
+ }
153
+
154
+ const duration = now() - entries[0].time;
104
155
 
105
156
  const easeOptions = {} as any;
106
157
 
@@ -8,15 +8,15 @@ import type {ICameraHelper, MapControlsDeltas} from '../geo/projection/camera_he
8
8
  import type {Terrain} from '../render/terrain.ts';
9
9
  import type {ITransform} from '../geo/transform_interface.ts';
10
10
  import {Event as MapEvent} from '../util/evented.ts';
11
- import {beforeMapTest, createMap} from '../util/test/util.ts';
11
+ import {MercatorCoordinate} from '../geo/mercator_coordinate.ts';
12
+ import {beforeMapTest, createMap, createTerrain} from '../util/test/util.ts';
13
+ import simulate from '../../test/unit/lib/simulate_interaction.ts';
12
14
 
13
15
  let map: Map;
14
16
  let manager: HandlerManager;
15
17
 
16
18
  beforeEach(() => {
17
19
  beforeMapTest();
18
- map = createMap();
19
- manager = map._handlers;
20
20
  });
21
21
 
22
22
  afterEach(() => {
@@ -25,6 +25,11 @@ afterEach(() => {
25
25
  });
26
26
 
27
27
  describe('HandlerManager terrain scenarios', () => {
28
+ beforeEach(() => {
29
+ map = createMap();
30
+ manager = map._handlers;
31
+ });
32
+
28
33
  test('_handleMapControls keeps terrain movement disabled when terrain is not enabled', () => {
29
34
  const handleZoom = vi.fn();
30
35
  const handlePan = vi.fn();
@@ -291,3 +296,238 @@ function createEventInProgress(name: keyof EventsInProgress): EventInProgress {
291
296
  originalEvent: new MapEvent(`${String(name)}start`),
292
297
  };
293
298
  }
299
+
300
+ describe('terrain gesture anchoring', () => {
301
+ async function setupGestureMap(pitch: number = 0): Promise<HTMLElement> {
302
+ map = createMap({interactive: true, zoom: 11, center: [7.5, 45.9], pitch, bearing: 0});
303
+ map.touchZoomRotate.disableRotation();
304
+ map._handlers._handlersById.tapZoom.disable();
305
+ map.touchPitch.disable();
306
+ await map.once('style.load');
307
+ return map.getCanvas();
308
+ }
309
+
310
+ function gestureStep(kind: 'touchstart' | 'touchmove', target: HTMLElement, positions: Point[]): void {
311
+ const touches = positions.map((p, i) => ({target, identifier: i + 1, clientX: p.x, clientY: p.y}));
312
+ simulate[kind](target, {touches});
313
+ map._renderTaskQueue.run();
314
+ }
315
+
316
+ function endGesture(target: HTMLElement): void {
317
+ simulate.touchend(target, {touches: []});
318
+ map._renderTaskQueue.run();
319
+ map._renderTaskQueue.run();
320
+ }
321
+
322
+ function pinchTouches(mid: Point, halfSpread: number): Point[] {
323
+ return [
324
+ new Point(mid.x, mid.y - halfSpread),
325
+ new Point(mid.x, mid.y + halfSpread),
326
+ ];
327
+ }
328
+
329
+ function slipOf(anchor: LngLat, expectedAt: Point): number {
330
+ return map.project(anchor).dist(expectedAt);
331
+ }
332
+
333
+ test('moving-centroid pinch with terrain keeps the grabbed terrain point under the fingers', async () => {
334
+ const target = await setupGestureMap();
335
+ const anchor = new LngLat(7.49, 45.905);
336
+ const anchorCoordinate = MercatorCoordinate.fromLngLat(anchor);
337
+ let raycastElevation = 1000;
338
+ map.terrain = {
339
+ ...createTerrain(),
340
+ pointCoordinate: () => new MercatorCoordinate(anchorCoordinate.x, anchorCoordinate.y, raycastElevation),
341
+ } as any as Terrain;
342
+
343
+ // Start the pinch with the finger midpoint exactly where the grabbed point renders
344
+ const start = map.project(anchor);
345
+ expect(start.x).toBeGreaterThan(20);
346
+ expect(start.x).toBeLessThan(180);
347
+ expect(start.y).toBeGreaterThan(20);
348
+ expect(start.y).toBeLessThan(180);
349
+
350
+ gestureStep('touchstart', target, pinchTouches(start, 30));
351
+ let mid = start;
352
+ let halfSpread = 30;
353
+ for (let step = 0; step < 3; step++) {
354
+ mid = mid.add(new Point(15, 10));
355
+ halfSpread += 10;
356
+ gestureStep('touchmove', target, pinchTouches(mid, halfSpread));
357
+ // terrain "loading" mid-gesture must not move the anchor plane
358
+ raycastElevation = 4000;
359
+ }
360
+
361
+ expect(map.getZoom()).toBeGreaterThan(11.5);
362
+ expect(map._handlers._terrainGestureAnchorElevation).toBe(1000);
363
+
364
+ const slip = slipOf(anchor, mid);
365
+ endGesture(target);
366
+ expect(slip).toBeLessThan(0.5);
367
+ expect(map._handlers._terrainGestureAnchorElevation).toBeNull();
368
+ });
369
+
370
+ test('pitched moving-centroid pinch with terrain keeps the grabbed terrain point under the fingers', async () => {
371
+ const target = await setupGestureMap(60);
372
+ const anchor = new LngLat(7.494, 45.904);
373
+ const anchorCoordinate = MercatorCoordinate.fromLngLat(anchor);
374
+ map.terrain = {
375
+ ...createTerrain(),
376
+ pointCoordinate: () => new MercatorCoordinate(anchorCoordinate.x, anchorCoordinate.y, 1000),
377
+ } as any as Terrain;
378
+
379
+ const start = map.project(anchor);
380
+ expect(start.x).toBeGreaterThan(20);
381
+ expect(start.x).toBeLessThan(180);
382
+ expect(start.y).toBeGreaterThan(20);
383
+ expect(start.y).toBeLessThan(180);
384
+
385
+ gestureStep('touchstart', target, pinchTouches(start, 25));
386
+ let mid = start;
387
+ let halfSpread = 25;
388
+ for (let step = 0; step < 3; step++) {
389
+ mid = mid.add(new Point(12, 8));
390
+ halfSpread += 8;
391
+ gestureStep('touchmove', target, pinchTouches(mid, halfSpread));
392
+ }
393
+
394
+ expect(map.getZoom()).toBeGreaterThan(11.5);
395
+
396
+ const slip = slipOf(anchor, mid);
397
+ endGesture(target);
398
+ expect(slip).toBeLessThan(0.5);
399
+ });
400
+
401
+ test('touch drag with terrain keeps a grabbed point elevated above the center plane under the finger', async () => {
402
+ const target = await setupGestureMap();
403
+ // a 2000 m ridge east of the center, with the center itself at 300 m
404
+ const ridgeStartLng = map.getCenter().lng + 0.003;
405
+ const elevationAt = (lngLat: LngLat) => lngLat.lng > ridgeStartLng ? 2000 : 300;
406
+ const anchor = new LngLat(map.getCenter().lng + 0.012, map.getCenter().lat);
407
+ const anchorCoordinate = MercatorCoordinate.fromLngLat(anchor);
408
+ map.terrain = {
409
+ ...createTerrain(),
410
+ getElevationForLngLat: (lngLat: LngLat) => elevationAt(lngLat),
411
+ getElevationForLngLatZoom: (lngLat: LngLat) => elevationAt(lngLat),
412
+ pointCoordinate: () => new MercatorCoordinate(anchorCoordinate.x, anchorCoordinate.y, 2000),
413
+ } as any as Terrain;
414
+ map._camera.transform.setElevation(300);
415
+
416
+ const start = map.project(anchor);
417
+ expect(start.x).toBeGreaterThan(20);
418
+ expect(start.x).toBeLessThan(180);
419
+
420
+ gestureStep('touchstart', target, [start]);
421
+ let finger = start;
422
+ for (let step = 0; step < 3; step++) {
423
+ finger = finger.add(new Point(-20, 0));
424
+ gestureStep('touchmove', target, [finger]);
425
+ }
426
+
427
+ expect(map.getCenter().lng).not.toBeCloseTo(7.5, 5);
428
+
429
+ const slip = slipOf(anchor, finger);
430
+ endGesture(target);
431
+ expect(slip).toBeLessThan(0.5);
432
+ });
433
+
434
+ test('center-anchored pinch over terrain still pans with the centroid', async () => {
435
+ const target = await setupGestureMap();
436
+ map.touchZoomRotate.enable({around: 'center'});
437
+ const anchor = new LngLat(7.49, 45.905);
438
+ const anchorCoordinate = MercatorCoordinate.fromLngLat(anchor);
439
+ map.terrain = {
440
+ ...createTerrain(),
441
+ pointCoordinate: () => new MercatorCoordinate(anchorCoordinate.x, anchorCoordinate.y, 1000),
442
+ } as any as Terrain;
443
+
444
+ const startCenter = map.getCenter();
445
+ const start = new Point(80, 90);
446
+ gestureStep('touchstart', target, pinchTouches(start, 30));
447
+ let mid = start;
448
+ let halfSpread = 30;
449
+ for (let step = 0; step < 3; step++) {
450
+ mid = mid.add(new Point(15, 10));
451
+ halfSpread += 10;
452
+ gestureStep('touchmove', target, pinchTouches(mid, halfSpread));
453
+ }
454
+
455
+ expect(map.getZoom()).toBeGreaterThan(11.5);
456
+ // center-anchored zoom keeps the center fixed, so only the pan moves it
457
+ const centerMovedPx = map.project(startCenter).dist(new Point(100, 100));
458
+ endGesture(target);
459
+ expect(centerMovedPx).toBeGreaterThan(20);
460
+ });
461
+
462
+ test('falls back to the center-elevation behavior when the terrain under the gesture is not loaded', async () => {
463
+ const target = await setupGestureMap();
464
+ map.terrain = {
465
+ ...createTerrain(),
466
+ pointCoordinate: () => null,
467
+ } as any as Terrain;
468
+
469
+ const startCenter = map.getCenter();
470
+ const start = new Point(80, 90);
471
+ gestureStep('touchstart', target, pinchTouches(start, 30));
472
+ let mid = start;
473
+ let halfSpread = 30;
474
+ for (let step = 0; step < 3; step++) {
475
+ mid = mid.add(new Point(15, 10));
476
+ halfSpread += 10;
477
+ gestureStep('touchmove', target, pinchTouches(mid, halfSpread));
478
+ }
479
+
480
+ // the gesture still zooms and pans sanely near where it started
481
+ expect(map.getZoom()).toBeGreaterThan(11.5);
482
+ expect(Math.abs(map.getCenter().lng - startCenter.lng)).toBeLessThan(0.5);
483
+ expect(Math.abs(map.getCenter().lat - startCenter.lat)).toBeLessThan(0.5);
484
+ endGesture(target);
485
+ });
486
+
487
+ test('falls back to the center-elevation behavior when the grabbed terrain point is above the camera altitude', async () => {
488
+ const target = await setupGestureMap();
489
+ map.terrain = {
490
+ ...createTerrain(),
491
+ pointCoordinate: () => new MercatorCoordinate(0.5, 0.35, 1e6),
492
+ } as any as Terrain;
493
+
494
+ const startCenter = map.getCenter();
495
+ const start = new Point(80, 90);
496
+ gestureStep('touchstart', target, pinchTouches(start, 30));
497
+ let mid = start;
498
+ let halfSpread = 30;
499
+ for (let step = 0; step < 3; step++) {
500
+ mid = mid.add(new Point(15, 10));
501
+ halfSpread += 10;
502
+ gestureStep('touchmove', target, pinchTouches(mid, halfSpread));
503
+ }
504
+
505
+ // the gesture still zooms and pans sanely near where it started
506
+ expect(map.getZoom()).toBeGreaterThan(11.5);
507
+ expect(Math.abs(map.getCenter().lng - startCenter.lng)).toBeLessThan(0.5);
508
+ expect(Math.abs(map.getCenter().lat - startCenter.lat)).toBeLessThan(0.5);
509
+ endGesture(target);
510
+ });
511
+
512
+ test('control: the same moving-centroid pinch without terrain keeps the grabbed point under the fingers', async () => {
513
+ const target = await setupGestureMap();
514
+
515
+ const start = new Point(67, 76);
516
+ const anchor = map.unproject(start);
517
+
518
+ gestureStep('touchstart', target, pinchTouches(start, 30));
519
+ let mid = start;
520
+ let halfSpread = 30;
521
+ for (let step = 0; step < 3; step++) {
522
+ mid = mid.add(new Point(15, 10));
523
+ halfSpread += 10;
524
+ gestureStep('touchmove', target, pinchTouches(mid, halfSpread));
525
+ }
526
+
527
+ expect(map.getZoom()).toBeGreaterThan(11.5);
528
+
529
+ const slip = slipOf(anchor, mid);
530
+ endGesture(target);
531
+ expect(slip).toBeLessThan(0.5);
532
+ });
533
+ });
@@ -31,6 +31,12 @@ import type {Terrain} from '../render/terrain.ts';
31
31
 
32
32
  const isMoving = (p: EventsInProgress) => p.zoom || p.drag || p.roll || p.pitch || p.rotate;
33
33
 
34
+ /**
35
+ * A terrain gesture's anchor plane must stay below this fraction of the camera's
36
+ * altitude over the center-elevation plane, or the ray-plane solve degenerates.
37
+ */
38
+ const TERRAIN_ANCHOR_MAX_CAMERA_ALTITUDE_FRACTION = 0.9;
39
+
34
40
  class RenderFrameEvent extends Event {
35
41
  type: 'renderFrame';
36
42
  timeStamp: number;
@@ -162,6 +168,11 @@ export class HandlerManager {
162
168
  _updatingCamera: boolean;
163
169
  _changes: Array<[HandlerResult, EventsInProgress, {[handlerName: string]: Event}]>;
164
170
  _terrainMovement: boolean;
171
+ /**
172
+ * Elevation in meters of the terrain point grabbed at gesture start; kept for the
173
+ * same lifetime as the elevation freeze. Null when that terrain was not available.
174
+ */
175
+ _terrainGestureAnchorElevation: number | null = null;
165
176
  _zoom: {handlerName: string};
166
177
  _previousActiveHandlers: {[x: string]: Handler};
167
178
  _listeners: Array<[Window | Document | HTMLElement, string, {
@@ -542,17 +553,10 @@ export class HandlerManager {
542
553
  // stop any ongoing camera animations (easeTo, flyTo)
543
554
  this._camera.stop(true);
544
555
 
545
- let {panDelta, zoomDelta, bearingDelta, pitchDelta, rollDelta, around, pinchAround} = combinedResult;
546
-
547
- if (pinchAround !== undefined) {
548
- around = pinchAround;
549
- }
550
-
551
- around ||= this._camera.transform.centerPoint;
556
+ const {panDelta, zoomDelta, bearingDelta, pitchDelta, rollDelta} = combinedResult;
552
557
 
553
- if (terrain && !tr.isPointOnMapSurface(around)) {
554
- around = tr.centerPoint;
555
- }
558
+ let {around, aroundOnSurface} = this._resolveAround(combinedResult, terrain, tr);
559
+ const aroundElevation = terrain ? this._terrainGestureElevation(terrain, around, aroundOnSurface, tr, combinedEventsInProgress) : undefined;
556
560
 
557
561
  const deltasForHelper: MapControlsDeltas = {
558
562
  panDelta,
@@ -561,17 +565,14 @@ export class HandlerManager {
561
565
  pitchDelta,
562
566
  bearingDelta,
563
567
  around,
568
+ aroundElevation,
564
569
  };
565
570
 
566
571
  // Pre-zoom location under the mouse cursor is required for accurate mercator panning and zooming
567
572
  if (this._camera.cameraHelper.useGlobeControls && !tr.isPointOnMapSurface(around)) {
568
573
  around = tr.centerPoint;
569
574
  }
570
- // If we are rotating about the center point, avoid numerical issues near the horizon by using the transform's
571
- // center directly, instead of computing it from the screen point
572
- const preZoomAroundLoc = around.distSqr(tr.centerPoint) < 1.0e-2 ?
573
- tr.center :
574
- tr.screenPointToLocation(panDelta ? around.sub(panDelta) : around);
575
+ const preZoomAroundLoc = this._computePreZoomAroundLoc(tr, around, panDelta, aroundElevation);
575
576
 
576
577
  this._handleMapControls({
577
578
  terrain,
@@ -590,6 +591,65 @@ export class HandlerManager {
590
591
 
591
592
  }
592
593
 
594
+ /**
595
+ * The gesture's anchor point: the pinch midpoint when pinching, otherwise the
596
+ * pan's anchor, clamped to the center point when it does not lie on the map.
597
+ */
598
+ _resolveAround(combinedResult: HandlerResult, terrain: Terrain | null, tr: ITransform): {around: Point; aroundOnSurface: boolean} {
599
+ let around = combinedResult.pinchAround !== undefined ? combinedResult.pinchAround : combinedResult.around;
600
+ around ||= this._camera.transform.centerPoint;
601
+ if (terrain && !tr.isPointOnMapSurface(around)) {
602
+ return {around: tr.centerPoint, aroundOnSurface: false};
603
+ }
604
+ return {around, aroundOnSurface: true};
605
+ }
606
+
607
+ /**
608
+ * The elevation of the plane a terrain gesture is solved on: the elevation of the
609
+ * terrain point grabbed at gesture start, captured here on the gesture's first
610
+ * frame. Undefined means the gesture is solved at the center's elevation instead —
611
+ * when the grabbed terrain is not loaded, the anchor is the center point (a
612
+ * center-point anchor is skipped by the camera helper and would lose the pan), or
613
+ * the anchor plane is too close to the camera for a stable ray-plane solve.
614
+ */
615
+ _terrainGestureElevation(terrain: Terrain, around: Point, aroundOnSurface: boolean, tr: ITransform, combinedEventsInProgress: EventsInProgress): number | undefined {
616
+ if (!aroundOnSurface) {
617
+ return undefined;
618
+ }
619
+ if (!this._terrainMovement && (combinedEventsInProgress.drag || combinedEventsInProgress.zoom)) {
620
+ const anchor = terrain.pointCoordinate(around);
621
+ this._terrainGestureAnchorElevation = anchor ? anchor.z : null;
622
+ }
623
+ if (this._terrainGestureAnchorElevation === null) {
624
+ return undefined;
625
+ }
626
+ const elevation = this._terrainGestureAnchorElevation;
627
+ if (around.distSqr(tr.centerPoint) < 1.0e-2) {
628
+ return undefined;
629
+ }
630
+ if (elevation - tr.elevation >= TERRAIN_ANCHOR_MAX_CAMERA_ALTITUDE_FRACTION * (tr.getCameraAltitude() - tr.elevation)) {
631
+ return undefined;
632
+ }
633
+ return elevation;
634
+ }
635
+
636
+ /**
637
+ * The location that was under `around` before this frame's deltas — the point the
638
+ * camera helper re-anchors after zooming — solved at `aroundElevation` when a
639
+ * terrain gesture provides one. When rotating about the center point, the
640
+ * transform's center is used directly to avoid numerical issues near the horizon.
641
+ */
642
+ _computePreZoomAroundLoc(tr: ITransform, around: Point, panDelta: Point | undefined, aroundElevation: number | undefined): LngLat {
643
+ if (around.distSqr(tr.centerPoint) < 1.0e-2) {
644
+ return tr.center;
645
+ }
646
+ const aroundPreviousPoint = panDelta ? around.sub(panDelta) : around;
647
+ if (aroundElevation !== undefined) {
648
+ return tr.screenPointToLocationAtElevation(aroundPreviousPoint, aroundElevation);
649
+ }
650
+ return tr.screenPointToLocation(aroundPreviousPoint);
651
+ }
652
+
593
653
  _handleMapControls({
594
654
  terrain,
595
655
  tr,
@@ -623,7 +683,9 @@ export class HandlerManager {
623
683
  return;
624
684
  }
625
685
 
626
- if (combinedEventsInProgress.drag && this._terrainMovement && panDelta) {
686
+ if (deltasForHelper.aroundElevation === undefined &&
687
+ combinedEventsInProgress.drag && this._terrainMovement && panDelta) {
688
+ // no usable anchor: drag by pixel-delta on the center-elevation plane
627
689
  tr.setCenter(tr.screenPointToLocation(tr.centerPoint.sub(panDelta)));
628
690
  return;
629
691
  }
@@ -685,6 +747,7 @@ export class HandlerManager {
685
747
  if (finishedMoving && this._terrainMovement) {
686
748
  this._camera.elevationFreeze = false;
687
749
  this._terrainMovement = false;
750
+ this._terrainGestureAnchorElevation = null;
688
751
  const tr = this._camera.getTransformForUpdate();
689
752
  if (this._map.getCenterClampedToGround()) {
690
753
  tr.recalculateZoomAndCenter(this._map.terrain);