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
package/src/ui/map.ts CHANGED
@@ -31,6 +31,7 @@ import {defaultLocale} from './default_locale.ts';
31
31
  import {isAbortError} from '../util/abort_error.ts';
32
32
  import {coveringTiles, type CoveringTilesOptions, createCalculateTileZoomFunction} from '../geo/projection/covering_tiles.ts';
33
33
  import {CanonicalTileID, type OverscaledTileID} from '../tile/tile_id.ts';
34
+ import {isStyleImageWebGLData} from '../style/style_image.ts';
34
35
 
35
36
  import type {PaddingOptions} from '../geo/edge_insets.ts';
36
37
  import type {Source} from '../source/source.ts';
@@ -3208,9 +3209,11 @@ export class Map extends Evented<MapEventType> {
3208
3209
  } else {
3209
3210
  const {width, height, data} = image as ImageData;
3210
3211
  const userImage = (image as any as StyleImageInterface);
3212
+ const isWebGLImage = isStyleImageWebGLData(userImage.data);
3211
3213
 
3212
3214
  return {
3213
- data: new RGBAImage({width, height}, new Uint8Array(data)),
3215
+ // A WebGL image paints its own slot, so its pixels are only ever transparent padding.
3216
+ data: isWebGLImage ? new RGBAImage({width, height}) : new RGBAImage({width, height}, new Uint8Array(data)),
3214
3217
  pixelRatio,
3215
3218
  stretchX,
3216
3219
  stretchY,
@@ -3219,6 +3222,7 @@ export class Map extends Evented<MapEventType> {
3219
3222
  textFitHeight,
3220
3223
  sdf,
3221
3224
  version,
3225
+ isWebGLImage,
3222
3226
  userImage
3223
3227
  };
3224
3228
  }
@@ -3267,8 +3271,13 @@ export class Map extends Evented<MapEventType> {
3267
3271
  'The width and height of the updated image must be that same as the previous version of the image')));
3268
3272
  }
3269
3273
 
3270
- const copy = !(image instanceof HTMLImageElement || isImageBitmap(image));
3271
- existingImage.data.replace(data, copy);
3274
+ existingImage.isWebGLImage = isStyleImageWebGLData(data);
3275
+ if (existingImage.isWebGLImage) {
3276
+ existingImage.userImage = image as StyleImageInterface;
3277
+ } else {
3278
+ const copy = !(image instanceof HTMLImageElement || isImageBitmap(image));
3279
+ existingImage.data.replace(data as Uint8Array | Uint8ClampedArray, copy);
3280
+ }
3272
3281
 
3273
3282
  this.style.updateImage(id, existingImage);
3274
3283
  return this;
@@ -4146,6 +4155,11 @@ export class Map extends Evented<MapEventType> {
4146
4155
 
4147
4156
  if (this._lostContextStyle.images && this.style) {
4148
4157
  this.style.imageManager.images = this._lostContextStyle.images;
4158
+ // The atlas textures died with the old context, so images that render themselves with WebGL owe every atlas a fresh render.
4159
+ for (const id in this._lostContextStyle.images) {
4160
+ const image = this._lostContextStyle.images[id];
4161
+ if (image.isWebGLImage) this.style.imageManager.updateImage(id, image, false);
4162
+ }
4149
4163
  }
4150
4164
 
4151
4165
  this._lostContextStyle = {style: null, images: null};
@@ -36,8 +36,8 @@ describe('Map cross-window support', () => {
36
36
  expect(iframeDocument).not.toBe(window.document);
37
37
  expect(iframeWindow).not.toBe(window);
38
38
 
39
- expect(container instanceof HTMLElement).toBe(false);
40
- expect(container instanceof iframeDocument.defaultView.HTMLElement).toBe(true);
39
+ expect(container).not.toBeInstanceOf( HTMLElement);
40
+ expect(container).toBeInstanceOf(iframeDocument.defaultView.HTMLElement);
41
41
 
42
42
  const map = new Map({
43
43
  container,
@@ -3,9 +3,9 @@ import simulate from '../../../test/unit/lib/simulate_interaction.ts';
3
3
  import {type StyleLayer} from '../../style/style_layer.ts';
4
4
  import {createMap, beforeMapTest, createStyle, sleep, createTerrain} from '../../util/test/util.ts';
5
5
  import {type MapGeoJSONFeature} from '../../util/vectortile_to_geojson.ts';
6
- import {type MapLibreEvent} from '../events.ts';
6
+ import {type MapLibreEvent, MapSourceDataEvent} from '../events.ts';
7
7
  import {Map} from '../map.ts';
8
- import {Event as EventedEvent, ErrorEvent} from '../../util/evented.ts';
8
+ import {ErrorEvent} from '../../util/evented.ts';
9
9
  import {type StyleSpecification} from '@maplibre/maplibre-gl-style-spec';
10
10
 
11
11
  type IsAny<T> = 0 extends T & 1 ? T : never;
@@ -980,7 +980,7 @@ describe('map events', () => {
980
980
  test('fires sourcedataabort event on dataabort event', async () => {
981
981
  const map = createMap();
982
982
  const sourcePromise = map.once('sourcedataabort');
983
- map.fire(new EventedEvent('dataabort'));
983
+ map.fire(new MapSourceDataEvent('dataabort'));
984
984
  await expect(sourcePromise).resolves.toBeDefined();
985
985
  });
986
986
 
@@ -95,6 +95,32 @@ test('map fires `styleimagemissing` when missing style image resolver returns no
95
95
  expect(map.hasImage(id)).toBeFalsy();
96
96
  });
97
97
 
98
+ test('map preserves resolved images when missing style image resolver rejects another image', async () => {
99
+ const map = createMap();
100
+
101
+ const resolvedId = 'resolved-style-image';
102
+ const rejectedId = 'rejected-style-image';
103
+ const sampleImage = {width: 2, height: 1, data: new Uint8Array(8)};
104
+ const missingImageEventSpy = vi.fn();
105
+
106
+ map.setMissingStyleImageResolver(async (imageId) => {
107
+ if (imageId === rejectedId) {
108
+ throw new Error('Failed to resolve image');
109
+ }
110
+ map.addImage(imageId, sampleImage);
111
+ });
112
+ map.on('styleimagemissing', missingImageEventSpy);
113
+
114
+ const generatedImages = await map.style.imageManager.getImages([resolvedId, rejectedId]);
115
+
116
+ expect(generatedImages[resolvedId].data.width).toEqual(sampleImage.width);
117
+ expect(generatedImages[rejectedId]).toBeUndefined();
118
+ expect(missingImageEventSpy).toHaveBeenCalledTimes(1);
119
+ expect(missingImageEventSpy.mock.calls[0][0].id).toBe(rejectedId);
120
+ expect(map.hasImage(resolvedId)).toBeTruthy();
121
+ expect(map.hasImage(rejectedId)).toBeFalsy();
122
+ });
123
+
98
124
  test('map keeps missing style image resolver after replacing the style', async () => {
99
125
  const map = createMap();
100
126
 
@@ -215,6 +241,35 @@ test('map getImage matches addImage, StyleImageInterface SDF', () => {
215
241
  expect(gotImage.sdf).toBe(true);
216
242
  });
217
243
 
244
+ test('map addImage packs a placeholder for a WebGL image, which brings its own pixels', () => {
245
+ const map = createMap();
246
+ const id = 'add-get-webgl-style-image';
247
+ const onAdd = vi.fn();
248
+ const inputImage: StyleImageInterface = {width: 3, height: 2, data: {renderWithWebGL: vi.fn()}, onAdd};
249
+
250
+ map.addImage(id, inputImage);
251
+
252
+ const gotImage = map.getImage(id);
253
+ expect(gotImage.data.width).toBe(3);
254
+ expect(gotImage.data.height).toBe(2);
255
+ expect([...gotImage.data.data]).toEqual(new Array(3 * 2 * 4).fill(0));
256
+ expect(gotImage.isWebGLImage).toBe(true);
257
+ expect(gotImage.userImage).toBe(inputImage);
258
+ expect(onAdd).toHaveBeenCalledWith(map, id);
259
+ });
260
+
261
+ test('map updateImage swaps a WebGL image\'s renderWithWebGL callback instead of copying pixels', () => {
262
+ const map = createMap();
263
+ const replacement: StyleImageInterface = {width: 1, height: 1, data: {renderWithWebGL: vi.fn()}};
264
+
265
+ map.addImage('webgl', {width: 1, height: 1, data: {renderWithWebGL: vi.fn()}});
266
+ const version = map.getImage('webgl').version;
267
+ map.updateImage('webgl', replacement);
268
+
269
+ expect(map.getImage('webgl').userImage).toBe(replacement);
270
+ expect(map.getImage('webgl').version).toBe(version + 1);
271
+ });
272
+
218
273
  test('map does not fire `styleimagemissing` for empty icon values', async () => {
219
274
  const map = createMap();
220
275
 
@@ -1,7 +1,8 @@
1
1
  import {describe, beforeEach, afterEach, test, expect, vi} from 'vitest';
2
2
  import {Map, type MapOptions} from '../map.ts';
3
3
  import {createMap, beforeMapTest, createStyle, createStyleSource, sleep} from '../../util/test/util.ts';
4
- import {Event as EventedEvent} from '../../util/evented.ts';
4
+ import {ErrorEvent} from '../../util/evented.ts';
5
+ import {MapSourceDataEvent, MapStyleDataEvent} from '../events.ts';
5
6
  import {fixedLngLat, fixedNum} from '../../../test/unit/lib/fixed.ts';
6
7
  import {extend} from '../../util/util.ts';
7
8
  import {fakeServer, type FakeServer} from 'nise';
@@ -42,9 +43,9 @@ describe('setStyle', () => {
42
43
  map.on('data', recordEvent);
43
44
  map.on('dataloading', recordEvent);
44
45
 
45
- map.style.fire(new EventedEvent('error'));
46
- map.style.fire(new EventedEvent('data'));
47
- map.style.fire(new EventedEvent('dataloading'));
46
+ map.style.fire(new ErrorEvent(new Error('test')));
47
+ map.style.fire(new MapSourceDataEvent('data'));
48
+ map.style.fire(new MapSourceDataEvent('dataloading'));
48
49
 
49
50
  expect(events).toEqual([
50
51
  'error',
@@ -65,10 +66,10 @@ describe('setStyle', () => {
65
66
  map.on('sourcedata', recordEvent);
66
67
  map.on('sourcedataloading', recordEvent);
67
68
 
68
- map.style.fire(new EventedEvent('data', {dataType: 'style'}));
69
- map.style.fire(new EventedEvent('dataloading', {dataType: 'style'}));
70
- map.style.fire(new EventedEvent('data', {dataType: 'source'}));
71
- map.style.fire(new EventedEvent('dataloading', {dataType: 'source'}));
69
+ map.style.fire(new MapStyleDataEvent('data'));
70
+ map.style.fire(new MapStyleDataEvent('dataloading'));
71
+ map.style.fire(new MapSourceDataEvent('data'));
72
+ map.style.fire(new MapSourceDataEvent('dataloading'));
72
73
 
73
74
  expect(events).toEqual([
74
75
  'styledata',
@@ -93,6 +93,36 @@ test('does not fire "webglcontextrestored" after remove has been called', async
93
93
  expect(spy).not.toHaveBeenCalled();
94
94
  });
95
95
 
96
+ test('a WebGL style image is told to release its GPU resources on context loss, and survives it', async () => {
97
+ const map = createMap();
98
+ const canvas = map.getCanvas();
99
+ await map.once('load');
100
+
101
+ const userImage = {
102
+ width: 2, height: 2,
103
+ data: {renderWithWebGL: vi.fn()},
104
+ onRemove: vi.fn()
105
+ };
106
+ map.addImage('gpu-image', userImage);
107
+ const versionBeforeContextLoss = map.getImage('gpu-image').version;
108
+
109
+ const contextLostPromise = map.once('webglcontextlost');
110
+ canvas.dispatchEvent(new window.Event('webglcontextlost'));
111
+ await contextLostPromise;
112
+
113
+ expect(userImage.onRemove).toHaveBeenCalled();
114
+
115
+ const contextRestoredPromise = map.once('webglcontextrestored');
116
+ canvas.dispatchEvent(new window.Event('webglcontextrestored'));
117
+ await contextRestoredPromise;
118
+
119
+ expect(map.hasImage('gpu-image')).toBe(true);
120
+ expect(map.getImage('gpu-image').userImage).toBe(userImage);
121
+ expect(map.getImage('gpu-image').version).toBe(versionBeforeContextLoss + 1);
122
+
123
+ map.remove();
124
+ });
125
+
96
126
  test('WebGL2 context creation error fires ErrorEvent with structured GPUInitializationError', () => {
97
127
  HTMLCanvasElement.prototype.getContext = function (type: string) {
98
128
  if (type === 'webgl2') {
@@ -96,20 +96,20 @@ describe('marker', () => {
96
96
  .addTo(map);
97
97
 
98
98
  const markerElement = marker.getElement();
99
- expect(markerElement.classList.contains('some')).toBeTruthy();
100
- expect(markerElement.classList.contains('classes')).toBeTruthy();
99
+ expect(markerElement.classList).toContain('some');
100
+ expect(markerElement.classList).toContain('classes');
101
101
 
102
102
  marker.addClassName('addedClass');
103
- expect(markerElement.classList.contains('addedClass')).toBeTruthy();
103
+ expect(markerElement.classList).toContain('addedClass');
104
104
 
105
105
  marker.removeClassName('addedClass');
106
- expect(!markerElement.classList.contains('addedClass')).toBeTruthy();
106
+ expect(markerElement.classList).not.toContain('addedClass');
107
107
 
108
108
  marker.toggleClassName('toggle');
109
- expect(markerElement.classList.contains('toggle')).toBeTruthy();
109
+ expect(markerElement.classList).toContain('toggle');
110
110
 
111
111
  marker.toggleClassName('toggle');
112
- expect(!markerElement.classList.contains('toggle')).toBeTruthy();
112
+ expect(markerElement.classList).not.toContain('toggle');
113
113
 
114
114
  expect(() => marker.addClassName('should throw exception')).toThrow(window.DOMException);
115
115
  expect(() => marker.removeClassName('should throw exception')).toThrow(window.DOMException);
@@ -123,19 +123,19 @@ describe('marker', () => {
123
123
  test('Marker provides LngLat accessors', () => {
124
124
  expect(new Marker().getLngLat()).toBeUndefined();
125
125
 
126
- expect(new Marker().setLngLat([1, 2]).getLngLat() instanceof LngLat).toBeTruthy();
126
+ expect(new Marker().setLngLat([1, 2]).getLngLat()).toBeInstanceOf(LngLat);
127
127
  expect(new Marker().setLngLat([1, 2]).getLngLat()).toEqual(new LngLat(1, 2));
128
128
 
129
- expect(new Marker().setLngLat(new LngLat(1, 2)).getLngLat() instanceof LngLat).toBeTruthy();
129
+ expect(new Marker().setLngLat(new LngLat(1, 2)).getLngLat()).toBeInstanceOf(LngLat);
130
130
  expect(new Marker().setLngLat(new LngLat(1, 2)).getLngLat()).toEqual(new LngLat(1, 2));
131
131
 
132
132
  });
133
133
 
134
134
  test('Marker provides offset accessors', () => {
135
- expect(new Marker().setOffset([1, 2]).getOffset() instanceof Point).toBeTruthy();
135
+ expect(new Marker().setOffset([1, 2]).getOffset()).toBeInstanceOf(Point);
136
136
  expect(new Marker().setOffset([1, 2]).getOffset()).toEqual(new Point(1, 2));
137
137
 
138
- expect(new Marker().setOffset(new Point(1, 2)).getOffset() instanceof Point).toBeTruthy();
138
+ expect(new Marker().setOffset(new Point(1, 2)).getOffset()).toBeInstanceOf(Point);
139
139
  expect(new Marker().setOffset(new Point(1, 2)).getOffset()).toEqual(new Point(1, 2));
140
140
 
141
141
  });
@@ -343,7 +343,7 @@ describe('marker', () => {
343
343
  .setLngLat([0, 0])
344
344
  .addTo(map);
345
345
 
346
- expect(marker.getElement().classList.contains('maplibregl-marker-anchor-center')).toBeTruthy();
346
+ expect(marker.getElement().classList).toContain('maplibregl-marker-anchor-center');
347
347
  expect(marker.getElement().style.transform).toMatch(/translate\(-50%,-50%\)/);
348
348
 
349
349
  map.remove();
@@ -355,7 +355,7 @@ describe('marker', () => {
355
355
  .setLngLat([0, 0])
356
356
  .addTo(map);
357
357
 
358
- expect(marker.getElement().classList.contains('maplibregl-marker-anchor-top')).toBeTruthy();
358
+ expect(marker.getElement().classList).toContain('maplibregl-marker-anchor-top');
359
359
  expect(marker.getElement().style.transform).toMatch(/translate\(-50%,0\)/);
360
360
 
361
361
  map.remove();
@@ -407,51 +407,35 @@ describe('marker', () => {
407
407
  Object.defineProperty(marker.getPopup()._container, 'offsetHeight', {value: 100});
408
408
 
409
409
  // marker should default to above since it has enough space
410
- expect(
411
- marker.getPopup()._container.classList.contains('maplibregl-popup-anchor-bottom')
412
- ).toBeTruthy();
410
+ expect(marker.getPopup()._container.classList).toContain('maplibregl-popup-anchor-bottom');
413
411
 
414
412
  // move marker to the top forcing the popup to below
415
413
  marker.setLngLat(map.unproject([mapHeight / 2, markerTop]));
416
- expect(
417
- marker.getPopup()._container.classList.contains('maplibregl-popup-anchor-top')
418
- ).toBeTruthy();
414
+ expect(marker.getPopup()._container.classList).toContain('maplibregl-popup-anchor-top');
419
415
 
420
416
  // move marker to the right forcing the popup to the left
421
417
  marker.setLngLat(map.unproject([mapHeight - markerRight, mapHeight / 2]));
422
- expect(
423
- marker.getPopup()._container.classList.contains('maplibregl-popup-anchor-right')
424
- ).toBeTruthy();
418
+ expect(marker.getPopup()._container.classList).toContain('maplibregl-popup-anchor-right');
425
419
 
426
420
  // move marker to the left forcing the popup to the right
427
421
  marker.setLngLat(map.unproject([markerRight, mapHeight / 2]));
428
- expect(
429
- marker.getPopup()._container.classList.contains('maplibregl-popup-anchor-left')
430
- ).toBeTruthy();
422
+ expect(marker.getPopup()._container.classList).toContain('maplibregl-popup-anchor-left');
431
423
 
432
424
  // move marker to the top left forcing the popup to the bottom right
433
425
  marker.setLngLat(map.unproject([markerRight, markerTop]));
434
- expect(
435
- marker.getPopup()._container.classList.contains('maplibregl-popup-anchor-top-left')
436
- ).toBeTruthy();
426
+ expect(marker.getPopup()._container.classList).toContain('maplibregl-popup-anchor-top-left');
437
427
 
438
428
  // move marker to the top right forcing the popup to the bottom left
439
429
  marker.setLngLat(map.unproject([mapHeight - markerRight, markerTop]));
440
- expect(
441
- marker.getPopup()._container.classList.contains('maplibregl-popup-anchor-top-right')
442
- ).toBeTruthy();
430
+ expect(marker.getPopup()._container.classList).toContain('maplibregl-popup-anchor-top-right');
443
431
 
444
432
  // move marker to the bottom left forcing the popup to the top right
445
433
  marker.setLngLat(map.unproject([markerRight, mapHeight]));
446
- expect(
447
- marker.getPopup()._container.classList.contains('maplibregl-popup-anchor-bottom-left')
448
- ).toBeTruthy();
434
+ expect(marker.getPopup()._container.classList).toContain('maplibregl-popup-anchor-bottom-left');
449
435
 
450
436
  // move marker to the bottom right forcing the popup to the top left
451
437
  marker.setLngLat(map.unproject([mapHeight - markerRight, mapHeight]));
452
- expect(
453
- marker.getPopup()._container.classList.contains('maplibregl-popup-anchor-bottom-right')
454
- ).toBeTruthy();
438
+ expect(marker.getPopup()._container.classList).toContain('maplibregl-popup-anchor-bottom-right');
455
439
 
456
440
  map.remove();
457
441
  });
@@ -828,6 +812,157 @@ describe('marker', () => {
828
812
  map.remove();
829
813
  });
830
814
 
815
+ test('Marker with draggable:true is keyboard-focusable, and stops being focusable when dragging is disabled', () => {
816
+ const map = createMap();
817
+ const marker = new Marker({draggable: true}).setLngLat([0, 0]).addTo(map);
818
+ expect(marker.getElement().getAttribute('tabindex')).toBe('0');
819
+ marker.setDraggable(false);
820
+ expect(marker.getElement().hasAttribute('tabindex')).toBe(false);
821
+ map.remove();
822
+ });
823
+
824
+ test('Marker.setDraggable preserves an application-provided tabindex', () => {
825
+ const marker = new Marker();
826
+ marker.getElement().setAttribute('tabindex', '5');
827
+
828
+ marker.setDraggable(true);
829
+ expect(marker.getElement().getAttribute('tabindex')).toBe('5');
830
+
831
+ marker.setDraggable(false);
832
+ expect(marker.getElement().getAttribute('tabindex')).toBe('5');
833
+ });
834
+
835
+ test('Marker keeps a tabindex the application overrode after the marker managed it', () => {
836
+ const marker = new Marker({draggable: true});
837
+ marker.setDraggable(true);
838
+ expect(marker.getElement().getAttribute('tabindex')).toBe('0');
839
+
840
+ marker.getElement().setAttribute('tabindex', '5');
841
+ marker.setDraggable(false);
842
+ expect(marker.getElement().getAttribute('tabindex')).toBe('5');
843
+ });
844
+
845
+ test('Marker starts a fresh keyboard gesture after being removed mid-drag and re-added', () => {
846
+ const map = createMap();
847
+ const marker = new Marker({draggable: true}).setLngLat([0, 0]).addTo(map);
848
+ const el = marker.getElement();
849
+ const dragstart = vi.fn();
850
+ marker.on('dragstart', dragstart);
851
+
852
+ el.dispatchEvent(new KeyboardEvent('keydown', {key: 'ArrowRight', bubbles: true, cancelable: true}));
853
+ expect(dragstart).toHaveBeenCalledTimes(1);
854
+
855
+ // removed mid-gesture (no keyup), then re-added
856
+ marker.remove();
857
+ marker.addTo(map);
858
+
859
+ el.dispatchEvent(new KeyboardEvent('keydown', {key: 'ArrowRight', bubbles: true, cancelable: true}));
860
+ expect(dragstart).toHaveBeenCalledTimes(2);
861
+ map.remove();
862
+ });
863
+
864
+ test('Marker keeps its tabindex while either dragging or a popup keeps it interactive', () => {
865
+ const marker = new Marker({draggable: true}).setPopup(new Popup());
866
+
867
+ marker.setPopup();
868
+ expect(marker.getElement().getAttribute('tabindex')).toBe('0');
869
+
870
+ marker.setDraggable(false);
871
+ expect(marker.getElement().hasAttribute('tabindex')).toBe(false);
872
+ });
873
+
874
+ test('Marker with a custom element leaves focusability and keyboard handling to the application', () => {
875
+ const map = createMap();
876
+ const element = window.document.createElement('div');
877
+ const marker = new Marker({draggable: true, element}).setLngLat([0, 0]).addTo(map);
878
+ const startLngLat = marker.getLngLat();
879
+ const event = new KeyboardEvent('keydown', {key: 'ArrowRight', bubbles: true, cancelable: true});
880
+
881
+ element.dispatchEvent(event);
882
+
883
+ expect(element.hasAttribute('tabindex')).toBe(false);
884
+ expect(marker.getLngLat()).toEqual(startLngLat);
885
+ expect(event.defaultPrevented).toBe(false);
886
+ map.remove();
887
+ });
888
+
889
+ test('Marker with draggable:true moves with arrow keys and fires drag events', () => {
890
+ const map = createMap();
891
+ const marker = new Marker({draggable: true}).setLngLat([0, 0]).addTo(map);
892
+ const el = marker.getElement();
893
+ const startPos = map.project(marker.getLngLat());
894
+ const events: string[] = [];
895
+ const positionsAtDragstart: Point[] = [];
896
+ marker.on('dragstart', () => {
897
+ events.push('dragstart');
898
+ positionsAtDragstart.push(map.project(marker.getLngLat()));
899
+ });
900
+ marker.on('drag', () => events.push('drag'));
901
+ marker.on('dragend', () => events.push('dragend'));
902
+
903
+ const ignoredEvent = new KeyboardEvent('keydown', {key: 'Enter', bubbles: true, cancelable: true});
904
+ el.dispatchEvent(ignoredEvent);
905
+ expect(map.project(marker.getLngLat())).toEqual(startPos);
906
+ expect(ignoredEvent.defaultPrevented).toBe(false);
907
+
908
+ const nestedElement = window.document.createElement('button');
909
+ el.attachShadow({mode: 'open'}).appendChild(nestedElement);
910
+ const nestedEvent = new KeyboardEvent('keydown', {key: 'ArrowRight', bubbles: true, composed: true, cancelable: true});
911
+ nestedElement.dispatchEvent(nestedEvent);
912
+ expect(map.project(marker.getLngLat())).toEqual(startPos);
913
+ expect(nestedEvent.defaultPrevented).toBe(false);
914
+
915
+ const easeTo = vi.spyOn(map, 'easeTo');
916
+ const containerHandler = vi.fn();
917
+ map.getCanvasContainer().addEventListener('keydown', containerHandler);
918
+ const event = new KeyboardEvent('keydown', {key: 'ArrowRight', bubbles: true, cancelable: true});
919
+ el.dispatchEvent(event);
920
+
921
+ expect(map.project(marker.getLngLat()).x).toBeCloseTo(startPos.x + 1);
922
+ expect(map.project(marker.getLngLat()).y).toBeCloseTo(startPos.y);
923
+ expect(event.defaultPrevented).toBe(true);
924
+ expect(containerHandler).not.toHaveBeenCalled();
925
+ expect(easeTo).not.toHaveBeenCalled();
926
+ expect(events).toEqual(['dragstart', 'drag']);
927
+ expect(positionsAtDragstart[0].x).toBeCloseTo(startPos.x + 1);
928
+
929
+ el.dispatchEvent(new KeyboardEvent('keydown', {key: 'ArrowRight', repeat: true, bubbles: true, cancelable: true}));
930
+ el.dispatchEvent(new KeyboardEvent('keyup', {key: 'ArrowRight', bubbles: true, cancelable: true}));
931
+ expect(events).toEqual(['dragstart', 'drag', 'drag', 'dragend']);
932
+
933
+ el.dispatchEvent(new KeyboardEvent('keydown', {key: 'ArrowRight', bubbles: true, cancelable: true}));
934
+ el.dispatchEvent(new FocusEvent('blur'));
935
+ expect(events).toEqual(['dragstart', 'drag', 'drag', 'dragend', 'dragstart', 'drag', 'dragend']);
936
+
937
+ el.dispatchEvent(new KeyboardEvent('keyup', {key: 'ArrowRight', bubbles: true, cancelable: true}));
938
+ expect(events).toEqual(['dragstart', 'drag', 'drag', 'dragend', 'dragstart', 'drag', 'dragend']);
939
+ map.remove();
940
+ });
941
+
942
+ test('Marker with draggable:true moves ten pixels per Shift+arrow keydown', () => {
943
+ const map = createMap();
944
+ const marker = new Marker({draggable: true}).setLngLat([0, 0]).addTo(map);
945
+ const startPos = map.project(marker.getLngLat());
946
+
947
+ marker.getElement().dispatchEvent(new KeyboardEvent('keydown', {key: 'ArrowDown', shiftKey: true, bubbles: true, cancelable: true}));
948
+
949
+ const endPos = map.project(marker.getLngLat());
950
+ expect(endPos.x).toBeCloseTo(startPos.x);
951
+ expect(endPos.y).toBeCloseTo(startPos.y + 10);
952
+ map.remove();
953
+ });
954
+
955
+ test('Marker stops responding to arrow keys after dragging is disabled', () => {
956
+ const map = createMap();
957
+ const marker = new Marker({draggable: true}).setLngLat([0, 0]).addTo(map).setDraggable(false);
958
+ const startLngLat = marker.getLngLat();
959
+
960
+ marker.getElement().dispatchEvent(new KeyboardEvent('keydown', {key: 'ArrowRight', bubbles: true, cancelable: true}));
961
+
962
+ expect(marker.getLngLat()).toEqual(startLngLat);
963
+ map.remove();
964
+ });
965
+
831
966
  test('Marker with draggable:false does not move to new position in response to a mouse-triggered drag', () => {
832
967
  const map = createMap();
833
968
  const marker = new Marker({})
@@ -1352,7 +1487,7 @@ describe('marker', () => {
1352
1487
  map.fire('terrain');
1353
1488
  await sleep(100);
1354
1489
 
1355
- expect(marker.getElement().classList.contains('maplibregl-marker-covered')).toBe(true);
1490
+ expect(marker.getElement().classList).toContain('maplibregl-marker-covered');
1356
1491
  map.remove();
1357
1492
  });
1358
1493
 
@@ -1368,13 +1503,13 @@ describe('marker', () => {
1368
1503
  map.fire('terrain');
1369
1504
  await sleep(100);
1370
1505
 
1371
- expect(marker.getElement().classList.contains('maplibregl-marker-covered')).toBe(true);
1506
+ expect(marker.getElement().classList).toContain('maplibregl-marker-covered');
1372
1507
 
1373
1508
  map.terrain.depthAtPoint = () => .95; // Terrain no longer blocking marker
1374
1509
  map.fire('moveend');
1375
1510
  await sleep(100);
1376
1511
 
1377
- expect(marker.getElement().classList.contains('maplibregl-marker-covered')).toBe(false);
1512
+ expect(marker.getElement().classList).not.toContain('maplibregl-marker-covered');
1378
1513
  map.remove();
1379
1514
  });
1380
1515
 
@@ -1389,7 +1524,7 @@ describe('marker', () => {
1389
1524
  map.setProjection({type: 'globe'});
1390
1525
  await sleep(100);
1391
1526
 
1392
- expect(marker.getElement().classList.contains('maplibregl-marker-covered')).toBe(true);
1527
+ expect(marker.getElement().classList).toContain('maplibregl-marker-covered');
1393
1528
  map.remove();
1394
1529
  });
1395
1530
 
@@ -1404,12 +1539,12 @@ describe('marker', () => {
1404
1539
  map.setProjection({type: 'globe'});
1405
1540
  await sleep(100);
1406
1541
 
1407
- expect(marker.getElement().classList.contains('maplibregl-marker-covered')).toBe(true);
1542
+ expect(marker.getElement().classList).toContain('maplibregl-marker-covered');
1408
1543
 
1409
1544
  marker.setLngLat([0, 0]);
1410
1545
  await sleep(100);
1411
1546
 
1412
- expect(marker.getElement().classList.contains('maplibregl-marker-covered')).toBe(false);
1547
+ expect(marker.getElement().classList).not.toContain('maplibregl-marker-covered');
1413
1548
  map.remove();
1414
1549
  });
1415
1550
  });