maplibre-gl 6.3.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 (56) hide show
  1. package/dist/maplibre-gl-dev.mjs +590 -208
  2. package/dist/maplibre-gl-dev.mjs.map +1 -1
  3. package/dist/maplibre-gl-shared-dev.mjs +258 -235
  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 +2 -2
  8. package/dist/maplibre-gl-worker.mjs +2 -2
  9. package/dist/maplibre-gl-worker.mjs.map +1 -1
  10. package/dist/maplibre-gl.d.ts +258 -97
  11. package/dist/maplibre-gl.mjs +18 -18
  12. package/dist/maplibre-gl.mjs.map +1 -1
  13. package/package.json +7 -7
  14. package/src/data/bucket/fill_extrusion_bucket.ts +1 -13
  15. package/src/data/bucket/round_polygon_corners.test.ts +84 -36
  16. package/src/data/bucket/round_polygon_corners.ts +90 -39
  17. package/src/data/extent_bounds.ts +28 -1
  18. package/src/geo/projection/globe_transform.test.ts +129 -1
  19. package/src/geo/projection/globe_utils.ts +189 -2
  20. package/src/geo/projection/vertical_perspective_camera_helper.test.ts +24 -0
  21. package/src/geo/projection/vertical_perspective_camera_helper.ts +11 -10
  22. package/src/render/image_atlas.test.ts +110 -2
  23. package/src/render/image_atlas.ts +35 -4
  24. package/src/render/image_manager.test.ts +66 -1
  25. package/src/render/image_manager.ts +136 -144
  26. package/src/render/painter.ts +5 -2
  27. package/src/render/pattern_atlas.test.ts +131 -0
  28. package/src/render/pattern_atlas.ts +144 -0
  29. package/src/shaders/glsl/_prelude.vertex.glsl +8 -8
  30. package/src/shaders/glsl/_prelude.vertex.glsl.g.ts +1 -1
  31. package/src/shaders/glsl/color_relief.fragment.glsl +1 -2
  32. package/src/shaders/glsl/color_relief.fragment.glsl.g.ts +1 -1
  33. package/src/shaders/glsl/hillshade_prepare.fragment.glsl +12 -12
  34. package/src/shaders/glsl/hillshade_prepare.fragment.glsl.g.ts +1 -1
  35. package/src/source/image_source.test.ts +41 -31
  36. package/src/source/image_source.ts +14 -15
  37. package/src/source/raster_dem_tile_source.test.ts +37 -3
  38. package/src/source/raster_dem_tile_source.ts +1 -2
  39. package/src/source/raster_tile_source.test.ts +5 -26
  40. package/src/source/raster_tile_source.ts +4 -3
  41. package/src/style/style.test.ts +64 -0
  42. package/src/style/style.ts +37 -64
  43. package/src/symbol/cross_tile_symbol_index.test.ts +31 -0
  44. package/src/symbol/cross_tile_symbol_index.ts +67 -37
  45. package/src/tile/tile_manager.test.ts +69 -1
  46. package/src/tile/tile_manager.ts +14 -9
  47. package/src/ui/handler/two_fingers_touch.test.ts +14 -3
  48. package/src/ui/handler_inertia.test.ts +91 -0
  49. package/src/ui/handler_inertia.ts +65 -14
  50. package/src/ui/map_tests/map_images.test.ts +26 -0
  51. package/src/ui/map_tests/map_webgl.test.ts +2 -1
  52. package/src/ui/marker.test.ts +151 -0
  53. package/src/ui/marker.ts +120 -10
  54. package/src/util/image_request.ts +31 -1
  55. package/src/webgl/draw/draw_background.ts +1 -1
  56. package/src/webgl/program/pattern.ts +3 -3
@@ -3,6 +3,7 @@ import {bezier, clamp, extend, evaluateZoomSnap} from '../util/util.ts';
3
3
  import Point from '@mapbox/point-geometry';
4
4
 
5
5
  import type {DragPanOptions} from './handler/shim/drag_pan.ts';
6
+ import type {HandlerResult} from './handler_manager.ts';
6
7
  import type {EaseToOptions} from './camera.ts';
7
8
  import type {Map} from './map.ts';
8
9
 
@@ -43,12 +44,24 @@ export type InertiaOptions = {
43
44
  maxSpeed: number;
44
45
  };
45
46
 
47
+ /**
48
+ * The maximum age of a recorded gesture update, in milliseconds.
49
+ */
50
+ const BUFFER_CUTOFF = 160;
51
+
52
+ /**
53
+ * The time window the gesture velocity is measured over, in milliseconds.
54
+ */
55
+ const VELOCITY_WINDOW = 60;
56
+
57
+ type InertiaBufferEntry = {
58
+ time: number;
59
+ settings: HandlerResult;
60
+ };
61
+
46
62
  export class HandlerInertia {
47
63
  _map: Map;
48
- _inertiaBuffer: Array<{
49
- time: number;
50
- settings: any;
51
- }>;
64
+ _inertiaBuffer: InertiaBufferEntry[];
52
65
 
53
66
  constructor(map: Map) {
54
67
  this._map = map;
@@ -59,23 +72,54 @@ export class HandlerInertia {
59
72
  this._inertiaBuffer = [];
60
73
  }
61
74
 
62
- record(settings: any): void {
75
+ record(settings: HandlerResult): void {
63
76
  this._drainInertiaBuffer();
64
77
  this._inertiaBuffer.push({time: now(), settings});
65
78
  }
66
79
 
67
80
  _drainInertiaBuffer(): void {
68
81
  const inertia = this._inertiaBuffer,
69
- currentTime = now(),
70
- cutoff = 160; //msec
82
+ currentTime = now();
71
83
 
72
- while (inertia.length > 0 && currentTime - inertia[0].time > cutoff)
84
+ while (inertia.length > 0 && currentTime - inertia[0].time > BUFFER_CUTOFF)
73
85
  inertia.shift();
74
86
  }
75
87
 
88
+ /**
89
+ * Returns the entries the velocity is measured from: everything recorded within
90
+ * {@link VELOCITY_WINDOW} before now, but never fewer than the last two, so that
91
+ * inertia also works below two recorded updates per window.
92
+ */
93
+ _getVelocityEntries(): InertiaBufferEntry[] {
94
+ const inertia = this._inertiaBuffer;
95
+ const windowStart = now() - VELOCITY_WINDOW;
96
+
97
+ let firstIndex = Math.max(0, inertia.length - 2);
98
+ while (firstIndex > 0 && inertia[firstIndex - 1].time >= windowStart) {
99
+ firstIndex--;
100
+ }
101
+
102
+ return inertia.slice(firstIndex);
103
+ }
104
+
105
+ /**
106
+ * Returns the ease which continues the gesture that has just ended.
107
+ *
108
+ * The velocity is measured over the time up to the release and not up to the last
109
+ * recorded movement, so that a gesture held still before being released has a lower
110
+ * velocity, down to no inertia at all. The delta of an entry happened before it was
111
+ * recorded, so the first entry only marks the start of the measured interval; anchors
112
+ * are the exception, as they describe the state at the moment of their entry.
113
+ *
114
+ * @param panInertiaOptions - overrides for the pan inertia defaults
115
+ * @returns the ease options, or `undefined` when the measured interval holds no
116
+ * movement, which terrain gestures produce by recording updates without any delta
117
+ */
76
118
  _onMoveEnd(panInertiaOptions?: DragPanOptions | boolean): EaseToOptions {
77
119
  this._drainInertiaBuffer();
78
- if (this._inertiaBuffer.length < 2) {
120
+ const entries = this._getVelocityEntries();
121
+ if (entries.length < 2) {
122
+ this.clear();
79
123
  return;
80
124
  }
81
125
 
@@ -89,18 +133,25 @@ export class HandlerInertia {
89
133
  around: undefined
90
134
  };
91
135
 
92
- for (const {settings} of this._inertiaBuffer) {
136
+ for (const {settings} of entries) {
137
+ if (settings.around) deltas.around = settings.around;
138
+ if (settings.pinchAround) deltas.pinchAround = settings.pinchAround;
139
+ }
140
+
141
+ for (const {settings} of entries.slice(1)) {
93
142
  deltas.zoom += settings.zoomDelta || 0;
94
143
  deltas.bearing += settings.bearingDelta || 0;
95
144
  deltas.pitch += settings.pitchDelta || 0;
96
145
  deltas.roll += settings.rollDelta || 0;
97
146
  if (settings.panDelta) deltas.pan._add(settings.panDelta);
98
- if (settings.around) deltas.around = settings.around;
99
- if (settings.pinchAround) deltas.pinchAround = settings.pinchAround;
100
147
  }
101
148
 
102
- const lastEntry = this._inertiaBuffer[this._inertiaBuffer.length - 1];
103
- const duration = (lastEntry.time - this._inertiaBuffer[0].time);
149
+ if (!deltas.pan.mag() && !deltas.zoom && !deltas.bearing && !deltas.pitch && !deltas.roll) {
150
+ this.clear();
151
+ return;
152
+ }
153
+
154
+ const duration = now() - entries[0].time;
104
155
 
105
156
  const easeOptions = {} as any;
106
157
 
@@ -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
 
@@ -104,6 +104,7 @@ test('a WebGL style image is told to release its GPU resources on context loss,
104
104
  onRemove: vi.fn()
105
105
  };
106
106
  map.addImage('gpu-image', userImage);
107
+ const versionBeforeContextLoss = map.getImage('gpu-image').version;
107
108
 
108
109
  const contextLostPromise = map.once('webglcontextlost');
109
110
  canvas.dispatchEvent(new window.Event('webglcontextlost'));
@@ -117,7 +118,7 @@ test('a WebGL style image is told to release its GPU resources on context loss,
117
118
 
118
119
  expect(map.hasImage('gpu-image')).toBe(true);
119
120
  expect(map.getImage('gpu-image').userImage).toBe(userImage);
120
- expect(map.style.imageManager.updatedImages['gpu-image']).toBe(true);
121
+ expect(map.getImage('gpu-image').version).toBe(versionBeforeContextLoss + 1);
121
122
 
122
123
  map.remove();
123
124
  });
@@ -812,6 +812,157 @@ describe('marker', () => {
812
812
  map.remove();
813
813
  });
814
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
+
815
966
  test('Marker with draggable:false does not move to new position in response to a mouse-triggered drag', () => {
816
967
  const map = createMap();
817
968
  const marker = new Marker({})
package/src/ui/marker.ts CHANGED
@@ -18,6 +18,19 @@ import type {PointLike} from './camera.ts';
18
18
  */
19
19
  export type Alignment = 'map' | 'viewport' | 'auto';
20
20
 
21
+ /**
22
+ * Screen-pixel deltas applied when a focused draggable marker is moved with the arrow keys.
23
+ */
24
+ const ARROW_KEY_DELTAS: Partial<Record<KeyboardEvent['key'], [number, number]>> = {
25
+ ArrowLeft: [-1, 0],
26
+ ArrowRight: [1, 0],
27
+ ArrowUp: [0, -1],
28
+ ArrowDown: [0, 1]
29
+ };
30
+
31
+ const KEYBOARD_DRAG_SMALL_STEP = 1;
32
+ const KEYBOARD_DRAG_LARGE_STEP = 10;
33
+
21
34
  /**
22
35
  * The {@link Marker} options object
23
36
  */
@@ -52,6 +65,10 @@ export type MarkerOptions = {
52
65
  scale?: number;
53
66
  /**
54
67
  * A boolean indicating whether or not a marker is able to be dragged to a new position on the map.
68
+ * A draggable default marker also becomes keyboard focusable and, while focused, moves by
69
+ * 1 screen pixel per arrow-key press (10 with Shift), firing the same `dragstart`/`drag`/`dragend`
70
+ * events as pointer dragging. Markers with a custom `element` keep their focusability and
71
+ * keyboard behavior application-owned.
55
72
  * @defaultValue false
56
73
  */
57
74
  draggable?: boolean;
@@ -222,12 +239,13 @@ export class Marker extends Evented<MarkerEventType> {
222
239
  _rotation: number;
223
240
  _pitchAlignment: Alignment;
224
241
  _rotationAlignment: Alignment;
225
- _originalTabIndex: string; // original tabindex of _element
226
242
  _opacity: string;
227
243
  _opacityWhenCovered: string;
228
244
  _opacityTimeout: ReturnType<typeof setTimeout>;
229
245
  _subpixelPositioning: boolean;
230
246
  _roleManaged: boolean;
247
+ _tabIndexManaged: boolean;
248
+ _keyboardDragActive: boolean;
231
249
 
232
250
  /**
233
251
  * @param options - the options
@@ -243,6 +261,8 @@ export class Marker extends Evented<MarkerEventType> {
243
261
  this._subpixelPositioning = options?.subpixelPositioning || false;
244
262
  this._isDragging = false;
245
263
  this._roleManaged = false;
264
+ this._tabIndexManaged = false;
265
+ this._keyboardDragActive = false;
246
266
  this._state = 'inactive';
247
267
  this._rotation = options?.rotation || 0;
248
268
  this._rotationAlignment = options?.rotationAlignment || 'auto';
@@ -447,6 +467,12 @@ export class Marker extends Evented<MarkerEventType> {
447
467
  delete this._map;
448
468
  }
449
469
  this._element.removeEventListener('click', this._onClick);
470
+ this._element.removeEventListener('keydown', this._onKeyDown);
471
+ this._element.removeEventListener('keyup', this._onKeyUp);
472
+ this._element.removeEventListener('blur', this._onBlur);
473
+ this._element.removeEventListener('keypress', this._onKeyPress);
474
+ // Drop any in-flight keyboard drag silently, like a pointer drag interrupted by remove().
475
+ this._keyboardDragActive = false;
450
476
  this._element.remove();
451
477
  if (this._popup) this._popup.remove();
452
478
  return this;
@@ -520,10 +546,6 @@ export class Marker extends Evented<MarkerEventType> {
520
546
  this._popup.remove();
521
547
  this._popup = null;
522
548
  this._element.removeEventListener('keypress', this._onKeyPress);
523
-
524
- if (!this._originalTabIndex) {
525
- this._element.removeAttribute('tabindex');
526
- }
527
549
  }
528
550
 
529
551
  if (popup) {
@@ -544,13 +566,10 @@ export class Marker extends Evented<MarkerEventType> {
544
566
  }
545
567
  this._popup = popup;
546
568
 
547
- this._originalTabIndex = this._element.getAttribute('tabindex');
548
- if (!this._originalTabIndex) {
549
- this._element.setAttribute('tabindex', '0');
550
- }
551
569
  this._element.addEventListener('keypress', this._onKeyPress);
552
570
  }
553
571
 
572
+ this._updateTabIndex();
554
573
  this._updateAccessibilityRole();
555
574
  return this;
556
575
  }
@@ -581,6 +600,57 @@ export class Marker extends Evented<MarkerEventType> {
581
600
  }
582
601
  };
583
602
 
603
+ /**
604
+ * Move a focused draggable default marker with the arrow keys
605
+ * (1 screen pixel per keydown; 10 with Shift).
606
+ * Mirrors the pointer drag gesture: the position updates before `dragstart`
607
+ * fires on the first movement, every movement fires `drag`, and releasing
608
+ * the arrow key (or losing focus) fires `dragend`. Holding a key down
609
+ * produces repeated `drag` events within a single gesture.
610
+ */
611
+ _onKeyDown = (e: KeyboardEvent): void => {
612
+ // Custom marker elements own their keyboard behavior (#7790).
613
+ if (!this._defaultMarker || !this._draggable || !this._map || !this._lngLat) return;
614
+ // Leave keys originating from elements nested inside the marker to the application.
615
+ if (e.composedPath()[0] !== this._element) return;
616
+ // Leave Alt/Ctrl/Meta shortcuts to the browser and the application.
617
+ if (e.altKey || e.ctrlKey || e.metaKey) return;
618
+
619
+ const delta = ARROW_KEY_DELTAS[e.key];
620
+ if (!delta) return;
621
+
622
+ e.preventDefault();
623
+ // The marker element lives in the canvas container, so without this the
624
+ // map's KeyboardHandler would also pan (or rotate/pitch with Shift) the camera.
625
+ e.stopPropagation();
626
+
627
+ const step = e.shiftKey ? KEYBOARD_DRAG_LARGE_STEP : KEYBOARD_DRAG_SMALL_STEP;
628
+ const pos = this._map.project(this._lngLat);
629
+ this.setLngLat(this._map.unproject(new Point(pos.x + delta[0] * step, pos.y + delta[1] * step)));
630
+
631
+ if (!this._keyboardDragActive) {
632
+ this._keyboardDragActive = true;
633
+ this.fire(new MarkerDragEvent('dragstart'));
634
+ }
635
+ this.fire(new MarkerDragEvent('drag'));
636
+ };
637
+
638
+ _onKeyUp = (e: KeyboardEvent): void => {
639
+ if (!ARROW_KEY_DELTAS[e.key]) return;
640
+ this._endKeyboardDrag();
641
+ };
642
+
643
+ _onBlur = (): void => {
644
+ this._endKeyboardDrag();
645
+ };
646
+
647
+ _endKeyboardDrag(): void {
648
+ if (this._keyboardDragActive) {
649
+ this._keyboardDragActive = false;
650
+ this.fire(new MarkerDragEvent('dragend'));
651
+ }
652
+ }
653
+
584
654
  _onMapClick = (e: MapMouseEvent): void => {
585
655
  const targetElement = e.originalEvent.target;
586
656
  const element = this._element;
@@ -851,7 +921,10 @@ export class Marker extends Evented<MarkerEventType> {
851
921
  };
852
922
 
853
923
  /**
854
- * Sets the `draggable` property and functionality of the marker
924
+ * Sets the `draggable` property and functionality of the marker.
925
+ * A draggable default marker is also keyboard focusable and movable with the
926
+ * arrow keys (see {@link MarkerOptions.draggable}); custom marker elements keep
927
+ * their focusability and keyboard behavior application-owned.
855
928
  * @param shouldBeDraggable - Turns drag functionality on/off
856
929
  */
857
930
  setDraggable(shouldBeDraggable?: boolean): this {
@@ -870,6 +943,20 @@ export class Marker extends Evented<MarkerEventType> {
870
943
  }
871
944
  }
872
945
 
946
+ if (this._defaultMarker) {
947
+ if (this._draggable) {
948
+ this._element.addEventListener('keydown', this._onKeyDown);
949
+ this._element.addEventListener('keyup', this._onKeyUp);
950
+ this._element.addEventListener('blur', this._onBlur);
951
+ } else {
952
+ this._element.removeEventListener('keydown', this._onKeyDown);
953
+ this._element.removeEventListener('keyup', this._onKeyUp);
954
+ this._element.removeEventListener('blur', this._onBlur);
955
+ this._endKeyboardDrag();
956
+ }
957
+ }
958
+
959
+ this._updateTabIndex();
873
960
  this._updateAccessibilityRole();
874
961
  return this;
875
962
  }
@@ -882,6 +969,29 @@ export class Marker extends Evented<MarkerEventType> {
882
969
  return this._draggable;
883
970
  }
884
971
 
972
+ /**
973
+ * Keep the marker element focusable while it has built-in keyboard behavior.
974
+ * A popup makes any marker element interactive; dragging only manages
975
+ * focusability for the default marker so custom elements stay
976
+ * application-owned (#7790). A tabindex supplied by the application is
977
+ * never added, changed, or removed here.
978
+ */
979
+ _updateTabIndex(): void {
980
+ const needsTabIndex = !!this._popup || (this._defaultMarker && this._draggable);
981
+ if (needsTabIndex) {
982
+ if (!this._element.hasAttribute('tabindex')) {
983
+ this._element.setAttribute('tabindex', '0');
984
+ this._tabIndexManaged = true;
985
+ }
986
+ } else if (this._tabIndexManaged) {
987
+ // Only remove the value we set; the application may have overridden it since.
988
+ if (this._element.getAttribute('tabindex') === '0') {
989
+ this._element.removeAttribute('tabindex');
990
+ }
991
+ this._tabIndexManaged = false;
992
+ }
993
+ }
994
+
885
995
  /**
886
996
  * Keep the default marker role aligned with interactivity.
887
997
  * Default markers need a role because `aria-label` is set in {@link Marker.addTo}.
@@ -1,9 +1,11 @@
1
1
  import {type RequestParameters, makeRequest, sameOrigin, type GetResourceResponse} from './ajax.ts';
2
2
  import {arrayBufferToImageBitmap, arrayBufferToImage, ensureError, extend, isWorker, isImageBitmap} from './util.ts';
3
3
  import {config} from './config.ts';
4
- import {AbortError} from './abort_error.ts';
4
+ import {AbortError, throwIfAborted} from './abort_error.ts';
5
5
  import {getProtocol} from '../source/protocol_crud.ts';
6
6
 
7
+ import type {RequestManager, ResourceType} from './request_manager.ts';
8
+
7
9
  type ImageQueueThrottleControlCallback = () => boolean;
8
10
 
9
11
  export type ImageRequestQueueItem = {
@@ -97,11 +99,39 @@ export namespace ImageRequest {
97
99
  return false;
98
100
  };
99
101
 
102
+ /**
103
+ * Transform `url` through {@link RequestManager.transformRequest} and load the image.
104
+ * The transform may be async; if `abortController` aborts while it is pending, the
105
+ * request is never issued and the returned promise rejects with an `AbortError`.
106
+ * @param requestManager - The request manager whose transform to apply.
107
+ * @param url - The image URL, before transformation.
108
+ * @param resourceType - The resource type to pass to the transform.
109
+ * @param abortController - allows to abort the request.
110
+ * @param supportImageRefresh - `true`, if the image request need to support refresh based on cache headers.
111
+ * @param imageBitmapOptions - Options for `createImageBitmap`. Pass `{colorSpaceConversion: 'none'}` for images
112
+ * whose pixels hold data rather than color, such as raster-DEM tiles, so the browser does not color manage them.
113
+ * @returns - A promise resolved when the image is loaded.
114
+ */
115
+ export async function transformAndGetImage(
116
+ requestManager: RequestManager,
117
+ url: string,
118
+ resourceType: ResourceType,
119
+ abortController: AbortController,
120
+ supportImageRefresh: boolean = true,
121
+ imageBitmapOptions?: ImageBitmapOptions
122
+ ): Promise<GetResourceResponse<HTMLImageElement | ImageBitmap | null>> {
123
+ const requestParameters = await requestManager.transformRequest(url, resourceType);
124
+ throwIfAborted(abortController.signal);
125
+ return ImageRequest.getImage(requestParameters, abortController, supportImageRefresh, imageBitmapOptions);
126
+ }
127
+
100
128
  /**
101
129
  * Request to load an image.
102
130
  * @param requestParameters - Request parameters.
103
131
  * @param abortController - allows to abort the request.
104
132
  * @param supportImageRefresh - `true`, if the image request need to support refresh based on cache headers.
133
+ * @param imageBitmapOptions - Options for `createImageBitmap`. Pass `{colorSpaceConversion: 'none'}` for images
134
+ * whose pixels hold data rather than color, such as raster-DEM tiles, so the browser does not color manage them.
105
135
  * @returns - A promise resolved when the image is loaded.
106
136
  */
107
137
  export const getImage = (
@@ -39,7 +39,7 @@ export function drawBackground(painter: Painter, tileManager: TileManager, layer
39
39
 
40
40
  if (image) {
41
41
  context.activeTexture.set(gl.TEXTURE0);
42
- painter.imageManager.bind(painter.context);
42
+ painter.patternAtlas.bind(painter.context);
43
43
  }
44
44
 
45
45
  const crossfade = layer.getCrossfadeParameters();
@@ -71,9 +71,9 @@ function bgPatternUniformValues(
71
71
  tileSize: number;
72
72
  }
73
73
  ): UniformValues<BackgroundPatternUniformsType> {
74
- const imagePosA = painter.imageManager.getPattern(image.from.toString());
75
- const imagePosB = painter.imageManager.getPattern(image.to.toString());
76
- const {width, height} = painter.imageManager.getPixelSize();
74
+ const imagePosA = painter.patternAtlas.getPattern(image.from.toString());
75
+ const imagePosB = painter.patternAtlas.getPattern(image.to.toString());
76
+ const {width, height} = painter.patternAtlas.getPixelSize();
77
77
 
78
78
  const numTiles = Math.pow(2, tile.tileID.overscaledZ);
79
79
  const tileSizeAtNearestZoom = tile.tileSize * Math.pow(2, painter.transform.tileZoom) / numTiles;