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
@@ -117,44 +117,74 @@ class TileLayerIndex {
117
117
  const scaledSymbolCoord = this.getScaledCoordinates(symbolInstance, newTileID);
118
118
 
119
119
  if (entry.index) {
120
- // Return any symbol with the same keys whose coordinates are within 1
121
- // grid unit. (with a 4px grid, this covers a 12px by 12px area)
122
- const indexes = entry.index.range(
123
- scaledSymbolCoord.x - tolerance,
124
- scaledSymbolCoord.y - tolerance,
125
- scaledSymbolCoord.x + tolerance,
126
- scaledSymbolCoord.y + tolerance).sort();
127
-
128
- for (const i of indexes) {
129
- const crossTileID = entry.crossTileIDs[i];
130
-
131
- if (!zoomCrossTileIDs[crossTileID]) {
132
- // Once we've marked ourselves duplicate against this parent symbol,
133
- // don't let any other symbols at the same zoom level duplicate against
134
- // the same parent (see issue #5993)
135
- zoomCrossTileIDs[crossTileID] = true;
136
- symbolInstance.crossTileID = crossTileID;
137
- break;
138
- }
139
- }
120
+ this.findMatchesForIndexedEntry(entry, symbolInstance, scaledSymbolCoord, tolerance, zoomCrossTileIDs);
140
121
  } else if (entry.positions) {
141
- for (let i = 0; i < entry.positions.length; i++) {
142
- const thisTileSymbol = entry.positions[i];
143
- const crossTileID = entry.crossTileIDs[i];
144
-
145
- // Return any symbol with the same keys whose coordinates are within 1
146
- // grid unit. (with a 4px grid, this covers a 12px by 12px area)
147
- if (Math.abs(thisTileSymbol.x - scaledSymbolCoord.x) <= tolerance &&
148
- Math.abs(thisTileSymbol.y - scaledSymbolCoord.y) <= tolerance &&
149
- !zoomCrossTileIDs[crossTileID]) {
150
- // Once we've marked ourselves duplicate against this parent symbol,
151
- // don't let any other symbols at the same zoom level duplicate against
152
- // the same parent (see issue #5993)
153
- zoomCrossTileIDs[crossTileID] = true;
154
- symbolInstance.crossTileID = crossTileID;
155
- break;
156
- }
157
- }
122
+ this.findMatchesForNonIndexedEntry(entry, symbolInstance, scaledSymbolCoord, tolerance, zoomCrossTileIDs);
123
+ }
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Matches a symbol instance against an entry backed by a KDBush spatial index
129
+ * (used for keys with more than {@link KDBUSH_THRESHHOLD} symbols).
130
+ *
131
+ * Matches any symbol with the same key whose coordinates are within one grid
132
+ * unit (with a 4px grid, this covers a 12px by 12px area). The lowest-index
133
+ * unclaimed candidate is claimed in a single pass rather than sorting the
134
+ * whole result set on every query: `range()` can return many candidates where
135
+ * symbols are coincident (e.g. stacked point markers), so a per-query sort
136
+ * would dominate placement cost on dense layers.
137
+ *
138
+ * Once a symbol is marked duplicate against a parent symbol, no other symbol
139
+ * at the same zoom level may duplicate against the same parent (see issue #5993).
140
+ */
141
+ private findMatchesForIndexedEntry(entry: SymbolsByKeyEntry, symbolInstance: SymbolInstance, scaledSymbolCoord: {x: number; y: number}, tolerance: number, zoomCrossTileIDs: {
142
+ [crossTileID: number]: boolean;
143
+ }): void {
144
+ const indexes = entry.index.range(
145
+ scaledSymbolCoord.x - tolerance,
146
+ scaledSymbolCoord.y - tolerance,
147
+ scaledSymbolCoord.x + tolerance,
148
+ scaledSymbolCoord.y + tolerance);
149
+
150
+ let bestIndex = Infinity;
151
+ for (const candidate of indexes) {
152
+ if (candidate < bestIndex && !zoomCrossTileIDs[entry.crossTileIDs[candidate]]) {
153
+ bestIndex = candidate;
154
+ }
155
+ }
156
+ if (bestIndex !== Infinity) {
157
+ const crossTileID = entry.crossTileIDs[bestIndex];
158
+ zoomCrossTileIDs[crossTileID] = true;
159
+ symbolInstance.crossTileID = crossTileID;
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Matches a symbol instance against an entry that stores its positions as a
165
+ * plain array (used for keys with at most {@link KDBUSH_THRESHHOLD} symbols).
166
+ *
167
+ * Matches any symbol with the same key whose coordinates are within one grid
168
+ * unit (with a 4px grid, this covers a 12px by 12px area). Positions are
169
+ * stored in symbol-instance order, so the first unclaimed match is also the
170
+ * lowest-index one.
171
+ *
172
+ * Once a symbol is marked duplicate against a parent symbol, no other symbol
173
+ * at the same zoom level may duplicate against the same parent (see issue #5993).
174
+ */
175
+ private findMatchesForNonIndexedEntry(entry: SymbolsByKeyEntry, symbolInstance: SymbolInstance, scaledSymbolCoord: {x: number; y: number}, tolerance: number, zoomCrossTileIDs: {
176
+ [crossTileID: number]: boolean;
177
+ }): void {
178
+ for (let i = 0; i < entry.positions.length; i++) {
179
+ const thisTileSymbol = entry.positions[i];
180
+ const crossTileID = entry.crossTileIDs[i];
181
+
182
+ if (Math.abs(thisTileSymbol.x - scaledSymbolCoord.x) <= tolerance &&
183
+ Math.abs(thisTileSymbol.y - scaledSymbolCoord.y) <= tolerance &&
184
+ !zoomCrossTileIDs[crossTileID]) {
185
+ zoomCrossTileIDs[crossTileID] = true;
186
+ symbolInstance.crossTileID = crossTileID;
187
+ break;
158
188
  }
159
189
  }
160
190
  }
@@ -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', () => {
@@ -11,7 +11,7 @@ import {extend} from '../util/util.ts';
11
11
  import {type Dispatcher} from '../util/dispatcher.ts';
12
12
  import {TileBounds} from './tile_bounds.ts';
13
13
  import {beforeMapTest, createMap as globalCreateMap, sleep, waitForEvent} from '../util/test/util.ts';
14
- import {now} from '../util/time_control.ts';
14
+ import {now, restoreNow, setNow} from '../util/time_control.ts';
15
15
 
16
16
  import {type Map} from '../ui/map.ts';
17
17
  import {type TileCache} from './tile_cache.ts';
@@ -118,6 +118,7 @@ beforeEach(() => {
118
118
 
119
119
  afterEach(() => {
120
120
  vi.clearAllMocks();
121
+ restoreNow();
121
122
  });
122
123
 
123
124
  describe('TileManager.addTile', () => {
@@ -627,6 +628,73 @@ describe('TileManager / Source lifecycle', () => {
627
628
 
628
629
  });
629
630
 
631
+ test('keeps the self fade timer when reloading a tile that has data', async () => {
632
+ const transform = new MercatorTransform();
633
+ transform.resize(511, 511);
634
+ transform.setZoom(0);
635
+ const tileID = new OverscaledTileID(0, 0, 0, 0, 0);
636
+
637
+ const tileManager = createTileManager();
638
+ tileManager.setRasterFadeDuration(300);
639
+ tileManager._source.loadTile = async (tile) => {
640
+ tile.state = 'loaded';
641
+ };
642
+ const metadataPromise = waitForEvent(tileManager, 'data', e => e.sourceDataType === 'metadata');
643
+ tileManager.onAdd(undefined);
644
+ await metadataPromise;
645
+
646
+ setNow(1000);
647
+ const loadPromise = waitForEvent(tileManager, 'data', e => e.tile?.tileID.key === tileID.key);
648
+ tileManager.update(transform);
649
+ await loadPromise;
650
+ const tile = tileManager.getTile(tileID);
651
+ tile.setSelfFadeLogic(now() + 300);
652
+ setNow(1010);
653
+ const reloadPromise = waitForEvent(tileManager, 'data', e => e.tile?.tileID.key === tileID.key);
654
+
655
+ tileManager.getSource().fire(new Event('data', {dataType: 'source', sourceDataType: 'content'}));
656
+
657
+ await reloadPromise;
658
+ expect(tile.selfFading).toBe(true);
659
+ expect(tile.timeAdded).toBe(1000);
660
+ expect(tile.fadeEndTime).toBe(1300);
661
+ });
662
+
663
+ test('bases the self fade timer on when tile data lands, even if a reload was requested prior to arrival', async () => {
664
+ const transform = new MercatorTransform();
665
+ transform.resize(511, 511);
666
+ transform.setZoom(0);
667
+ const tileID = new OverscaledTileID(0, 0, 0, 0, 0);
668
+
669
+ const tileManager = createTileManager();
670
+ tileManager.setRasterFadeDuration(300);
671
+ let respond: () => void;
672
+ const responded = new Promise<void>(resolve => { respond = resolve; });
673
+ tileManager._source.loadTile = async (tile) => {
674
+ await responded;
675
+ tile.state = 'loaded';
676
+ };
677
+ const metadataPromise = waitForEvent(tileManager, 'data', e => e.sourceDataType === 'metadata');
678
+ tileManager.onAdd(undefined);
679
+ await metadataPromise;
680
+
681
+ setNow(1000);
682
+ tileManager.update(transform);
683
+ const tile = tileManager.getTile(tileID);
684
+ tile.setSelfFadeLogic(now() + 300);
685
+ const loadPromise = waitForEvent(tileManager, 'data', e => e.tile?.tileID.key === tileID.key);
686
+
687
+ tileManager.getSource().fire(new Event('data', {dataType: 'source', sourceDataType: 'content'}));
688
+
689
+ expect(tile.state).toBe('loading');
690
+ setNow(1010);
691
+ respond();
692
+ await loadPromise;
693
+ expect(tile.selfFading).toBe(true);
694
+ expect(tile.timeAdded).toBe(1010);
695
+ expect(tile.fadeEndTime).toBe(1310);
696
+ });
697
+
630
698
  });
631
699
 
632
700
  describe('TileManager.update', () => {
@@ -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;
@@ -185,10 +185,10 @@ export class TileManager extends Evented {
185
185
  if (this.transform) this.update(this.transform, this.terrain);
186
186
  }
187
187
 
188
- async _loadTile(tile: Tile, id: string, state: TileState): Promise<void> {
188
+ async _loadTile(tile: Tile, id: string, state: TileState, hadData: boolean): Promise<void> {
189
189
  try {
190
190
  const result = await this._source.loadTile(tile) as LoadTileResult;
191
- this._tileLoaded(tile, id, state, result);
191
+ this._tileLoaded(tile, id, state, hadData, result);
192
192
  } catch (err) {
193
193
  tile.state = 'errored';
194
194
 
@@ -287,6 +287,8 @@ export class TileManager extends Evented {
287
287
  // - hard to tell without repro steps
288
288
  if (!tile) return;
289
289
 
290
+ const hadData = tile.hasData();
291
+
290
292
  // The difference between "loading" tiles and "reloading" or "expired"
291
293
  // tiles is that "reloading"/"expired" tiles are "renderable".
292
294
  // Therefore, a "loading" tile cannot become a "reloading" tile without
@@ -294,14 +296,17 @@ export class TileManager extends Evented {
294
296
  if (tile.state !== 'loading') {
295
297
  tile.state = state;
296
298
  }
297
- await this._loadTile(tile, id, state);
299
+ await this._loadTile(tile, id, state, hadData);
298
300
  }
299
301
 
300
- _tileLoaded(tile: Tile, id: string, previousState: TileState, result: LoadTileResult): void {
301
- tile.timeAdded = now();
302
- // Since self-fading applies to unloaded tiles, fadeEndTime must be updated upon load
303
- if (tile.selfFading) {
304
- tile.fadeEndTime = tile.timeAdded + this._rasterFadeDuration;
302
+ _tileLoaded(tile: Tile, id: string, previousState: TileState, hadData: boolean, result: LoadTileResult): void {
303
+ // If the tile was already showing do not restart its fade-in animation
304
+ if (!hadData) {
305
+ tile.timeAdded = now();
306
+ // Since self-fading applies to unloaded tiles, fadeEndTime must be updated upon load
307
+ if (tile.selfFading) {
308
+ tile.fadeEndTime = tile.timeAdded + this._rasterFadeDuration;
309
+ }
305
310
  }
306
311
 
307
312
  if (previousState === 'expired') tile.refreshedUponExpiration = true;
@@ -708,7 +713,7 @@ export class TileManager extends Evented {
708
713
 
709
714
  if (!tile) {
710
715
  tile = new Tile(tileID, this._source.tileSize * tileID.overscaleFactor());
711
- this._loadTile(tile, tileID.key, tile.state);
716
+ this._loadTile(tile, tileID.key, tile.state, false);
712
717
  }
713
718
 
714
719
  tile.uses++;
@@ -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();
@@ -1,9 +1,10 @@
1
- import {describe, beforeEach, test, expect, vi} from 'vitest';
1
+ import {describe, beforeEach, test, expect, vi, onTestFinished} from 'vitest';
2
2
  import {Map} from '../map.ts';
3
3
  import {Marker} from '../marker.ts';
4
4
  import {DOM} from '../../util/dom.ts';
5
5
  import simulate from '../../../test/unit/lib/simulate_interaction.ts';
6
6
  import {beforeMapTest} from '../../util/test/util.ts';
7
+ import {restoreNow, setNow} from '../../util/time_control.ts';
7
8
 
8
9
  function createMap() {
9
10
  return new Map({container: DOM.create('div', '', window.document.body)});
@@ -96,28 +97,38 @@ describe('touch zoom rotate', () => {
96
97
  map.on('rotate', rotate);
97
98
  map.on('rotateend', rotateend);
98
99
 
100
+ // Frozen time, so that the inertia does not depend on how long the test itself takes.
101
+ setNow(1000);
102
+ onTestFinished(restoreNow);
99
103
  simulate.touchstart(map.getCanvas(), {touches: [{target, identifier: 0, clientX: 0, clientY: -50}, {target, identifier: 1, clientX: 0, clientY: 50}]});
100
104
  map._renderTaskQueue.run();
101
105
  expect(rotatestart).toHaveBeenCalledTimes(0);
102
106
  expect(rotate).toHaveBeenCalledTimes(0);
103
107
  expect(rotateend).toHaveBeenCalledTimes(0);
104
108
 
109
+ setNow(1016);
105
110
  simulate.touchmove(map.getCanvas(), {touches: [{target, identifier: 0, clientX: -50, clientY: 0}, {target, identifier: 1, clientX: 50, clientY: 0}]});
106
111
  map._renderTaskQueue.run();
107
112
  expect(rotatestart).toHaveBeenCalledTimes(1);
108
113
  expect(rotate).toHaveBeenCalledTimes(1);
109
114
  expect(rotateend).toHaveBeenCalledTimes(0);
110
115
 
116
+ setNow(1032);
111
117
  simulate.touchmove(map.getCanvas(), {touches: [{target, identifier: 0, clientX: 0, clientY: -50}, {target, identifier: 1, clientX: 0, clientY: 50}]});
112
118
  map._renderTaskQueue.run();
113
119
  expect(rotatestart).toHaveBeenCalledTimes(1);
114
120
  expect(rotate).toHaveBeenCalledTimes(2);
115
121
  expect(rotateend).toHaveBeenCalledTimes(0);
116
122
 
123
+ setNow(1048);
117
124
  simulate.touchend(map.getCanvas(), {touches: []});
118
125
  map._renderTaskQueue.run();
119
- expect(rotatestart).toHaveBeenCalledTimes(1);
120
- expect(rotate).toHaveBeenCalledTimes(2);
126
+
127
+ // incremented because inertia starts a second rotation: the gesture was still
128
+ // rotating when it ended, even though it ended at the bearing it started from
129
+ expect(rotatestart).toHaveBeenCalledTimes(2);
130
+ map._renderTaskQueue.run();
131
+ expect(rotate).toHaveBeenCalledTimes(3);
121
132
  expect(rotateend).toHaveBeenCalledTimes(1);
122
133
 
123
134
  map.remove();
@@ -312,11 +323,11 @@ describe('touch zoom rotate', () => {
312
323
  const map = createMap();
313
324
 
314
325
  const className = 'maplibregl-touch-zoom-rotate';
315
- expect(map.getCanvasContainer().classList.contains(className)).toBeTruthy();
326
+ expect(map.getCanvasContainer().classList).toContain(className);
316
327
  map.touchZoomRotate.disable();
317
- expect(map.getCanvasContainer().classList.contains(className)).toBeFalsy();
328
+ expect(map.getCanvasContainer().classList).not.toContain(className);
318
329
  map.touchZoomRotate.enable();
319
- expect(map.getCanvasContainer().classList.contains(className)).toBeTruthy();
330
+ expect(map.getCanvasContainer().classList).toContain(className);
320
331
  });
321
332
 
322
333
  test('TwoFingersTouchZoomRotateHandler zooms when touching two markers on the same map', () => {