maplibre-gl 6.2.0 → 6.3.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 (124) hide show
  1. package/dist/maplibre-gl-dev.mjs +522 -223
  2. package/dist/maplibre-gl-dev.mjs.map +1 -1
  3. package/dist/maplibre-gl-shared-dev.mjs +103 -58
  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 +214 -59
  13. package/dist/maplibre-gl.mjs +44 -44
  14. package/dist/maplibre-gl.mjs.map +1 -1
  15. package/package.json +10 -20
  16. package/src/data/feature_index.bench.ts +21 -0
  17. package/src/data/segment.test.ts +2 -2
  18. package/src/geo/edge_insets.test.ts +1 -1
  19. package/src/geo/lng_lat.test.ts +7 -7
  20. package/src/geo/lng_lat_bounds.test.ts +4 -4
  21. package/src/geo/mercator_coordinate.test.ts +2 -2
  22. package/src/geo/projection/camera_helper.ts +5 -0
  23. package/src/geo/projection/globe_transform.test.ts +25 -0
  24. package/src/geo/projection/globe_transform.ts +7 -3
  25. package/src/geo/projection/mercator_camera_helper.ts +1 -1
  26. package/src/geo/projection/mercator_transform.ts +21 -5
  27. package/src/geo/projection/vertical_perspective_camera_helper.ts +60 -31
  28. package/src/geo/projection/vertical_perspective_transform.ts +7 -1
  29. package/src/geo/transform_interface.ts +13 -1
  30. package/src/index.ts +7 -2
  31. package/src/render/image_atlas.test.ts +72 -0
  32. package/src/render/image_atlas.ts +17 -2
  33. package/src/render/image_manager.test.ts +46 -0
  34. package/src/render/image_manager.ts +9 -2
  35. package/src/render/painter.ts +1 -12
  36. package/src/render/update_pattern_positions_in_program.test.ts +1 -1
  37. package/src/shaders/glsl/_prelude.vertex.glsl +2 -3
  38. package/src/shaders/glsl/_prelude.vertex.glsl.g.ts +1 -1
  39. package/src/shaders/glsl/circle.vertex.glsl +4 -4
  40. package/src/shaders/glsl/circle.vertex.glsl.g.ts +1 -1
  41. package/src/shaders/glsl/fill_extrusion.vertex.glsl +3 -3
  42. package/src/shaders/glsl/fill_extrusion.vertex.glsl.g.ts +1 -1
  43. package/src/shaders/glsl/fill_extrusion_pattern.vertex.glsl +5 -5
  44. package/src/shaders/glsl/fill_extrusion_pattern.vertex.glsl.g.ts +1 -1
  45. package/src/shaders/glsl/heatmap.vertex.glsl +4 -4
  46. package/src/shaders/glsl/heatmap.vertex.glsl.g.ts +1 -1
  47. package/src/shaders/glsl/line.vertex.glsl +7 -7
  48. package/src/shaders/glsl/line.vertex.glsl.g.ts +1 -1
  49. package/src/shaders/glsl/line_gradient.vertex.glsl +6 -6
  50. package/src/shaders/glsl/line_gradient.vertex.glsl.g.ts +1 -1
  51. package/src/shaders/glsl/line_gradient_sdf.vertex.glsl +7 -7
  52. package/src/shaders/glsl/line_gradient_sdf.vertex.glsl.g.ts +1 -1
  53. package/src/shaders/glsl/line_pattern.vertex.glsl +7 -7
  54. package/src/shaders/glsl/line_pattern.vertex.glsl.g.ts +1 -1
  55. package/src/shaders/glsl/line_sdf.vertex.glsl +7 -7
  56. package/src/shaders/glsl/line_sdf.vertex.glsl.g.ts +1 -1
  57. package/src/shaders/glsl/raster.fragment.glsl +4 -4
  58. package/src/shaders/glsl/raster.fragment.glsl.g.ts +1 -1
  59. package/src/shaders/glsl/raster.vertex.glsl +11 -6
  60. package/src/shaders/glsl/raster.vertex.glsl.g.ts +1 -1
  61. package/src/shaders/glsl/symbol_icon.vertex.glsl +5 -5
  62. package/src/shaders/glsl/symbol_icon.vertex.glsl.g.ts +1 -1
  63. package/src/shaders/glsl/symbol_sdf.vertex.glsl +5 -5
  64. package/src/shaders/glsl/symbol_sdf.vertex.glsl.g.ts +1 -1
  65. package/src/shaders/glsl/symbol_text_and_icon.vertex.glsl +6 -6
  66. package/src/shaders/glsl/symbol_text_and_icon.vertex.glsl.g.ts +1 -1
  67. package/src/source/canvas_source.test.ts +31 -0
  68. package/src/source/canvas_source.ts +2 -1
  69. package/src/source/geojson_worker_source.test.ts +88 -0
  70. package/src/source/geojson_worker_source.ts +22 -39
  71. package/src/source/image_source.test.ts +205 -7
  72. package/src/source/image_source.ts +118 -4
  73. package/src/source/raster_dem_tile_worker_source.test.ts +1 -1
  74. package/src/source/vector_tile_worker_source.test.ts +94 -2
  75. package/src/source/vector_tile_worker_source.ts +18 -27
  76. package/src/source/video_source.test.ts +12 -8
  77. package/src/source/worker_tile.bench.ts +10 -0
  78. package/src/source/worker_tile.ts +0 -3
  79. package/src/style/create_style_layer.bench.ts +15 -0
  80. package/src/style/properties.bench.ts +81 -0
  81. package/src/style/style.test.ts +11 -10
  82. package/src/style/style_image.ts +67 -9
  83. package/src/style/style_layer.bench.ts +65 -0
  84. package/src/style/style_layer.test.ts +1 -1
  85. package/src/style/style_layer.ts +2 -2
  86. package/src/style/validate_style.bench.ts +11 -0
  87. package/src/style/validate_style.test.ts +2 -2
  88. package/src/symbol/anchor.test.ts +2 -2
  89. package/src/symbol/collision_index.bench.ts +116 -0
  90. package/src/symbol/cross_tile_symbol_index.bench.ts +49 -0
  91. package/src/symbol/shaping.test.ts +2 -1
  92. package/src/symbol/symbol_layout.bench.ts +30 -0
  93. package/src/tile/tile_id.test.ts +1 -1
  94. package/src/tile/tile_manager.ts +2 -2
  95. package/src/ui/control/fullscreen_control.test.ts +10 -10
  96. package/src/ui/control/geolocate_control.test.ts +3 -5
  97. package/src/ui/control/globe_control.test.ts +6 -6
  98. package/src/ui/events.ts +1 -1
  99. package/src/ui/handler/scroll_zoom.test.ts +50 -0
  100. package/src/ui/handler/two_fingers_touch.test.ts +3 -3
  101. package/src/ui/handler_manager.test.ts +243 -3
  102. package/src/ui/handler_manager.ts +79 -16
  103. package/src/ui/map.ts +17 -3
  104. package/src/ui/map_tests/map_cross_window.test.ts +2 -2
  105. package/src/ui/map_tests/map_events.test.ts +3 -3
  106. package/src/ui/map_tests/map_images.test.ts +29 -0
  107. package/src/ui/map_tests/map_style.test.ts +9 -8
  108. package/src/ui/map_tests/map_webgl.test.ts +29 -0
  109. package/src/ui/marker.test.ts +26 -42
  110. package/src/ui/popup.test.ts +14 -14
  111. package/src/util/evented.test.ts +33 -28
  112. package/src/util/evented.ts +69 -43
  113. package/src/util/test/null_gl.ts +10 -0
  114. package/src/util/test/util.ts +2 -1
  115. package/src/util/util.test.ts +1 -1
  116. package/src/util/web_worker_transfer.test.ts +5 -5
  117. package/src/webgl/context.ts +20 -0
  118. package/src/webgl/draw/draw_raster.ts +11 -6
  119. package/src/webgl/program/raster_program.ts +11 -0
  120. package/src/webgl/program.ts +31 -16
  121. package/src/webgl/texture.test.ts +16 -1
  122. package/src/webgl/texture.ts +3 -0
  123. package/src/webgl/vertex_buffer.test.ts +17 -2
  124. package/src/webgl/vertex_buffer.ts +24 -13
@@ -120,7 +120,8 @@ describe('shapeIcon', () => {
120
120
  stretchY: undefined,
121
121
  paddedRect: Object.freeze({x: 0, y: 0, w: 22, h: 22}),
122
122
  pixelRatio: 1,
123
- version: 1
123
+ version: 1,
124
+ needsFirstWebGLRender: false
124
125
  });
125
126
 
126
127
  test('text-anchor: center', () => {
@@ -0,0 +1,30 @@
1
+ import {bench} from 'vitest';
2
+ import {performSymbolLayout} from './symbol_layout.ts';
3
+ import {SymbolBucket} from '../data/bucket/symbol_bucket.ts';
4
+ import {SubdivisionGranularitySetting} from '../render/subdivision_granularity_settings.ts';
5
+ import {fixtureTiles, parseTile} from '../../test/bench/lib/parse_tile.ts';
6
+
7
+ import type {WorkerTileWithData} from '../source/worker_source.ts';
8
+
9
+ const parsedTiles = await Promise.all(fixtureTiles.map(async (tile): Promise<WorkerTileWithData> => {
10
+ return await parseTile(tile, true) as WorkerTileWithData;
11
+ }));
12
+
13
+ bench('performSymbolLayout', () => {
14
+ for (const tileResult of parsedTiles) {
15
+ for (const bucket of tileResult.buckets) {
16
+ if (bucket instanceof SymbolBucket) {
17
+ performSymbolLayout({
18
+ bucket,
19
+ glyphMap: tileResult.glyphMap,
20
+ glyphPositions: tileResult.glyphPositions,
21
+ imageMap: tileResult.iconMap,
22
+ imagePositions: tileResult.imageAtlas.iconPositions,
23
+ showCollisionBoxes: false,
24
+ canonical: tileResult.featureIndex.tileID.canonical,
25
+ subdivisionGranularity: SubdivisionGranularitySetting.noSubdivision
26
+ });
27
+ }
28
+ }
29
+ }
30
+ });
@@ -63,7 +63,7 @@ describe('CanonicalTileID', () => {
63
63
 
64
64
  describe('OverscaledTileID', () => {
65
65
  test('constructor', () => {
66
- expect(new OverscaledTileID(0, 0, 0, 0, 0) instanceof OverscaledTileID).toBeTruthy();
66
+ expect(new OverscaledTileID(0, 0, 0, 0, 0)).toBeInstanceOf(OverscaledTileID);
67
67
  });
68
68
 
69
69
  test('constructor - deeper canonicalZ than overscaledZ disallowed', () => {
@@ -16,7 +16,7 @@ import {GEOJSON_TILE_LAYER_NAME} from '../data/feature_index.ts';
16
16
  import {hasRasterTransition, isRasterType, updateFadingTiles} from './tile_manager_raster.ts';
17
17
  import {backfillDEM} from './tile_manager_raster_dem.ts';
18
18
  import {InViewTiles} from './tile_manager_in_view_tiles.ts';
19
- import {MapSourceDataEvent} from '../ui/events.ts';
19
+ import {MapSourceDataEvent, type SourceEventType} from '../ui/events.ts';
20
20
 
21
21
  import type Point from '@mapbox/point-geometry';
22
22
  import type {Context} from '../webgl/context.ts';
@@ -55,7 +55,7 @@ type TileResult = {
55
55
  * - unloading cached tiles not needed to render a given viewport
56
56
  * - managing tile state and feature state
57
57
  */
58
- export class TileManager extends Evented {
58
+ export class TileManager extends Evented<SourceEventType> {
59
59
  id: string;
60
60
  dispatcher: Dispatcher;
61
61
  map: Map;
@@ -45,15 +45,15 @@ describe('FullscreenControl', () => {
45
45
 
46
46
  const click = new window.Event('click');
47
47
 
48
- expect(mapContainer.classList.contains('maplibregl-pseudo-fullscreen')).toBe(false);
48
+ expect(mapContainer.classList).not.toContain('maplibregl-pseudo-fullscreen');
49
49
 
50
50
  fullscreen._fullscreenButton.dispatchEvent(click);
51
51
 
52
- expect(mapContainer.classList.contains('maplibregl-pseudo-fullscreen')).toBe(true);
52
+ expect(mapContainer.classList).toContain('maplibregl-pseudo-fullscreen');
53
53
 
54
54
  fullscreen._fullscreenButton.dispatchEvent(click);
55
55
 
56
- expect(mapContainer.classList.contains('maplibregl-pseudo-fullscreen')).toBe(false);
56
+ expect(mapContainer.classList).not.toContain('maplibregl-pseudo-fullscreen');
57
57
  });
58
58
 
59
59
  test('start and end events fire for fullscreen button clicks', () => {
@@ -129,11 +129,11 @@ describe('FullscreenControl', () => {
129
129
 
130
130
  const click = new window.Event('click');
131
131
 
132
- expect(mapContainer.classList.contains('maplibregl-pseudo-fullscreen')).toBe(false);
132
+ expect(mapContainer.classList).not.toContain('maplibregl-pseudo-fullscreen');
133
133
  fullscreen._fullscreenButton.dispatchEvent(click);
134
- expect(mapContainer.classList.contains('maplibregl-pseudo-fullscreen')).toBe(true);
134
+ expect(mapContainer.classList).toContain('maplibregl-pseudo-fullscreen');
135
135
  fullscreen._fullscreenButton.dispatchEvent(click);
136
- expect(mapContainer.classList.contains('maplibregl-pseudo-fullscreen')).toBe(false);
136
+ expect(mapContainer.classList).not.toContain('maplibregl-pseudo-fullscreen');
137
137
  });
138
138
 
139
139
  test('pseudo option forces pseudo fullscreen even when native fullscreen is available', () => {
@@ -151,7 +151,7 @@ describe('FullscreenControl', () => {
151
151
  fullscreen._fullscreenButton.dispatchEvent(click);
152
152
 
153
153
  expect(requestFullscreenSpy).not.toHaveBeenCalled();
154
- expect(mapContainer.classList.contains('maplibregl-pseudo-fullscreen')).toBe(true);
154
+ expect(mapContainer.classList).toContain('maplibregl-pseudo-fullscreen');
155
155
  });
156
156
 
157
157
  test('pseudo fullscreen can be used on custom container', () => {
@@ -166,11 +166,11 @@ describe('FullscreenControl', () => {
166
166
 
167
167
  const click = new window.Event('click');
168
168
 
169
- expect(container.classList.contains('maplibregl-pseudo-fullscreen')).toBe(false);
169
+ expect(container.classList).not.toContain('maplibregl-pseudo-fullscreen');
170
170
  fullscreen._fullscreenButton.dispatchEvent(click);
171
- expect(container.classList.contains('maplibregl-pseudo-fullscreen')).toBe(true);
171
+ expect(container.classList).toContain('maplibregl-pseudo-fullscreen');
172
172
  expect(fullscreen._container.tagName).toBe('BODY');
173
173
  fullscreen._fullscreenButton.dispatchEvent(click);
174
- expect(container.classList.contains('maplibregl-pseudo-fullscreen')).toBe(false);
174
+ expect(container.classList).not.toContain('maplibregl-pseudo-fullscreen');
175
175
  });
176
176
  });
@@ -418,7 +418,7 @@ describe('GeolocateControl with no options', () => {
418
418
  geolocate.trigger();
419
419
  geolocation.sendError({code: 2, message: 'error message'});
420
420
  expect(geolocate._watchState).toBe('ACTIVE_ERROR');
421
- expect(geolocate._geolocateButton.classList.contains('maplibregl-ctrl-geolocate-active-error')).toBeTruthy();
421
+ expect(geolocate._geolocateButton.classList).toContain('maplibregl-ctrl-geolocate-active-error');
422
422
  });
423
423
 
424
424
  test('trigger before added to map', () => {
@@ -538,9 +538,7 @@ describe('GeolocateControl with no options', () => {
538
538
 
539
539
  expect(lngLatAsFixed(map.getCenter(), 4)).toEqual({lat: '10.0000', lng: '20.0000'});
540
540
  expect(geolocate._userLocationDotMarker._map).toBeTruthy();
541
- expect(
542
- geolocate._userLocationDotMarker._element.classList.contains('maplibregl-user-location-dot-stale')
543
- ).toBeFalsy();
541
+ expect(geolocate._userLocationDotMarker._element.classList).not.toContain('maplibregl-user-location-dot-stale');
544
542
  const secontMoveEnd = map.once('moveend');
545
543
  geolocation.change({latitude: 40, longitude: 50, accuracy: 60});
546
544
  await secontMoveEnd;
@@ -549,7 +547,7 @@ describe('GeolocateControl with no options', () => {
549
547
  geolocation.changeError({code: 2, message: 'position unavailable'});
550
548
  await errorPromise;
551
549
  expect(geolocate._userLocationDotMarker._map).toBeTruthy();
552
- expect(geolocate._userLocationDotMarker._element.classList.contains('maplibregl-user-location-dot-stale')).toBeTruthy();
550
+ expect(geolocate._userLocationDotMarker._element.classList).toContain('maplibregl-user-location-dot-stale');
553
551
  });
554
552
 
555
553
  /**
@@ -73,8 +73,8 @@ describe('GlobeControl', () => {
73
73
  test('default without call to setProjection', () => {
74
74
  const button = map.getContainer().querySelector('.maplibregl-ctrl-globe');
75
75
  expect(map.style.projection.name).toBe('mercator');
76
- expect(button.classList.contains('maplibregl-ctrl-globe')).toBeTruthy();
77
- expect(button.classList.contains('maplibregl-ctrl-globe-enabled')).toBeFalsy();
76
+ expect(button.classList).toContain('maplibregl-ctrl-globe');
77
+ expect(button.classList).not.toContain('maplibregl-ctrl-globe-enabled');
78
78
  });
79
79
 
80
80
  test('setProjection({type: "mercator" -> "globe")', () => {
@@ -85,8 +85,8 @@ describe('GlobeControl', () => {
85
85
  const button = map.getContainer().querySelector('.maplibregl-ctrl-globe-enabled');
86
86
  expect(map.style.projection.name).toBe('globe');
87
87
  expect(button).not.toBeNull();
88
- expect(button.classList.contains('maplibregl-ctrl-globe-enabled')).toBeTruthy();
89
- expect(button.classList.contains('maplibregl-ctrl-globe')).toBeFalsy();
88
+ expect(button.classList).toContain('maplibregl-ctrl-globe-enabled');
89
+ expect(button.classList).not.toContain('maplibregl-ctrl-globe');
90
90
  });
91
91
 
92
92
  test('setProjection({type: "globe" -> "mercator")', () => {
@@ -96,8 +96,8 @@ describe('GlobeControl', () => {
96
96
  // mercator = disabled state
97
97
  const button = map.getContainer().querySelector('.maplibregl-ctrl-globe');
98
98
  expect(map.style.projection.name).toBe('mercator');
99
- expect(button.classList.contains('maplibregl-ctrl-globe')).toBeTruthy();
100
- expect(button.classList.contains('maplibregl-ctrl-globe-enabled')).toBeFalsy();
99
+ expect(button.classList).toContain('maplibregl-ctrl-globe');
100
+ expect(button.classList).not.toContain('maplibregl-ctrl-globe-enabled');
101
101
  });
102
102
  });
103
103
  });
package/src/ui/events.ts CHANGED
@@ -1,4 +1,4 @@
1
- import {Event} from '../util/evented.ts';
1
+ import {Event, type ErrorEvent} from '../util/evented.ts';
2
2
 
3
3
  import {DOM} from '../util/dom.ts';
4
4
  import Point from '@mapbox/point-geometry';
@@ -1,4 +1,5 @@
1
1
  import {describe, beforeEach, test, expect, vi, type MockInstance} from 'vitest';
2
+ import Point from '@mapbox/point-geometry';
2
3
  import * as timeControl from '../../util/time_control.ts';
3
4
  import {Map, type MapOptions} from '../../ui/map.ts';
4
5
  import {DOM} from '../../util/dom.ts';
@@ -656,6 +657,55 @@ describe('ScrollZoomHandler', () => {
656
657
  map.remove();
657
658
  });
658
659
 
660
+ describe('globe zooms around the pointer', () => {
661
+ async function zoomInAtOffset(offset: Point) {
662
+ const timeControlNow = vi.spyOn(timeControl, 'now');
663
+ timeControlNow.mockReturnValue(1555555555555);
664
+ const map = createMap();
665
+ await map.once('load');
666
+ map.setProjection({type: 'globe'});
667
+ map.setCenter([0, 0]);
668
+ map.setZoom(0);
669
+ map._renderTaskQueue.run();
670
+
671
+ const pointer = map._camera.transform.centerPoint.add(offset);
672
+ const locationBefore = map.unproject(pointer);
673
+
674
+ simulate.wheel(map.getCanvas(), {deltaY: -simulate.magicWheelZoomDelta * 4, clientX: pointer.x, clientY: pointer.y});
675
+ map._renderTaskQueue.run();
676
+
677
+ const locationAfter = map.unproject(pointer);
678
+ const result = {
679
+ driftLng: locationAfter.lng - locationBefore.lng,
680
+ driftLat: locationAfter.lat - locationBefore.lat,
681
+ center: map.getCenter()
682
+ };
683
+ map.remove();
684
+ return result;
685
+ }
686
+
687
+ test('keeps the location under the pointer while the globe is small on screen, and still zooms towards it', async () => {
688
+ const wellInsideTheGlobe = new Point(0, -40);
689
+ const {driftLng, driftLat, center} = await zoomInAtOffset(wellInsideTheGlobe);
690
+ expect(driftLng).toBeCloseTo(0, 6);
691
+ expect(driftLat).toBeCloseTo(0, 6);
692
+ expect(center.lat).toBeCloseTo(0.5972, 3);
693
+ });
694
+
695
+ test('eases the anchoring off near the horizon instead of dropping it', async () => {
696
+ const nearTheHorizon = new Point(0, -67.5);
697
+ const {driftLat} = await zoomInAtOffset(nearTheHorizon);
698
+ expect(Math.abs(driftLat)).toBeGreaterThan(0.5);
699
+ expect(Math.abs(driftLat)).toBeLessThan(1.5);
700
+ });
701
+
702
+ test('leaves zooming with the pointer off the planet unchanged', async () => {
703
+ const offThePlanet = new Point(0, -85);
704
+ const {center} = await zoomInAtOffset(offThePlanet);
705
+ expect(center.lat).toBeCloseTo(0.3545, 3);
706
+ });
707
+ });
708
+
659
709
  test('Clamps to min/max zoom when using mercator projection', async () => {
660
710
  const timeControlNow = vi.spyOn(timeControl, 'now');
661
711
  const map = createMap();
@@ -312,11 +312,11 @@ describe('touch zoom rotate', () => {
312
312
  const map = createMap();
313
313
 
314
314
  const className = 'maplibregl-touch-zoom-rotate';
315
- expect(map.getCanvasContainer().classList.contains(className)).toBeTruthy();
315
+ expect(map.getCanvasContainer().classList).toContain(className);
316
316
  map.touchZoomRotate.disable();
317
- expect(map.getCanvasContainer().classList.contains(className)).toBeFalsy();
317
+ expect(map.getCanvasContainer().classList).not.toContain(className);
318
318
  map.touchZoomRotate.enable();
319
- expect(map.getCanvasContainer().classList.contains(className)).toBeTruthy();
319
+ expect(map.getCanvasContainer().classList).toContain(className);
320
320
  });
321
321
 
322
322
  test('TwoFingersTouchZoomRotateHandler zooms when touching two markers on the same map', () => {
@@ -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
+ });