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
@@ -5,7 +5,6 @@ import {type FakeServer, fakeServer} from 'nise';
5
5
  import {beforeMapTest, createMap, sleep, stubAjaxGetImage, waitForEvent} from '../util/test/util.ts';
6
6
  import {Tile} from '../tile/tile.ts';
7
7
  import {OverscaledTileID} from '../tile/tile_id.ts';
8
- import {ImageRequest} from '../util/image_request.ts';
9
8
  import type {Texture} from '../webgl/texture.ts';
10
9
  import type {ImageSourceSpecification} from '@maplibre/maplibre-gl-style-spec';
11
10
  import type {MapSourceDataEvent} from '../ui/events.ts';
@@ -19,6 +18,19 @@ function createSource(options) {
19
18
  return new ImageSource('id', options, {} as any, options.eventedParent);
20
19
  }
21
20
 
21
+ async function createLoadedSourceWithTile(map: Map, server: FakeServer) {
22
+ server.respondImmediately = true;
23
+ const source = createSource({url: '/image.png', eventedParent: map});
24
+ const loaded = waitForEvent(source, 'data', (e: MapSourceDataEvent) => e.sourceDataType === 'metadata');
25
+ source.onAdd(map);
26
+ await loaded;
27
+
28
+ const {z, x, y} = source.tileID;
29
+ const tile = new Tile(new OverscaledTileID(z, 0, z, x, y), 512);
30
+ await source.loadTile(tile);
31
+ return {source, tile};
32
+ }
33
+
22
34
  describe('ImageSource', () => {
23
35
  stubAjaxGetImage(undefined);
24
36
  let server: FakeServer;
@@ -58,40 +70,24 @@ describe('ImageSource', () => {
58
70
  expect(source.image).toBeTruthy();
59
71
  });
60
72
 
61
- test('passes a live AbortController to ImageRequest when the source is aborted during an async transformRequest', async () => {
73
+ test('does not request the image when the source is removed while its request is being transformed', async () => {
74
+ server.respondImmediately = true;
62
75
  const source = createSource({url: '/image.png'});
63
- let transformStarted: () => void;
64
- const transformCalled = new Promise<void>((resolve) => {
65
- transformStarted = resolve;
66
- });
76
+ const errorHandler = vi.fn();
77
+ source.on('error', errorHandler);
67
78
  let releaseTransform: (params: {url: string}) => void;
68
- map.setTransformRequest(() => {
69
- transformStarted();
70
- return new Promise<{url: string}>((resolve) => {
71
- releaseTransform = resolve;
72
- });
73
- });
74
- const image = {width: 1, height: 1} as ImageBitmap;
75
- let requestController: AbortController;
76
- let sourceControllerAtRequestTime: AbortController;
77
- const getImageSpy = vi.spyOn(ImageRequest, 'getImage').mockImplementation(async (_request, abortController) => {
78
- requestController = abortController;
79
- sourceControllerAtRequestTime = source._request;
80
- return {data: image};
81
- });
79
+ map.setTransformRequest(() => new Promise<{url: string}>((resolve) => {
80
+ releaseTransform = resolve;
81
+ }));
82
+ const load = vi.spyOn(source, 'load');
82
83
 
83
84
  source.onAdd(map);
84
- await transformCalled; // load() is suspended in the transform
85
- source.onRemove(); // the abort lands during that suspension
86
- const loaded = waitForEvent(source, 'data', (e: MapSourceDataEvent) => e.sourceDataType === 'metadata');
85
+ source.onRemove();
87
86
  releaseTransform({url: '/image.png'});
88
- await loaded;
87
+ await load.mock.results[0].value;
89
88
 
90
- expect(getImageSpy).toHaveBeenCalledTimes(1);
91
- // The request must carry the source's own live controller, so an abort
92
- // arriving while it is in flight reaches exactly this request.
93
- expect(requestController).toBeInstanceOf(AbortController);
94
- expect(requestController).toBe(sourceControllerAtRequestTime);
89
+ expect(server.requests).toHaveLength(0);
90
+ expect(errorHandler).not.toHaveBeenCalled();
95
91
  });
96
92
 
97
93
  test('transforms url request', () => {
@@ -147,6 +143,112 @@ describe('ImageSource', () => {
147
143
  expect(afterSerialized.coordinates).toEqual([[0, 0], [-1, 0], [-1, -1], [0, -1]]);
148
144
  });
149
145
 
146
+ test('sets perspective transform for non-parallelogram coordinates', () => {
147
+ const source = createSource({url: '/image.png'});
148
+ source.setCoordinates([
149
+ [-122.52, 37.815],
150
+ [-122.355, 37.8],
151
+ [-122.325, 37.7],
152
+ [-122.545, 37.735]
153
+ ]);
154
+
155
+ expect(source.perspectiveTransform).not.toEqual([0, 0, 1]);
156
+ expect(source.perspectiveTransform.every(Number.isFinite)).toBe(true);
157
+ });
158
+
159
+ test('sets perspective transform when initial coordinates are loaded', async () => {
160
+ const source = createSource({
161
+ url: '/image.png',
162
+ coordinates: [
163
+ [-122.52, 37.815],
164
+ [-122.355, 37.8],
165
+ [-122.325, 37.7],
166
+ [-122.545, 37.735]
167
+ ]
168
+ });
169
+ const promise = waitForEvent(source, 'data', (e) => e.sourceDataType === 'content');
170
+
171
+ source.onAdd(map);
172
+ await sleep(0);
173
+ server.respond();
174
+ await promise;
175
+
176
+ expect(source.perspectiveTransform).not.toEqual([0, 0, 1]);
177
+ expect(source.perspectiveTransform.every(Number.isFinite)).toBe(true);
178
+ });
179
+
180
+ test('keeps projective transform when its constant coefficient is zero', () => {
181
+ const source = createSource({url: '/image.png'});
182
+ source.setCoordinates([
183
+ [-67.5, 55.77657301866769],
184
+ [-22.5, 55.77657301866769],
185
+ [0, 40.97989806962013],
186
+ [-90, 40.97989806962013]
187
+ ]);
188
+
189
+ expect(source.perspectiveTransform).toEqual([0, 1, 0]);
190
+ });
191
+
192
+ test('sets identity perspective transform for parallelogram coordinates', () => {
193
+ const source = createSource({url: '/image.png'});
194
+ source.setCoordinates([
195
+ [-122.431640625, 37.857507156],
196
+ [-122.409667969, 37.857507156],
197
+ [-122.409667969, 37.840156836],
198
+ [-122.431640625, 37.840156836]
199
+ ]);
200
+
201
+ expect(source.perspectiveTransform).toEqual([0, 0, 1]);
202
+ });
203
+
204
+ test('sets identity perspective transform for collinear destination coordinates', () => {
205
+ const source = createSource({url: '/image.png'});
206
+ source.setCoordinates([
207
+ [-122.431640625, 37.857507156],
208
+ [-122.409667969, 37.857507156],
209
+ [-122.387695312, 37.857507156],
210
+ [-122.365722656, 37.857507156]
211
+ ]);
212
+
213
+ expect(source.perspectiveTransform).toEqual([0, 0, 1]);
214
+ });
215
+
216
+ test('sets identity perspective transform when the inverse basis is singular', () => {
217
+ const source = createSource({url: '/image.png'});
218
+ source.setCoordinates([
219
+ [-122.431640625, 37.857507156],
220
+ [-122.431640625, 37.840156836],
221
+ [-122.431640625, 37.822802434],
222
+ [-122.409667969, 37.857507156]
223
+ ]);
224
+
225
+ expect(source.perspectiveTransform).toEqual([0, 0, 1]);
226
+ });
227
+
228
+ test('sets identity perspective transform for a cancellation-degenerate quad', () => {
229
+ const source = createSource({url: '/image.png'});
230
+ source.setCoordinates([
231
+ [96.85546875, 79.36770077764092],
232
+ [-127.265625, 79.36770077764092],
233
+ [88.06640625, 79.36770077764092],
234
+ [-17.40234375, 59.534318001095585]
235
+ ]);
236
+
237
+ expect(source.perspectiveTransform).toEqual([0, 0, 1]);
238
+ });
239
+
240
+ test('sets identity perspective transform when its denominator crosses zero inside the quad', () => {
241
+ const source = createSource({url: '/image.png'});
242
+ source.setCoordinates([
243
+ [-90, 66.51326044311186],
244
+ [0, 66.51326044311186],
245
+ [-78.75, 61.606396371386275],
246
+ [-90, 0]
247
+ ]);
248
+
249
+ expect(source.perspectiveTransform).toEqual([0, 0, 1]);
250
+ });
251
+
150
252
  test('sets coordinates via updateImage', async () => {
151
253
  const source = createSource({url: '/image.png'});
152
254
  source.onAdd(map);
@@ -199,6 +301,57 @@ describe('ImageSource', () => {
199
301
  expect(tile.state).toBe('loaded');
200
302
  });
201
303
 
304
+ test('uploads a url into the texture the tiles already hold', async () => {
305
+ const {source, tile} = await createLoadedSourceWithTile(map, server);
306
+ source.prepare();
307
+ const texture = source.texture;
308
+ const upload = vi.spyOn(texture, 'update');
309
+ const destroy = vi.spyOn(texture, 'destroy');
310
+
311
+ const load = vi.spyOn(source, 'load');
312
+ source.updateImage({url: '/image2.png'});
313
+ await load.mock.results[0].value;
314
+ source.prepare();
315
+
316
+ expect(upload).toHaveBeenCalledTimes(1);
317
+ expect(destroy).not.toHaveBeenCalled();
318
+ expect(source.texture).toBe(texture);
319
+ expect(tile.texture).toBe(texture);
320
+ });
321
+
322
+ test('uploads a decoded image into the texture the tiles already hold', async () => {
323
+ const {source, tile} = await createLoadedSourceWithTile(map, server);
324
+ source.prepare();
325
+ const texture = source.texture;
326
+ const upload = vi.spyOn(texture, 'update');
327
+ const destroy = vi.spyOn(texture, 'destroy');
328
+
329
+ source.updateImage({image: new ImageBitmap()});
330
+ source.prepare();
331
+
332
+ expect(upload).toHaveBeenCalledTimes(1);
333
+ expect(destroy).not.toHaveBeenCalled();
334
+ expect(source.texture).toBe(texture);
335
+ expect(tile.texture).toBe(texture);
336
+ });
337
+
338
+ test('keeps the texture the tiles hold live when updateImage runs from an event fired during prepare', async () => {
339
+ const {source, tile} = await createLoadedSourceWithTile(map, server);
340
+ source.on('data', (e: MapSourceDataEvent) => {
341
+ if (e.sourceDataType === 'idle') source.updateImage({image: new ImageBitmap()});
342
+ });
343
+
344
+ source.prepare();
345
+
346
+ expect(source.texture).toBeTruthy();
347
+ expect(tile.texture).toBe(source.texture);
348
+ expect(source.texture.texture).not.toBeNull();
349
+
350
+ const upload = vi.spyOn(source.texture, 'update');
351
+ source.prepare();
352
+ expect(upload).toHaveBeenCalledTimes(1);
353
+ });
354
+
202
355
  test('serialize url and coordinates', () => {
203
356
  const source = createSource({url: '/image.png'});
204
357
 
@@ -223,6 +376,19 @@ describe('ImageSource', () => {
223
376
  expect(source.image).toBeTruthy();
224
377
  });
225
378
 
379
+ test('keeps the image handed to updateImage instead of the url load it replaced', async () => {
380
+ const source = createSource({url: '/image.png', eventedParent: map});
381
+ const bitmap = new ImageBitmap();
382
+ const load = vi.spyOn(source, 'load');
383
+
384
+ source.onAdd(map);
385
+ source.updateImage({image: bitmap});
386
+ server.respondImmediately = true;
387
+ await load.mock.results[0].value;
388
+
389
+ expect(source.image).toBe(bitmap);
390
+ });
391
+
226
392
  test('cancels request if updateImage is used', async () => {
227
393
  const source = createSource({url: '/image.png', eventedParent: map});
228
394
 
@@ -237,6 +403,20 @@ describe('ImageSource', () => {
237
403
  expect(spy).toHaveBeenCalled();
238
404
  });
239
405
 
406
+ test('cancels the request updateImage started when updateImage is used again', async () => {
407
+ const source = createSource({url: '/image.png', eventedParent: map});
408
+ const load = vi.spyOn(source, 'load');
409
+
410
+ source.onAdd(map);
411
+ source.updateImage({url: '/image2.png'});
412
+ await load.mock.results[0].value;
413
+
414
+ const spy = vi.spyOn(server.requests[0] as any, 'abort');
415
+
416
+ source.updateImage({url: '/image3.png'});
417
+ expect(spy).toHaveBeenCalledTimes(1);
418
+ });
419
+
240
420
  test('marks the source as loaded when the request has received a response', async () => {
241
421
  const source = createSource({url: '/image.png', eventedParent: map});
242
422
 
@@ -275,6 +455,41 @@ describe('ImageSource', () => {
275
455
  expect(errorHandler).not.toHaveBeenCalled();
276
456
  });
277
457
 
458
+ test('keeps the image it displays, and its texture, when the url handed to updateImage fails to load', async () => {
459
+ const {source} = await createLoadedSourceWithTile(map, server);
460
+ source.prepare();
461
+ const texture = source.texture;
462
+ const image = source.image;
463
+ const upload = vi.spyOn(texture, 'update');
464
+ const destroy = vi.spyOn(texture, 'destroy');
465
+ const errorHandler = vi.fn();
466
+ map.on('error', errorHandler);
467
+
468
+ const load = vi.spyOn(source, 'load');
469
+ source.updateImage({url: '/missing-image.png'});
470
+ await load.mock.results[0].value;
471
+ source.prepare();
472
+
473
+ expect(errorHandler).toHaveBeenCalledTimes(1);
474
+ expect(destroy).not.toHaveBeenCalled();
475
+ expect(upload).not.toHaveBeenCalled();
476
+ expect(source.texture).toBe(texture);
477
+ expect(source.image).toBe(image);
478
+ });
479
+
480
+ test('deletes its texture and drops its image when the source is removed', async () => {
481
+ const {source} = await createLoadedSourceWithTile(map, server);
482
+ source.prepare();
483
+ const destroy = vi.spyOn(source.texture, 'destroy');
484
+
485
+ source.onRemove();
486
+
487
+ expect(destroy).toHaveBeenCalledTimes(1);
488
+ expect(source.texture).toBeNull();
489
+ expect(source.image).toBeNull();
490
+ expect(source.tiles).toEqual({});
491
+ });
492
+
278
493
  describe('updateImage with a decoded image', () => {
279
494
  let source: ImageSource;
280
495
  let transformRequest: Mock<(url: string, resourceType?: string) => any>;
@@ -286,7 +501,7 @@ describe('ImageSource', () => {
286
501
  map.on('error', () => {});
287
502
  source = createSource({url: '/image.png', eventedParent: map});
288
503
  // onAdd starts the initial load synchronously up to its first await, so
289
- // this._request is set and transformRequest is called once. Clear that call
504
+ // this._abortController is set and transformRequest is called once. Clear that call
290
505
  // so tests can assert the image path issues no further request.
291
506
  source.onAdd(map);
292
507
  transformRequest.mockClear();
@@ -309,13 +524,6 @@ describe('ImageSource', () => {
309
524
  expect(firedMetadata).toBe(true);
310
525
  });
311
526
 
312
- test('resets the texture so the new image is uploaded on the next prepare', () => {
313
- source.texture = {} as Texture;
314
- source.updateImage({image: new ImageBitmap()});
315
-
316
- expect(source.texture).toBeNull();
317
- });
318
-
319
527
  test('updates coordinates alongside the image', () => {
320
528
  source.updateImage({
321
529
  image: new ImageBitmap(),
@@ -326,7 +534,7 @@ describe('ImageSource', () => {
326
534
  });
327
535
 
328
536
  test('cancels a pending request', () => {
329
- const spy = vi.spyOn(source._request, 'abort');
537
+ const spy = vi.spyOn(source._abortController, 'abort');
330
538
  source.updateImage({image: new ImageBitmap()});
331
539
  expect(spy).toHaveBeenCalled();
332
540
  });
@@ -19,6 +19,11 @@ import type Point from '@mapbox/point-geometry';
19
19
  import {ensureError, MAX_TILE_ZOOM} from '../util/util.ts';
20
20
  import {Bounds} from '../geo/bounds.ts';
21
21
  import {isAbortError} from '../util/abort_error.ts';
22
+ import {
23
+ identityPerspectiveTransform,
24
+ type RasterPerspectiveTransform
25
+ } from '../webgl/program/raster_program.ts';
26
+ import {mat2, vec3} from 'gl-matrix';
22
27
 
23
28
  /**
24
29
  * Four geographical coordinates,
@@ -141,9 +146,11 @@ export class ImageSource extends Evented<SourceEventType> implements Source {
141
146
  image: ImageSourceImage;
142
147
  tileID: CanonicalTileID;
143
148
  tileCoords: Point[];
149
+ perspectiveTransform: RasterPerspectiveTransform = identityPerspectiveTransform;
144
150
  flippedWindingOrder: boolean = false;
145
151
  _loaded: boolean;
146
- _request: AbortController;
152
+ _abortController: AbortController;
153
+ private _imageDirty: boolean = false;
147
154
 
148
155
  /** @internal */
149
156
  constructor(id: string, options: ImageSourceSpecification | VideoSourceSpecification | CanvasSourceSpecification, dispatcher: Dispatcher, eventedParent: Evented) {
@@ -170,26 +177,25 @@ export class ImageSource extends Evented<SourceEventType> implements Source {
170
177
 
171
178
  this.url = this.options.url;
172
179
 
173
- const request = await this.map._requestManager.transformRequest(this.url, ResourceType.Image);
174
- this._request = new AbortController();
180
+ this._abortController = new AbortController();
175
181
  try {
176
- const image = await ImageRequest.getImage(request, this._request);
177
- this._request = null;
182
+ const image = await ImageRequest.transformAndGetImage(this.map._requestManager, this.url, ResourceType.Image, this._abortController);
183
+ this._abortController = null;
178
184
  this._loaded = true;
179
185
 
180
186
  if (image?.data) {
181
- this.image = image.data;
187
+ this._setImage(image.data);
182
188
  if (newCoordinates) {
183
189
  this.coordinates = newCoordinates;
184
190
  }
185
191
  this._finishLoading();
186
192
  }
187
193
  } catch (err) {
188
- this._request = null;
194
+ // In case of abort error, the aborter may have started a new request so we don't want to clear its abort controller.
195
+ if (isAbortError(err)) return;
196
+ this._abortController = null;
189
197
  this._loaded = true;
190
- if (!isAbortError(err)) {
191
- this.fire(new ErrorEvent(ensureError(err)));
192
- }
198
+ this.fire(new ErrorEvent(ensureError(err)));
193
199
  }
194
200
  }
195
201
 
@@ -208,19 +214,18 @@ export class ImageSource extends Evented<SourceEventType> implements Source {
208
214
  * @param options - The options object.
209
215
  */
210
216
  updateImage(options: UpdateImageOptions): this {
211
- if (this._request) {
212
- this._request.abort();
213
- this._request = null;
217
+ if (this._abortController) {
218
+ this._abortController.abort();
219
+ this._abortController = null;
214
220
  }
215
221
 
216
222
  if ('image' in options) {
217
223
  // Use the already-decoded image directly, skipping the network request.
218
224
  this._loaded = true;
219
- this.image = options.image;
225
+ this._setImage(options.image);
220
226
  if (options.coordinates) {
221
227
  this.coordinates = options.coordinates;
222
228
  }
223
- this.texture = null;
224
229
  this._finishLoading();
225
230
  return this;
226
231
  }
@@ -230,10 +235,22 @@ export class ImageSource extends Evented<SourceEventType> implements Source {
230
235
  }
231
236
 
232
237
  this.options.url = options.url;
233
- this.load(options.coordinates).finally(() => this.texture = null);
238
+ this.load(options.coordinates);
234
239
  return this;
235
240
  }
236
241
 
242
+ /** Loaded tiles hold `this.texture`, so the wrapper has to outlive the images in it. */
243
+ private _setImage(image: ImageSourceImage): void {
244
+ this.image = image;
245
+ this._imageDirty = true;
246
+ }
247
+
248
+ /** Teardown only: dropping the reference alone leaves the allocation to the GC. */
249
+ private _disposeTexture(): void {
250
+ this.texture?.destroy();
251
+ this.texture = null;
252
+ }
253
+
237
254
  _finishLoading(): void {
238
255
  if (this.map) {
239
256
  this.setCoordinates(this.coordinates);
@@ -247,10 +264,13 @@ export class ImageSource extends Evented<SourceEventType> implements Source {
247
264
  }
248
265
 
249
266
  onRemove(): void {
250
- if (this._request) {
251
- this._request.abort();
252
- this._request = null;
267
+ if (this._abortController) {
268
+ this._abortController.abort();
269
+ this._abortController = null;
253
270
  }
271
+ this._disposeTexture();
272
+ this.image = null;
273
+ this.tiles = {};
254
274
  }
255
275
 
256
276
  /**
@@ -287,6 +307,7 @@ export class ImageSource extends Evented<SourceEventType> implements Source {
287
307
  // Transform the corner coordinates into the coordinate space of our
288
308
  // tile.
289
309
  this.tileCoords = cornerCoords.map((coord) => this.tileID.getTilePoint(coord)._round());
310
+ this.perspectiveTransform = calculateRasterPerspectiveTransform(this.tileCoords);
290
311
  this.flippedWindingOrder = hasWrongWindingOrder(this.tileCoords);
291
312
 
292
313
  this.fire(new MapSourceDataEvent('data', {sourceDataType: 'content'}));
@@ -304,7 +325,11 @@ export class ImageSource extends Evented<SourceEventType> implements Source {
304
325
  if (!this.texture) {
305
326
  this.texture = new Texture(context, this.image, gl.RGBA);
306
327
  this.texture.bind(gl.LINEAR, gl.CLAMP_TO_EDGE);
328
+ } else if (this._imageDirty) {
329
+ this.texture.update(this.image);
330
+ this.texture.bind(gl.LINEAR, gl.CLAMP_TO_EDGE);
307
331
  }
332
+ this._imageDirty = false;
308
333
 
309
334
  let newTilesLoaded = false;
310
335
  for (const w in this.tiles) {
@@ -418,3 +443,91 @@ function hasWrongWindingOrder(coords: Point[]) {
418
443
 
419
444
  return crossProduct < 0;
420
445
  }
446
+
447
+ const perspectiveEpsilon = Number.EPSILON;
448
+ // Allows for accumulated rounding error in the three-term homogeneous dot products.
449
+ const perspectiveErrorFactor = 8;
450
+
451
+ /**
452
+ * Calculates the inverse-homography denominator used to sample non-affine
453
+ * image-source texture coordinates. Image source tile coordinates are rounded
454
+ * to integers before this runs, so singularity checks use exact zero or
455
+ * Number.EPSILON scaled to the corner denominators.
456
+ *
457
+ * Based on Paul S. Heckbert, "Fundamentals of Texture Mapping and Image
458
+ * Warping", UCB/CSD-89-516, 1989, section 2.2.3 and appendix A.2.
459
+ *
460
+ * @see https://www2.eecs.berkeley.edu/Pubs/TechRpts/1989/5504.html
461
+ * @see https://www.cs.cmu.edu/~ph/texfund/texfund.pdf
462
+ */
463
+ function calculateRasterPerspectiveTransform(cornerCoords: Point[]): RasterPerspectiveTransform {
464
+ if (isParallelogram(cornerCoords)) {
465
+ return identityPerspectiveTransform;
466
+ }
467
+
468
+ const [topLeft, topRight, bottomRight, bottomLeft] = cornerCoords;
469
+ const sx = topLeft.x - topRight.x + bottomRight.x - bottomLeft.x;
470
+ const sy = topLeft.y - topRight.y + bottomRight.y - bottomLeft.y;
471
+ const dx1 = topRight.x - bottomRight.x;
472
+ const dy1 = topRight.y - bottomRight.y;
473
+ const dx2 = bottomLeft.x - bottomRight.x;
474
+ const dy2 = bottomLeft.y - bottomRight.y;
475
+ const basis: mat2 = [dx1, dy1, dx2, dy2];
476
+ const determinant = mat2.determinant(basis);
477
+
478
+ if (Math.abs(determinant) < perspectiveEpsilon) {
479
+ return identityPerspectiveTransform;
480
+ }
481
+
482
+ const perspectiveX = (sx * dy2 - dx2 * sy) / determinant;
483
+ const perspectiveY = (dx1 * sy - sx * dy1) / determinant;
484
+
485
+ // A finite image of the unit square has positive homogeneous denominators
486
+ // at all four corners after normalizing the top-left denominator to one.
487
+ const forwardDenominators = [1, 1 + perspectiveX, 1 + perspectiveX + perspectiveY, 1 + perspectiveY];
488
+ const forwardDenominatorScale = Math.max(...forwardDenominators.map(value => Math.abs(value)));
489
+ const hasInvalidForwardDenominator = forwardDenominators.some(value =>
490
+ !Number.isFinite(value) || value <= perspectiveErrorFactor * perspectiveEpsilon * forwardDenominatorScale);
491
+ if (!Number.isFinite(forwardDenominatorScale) || forwardDenominatorScale === 0 || hasInvalidForwardDenominator) {
492
+ return identityPerspectiveTransform;
493
+ }
494
+
495
+ const a = topRight.x - topLeft.x + perspectiveX * topRight.x;
496
+ const b = bottomLeft.x - topLeft.x + perspectiveY * bottomLeft.x;
497
+ const d = topRight.y - topLeft.y + perspectiveX * topRight.y;
498
+ const e = bottomLeft.y - topLeft.y + perspectiveY * bottomLeft.y;
499
+ const inverseDenominator: RasterPerspectiveTransform = [0, 0, 0];
500
+ vec3.cross(inverseDenominator, [a, d, perspectiveX], [b, e, perspectiveY]);
501
+ const normalizationScale = Math.max(...inverseDenominator.map(value => Math.abs(value)));
502
+ const cornerDenominators = cornerCoords.map(({x, y}) =>
503
+ vec3.dot(inverseDenominator, [x, y, 1]));
504
+ const cornerDenominatorErrors = cornerCoords.map(({x, y}) =>
505
+ perspectiveErrorFactor * perspectiveEpsilon * (
506
+ Math.abs(inverseDenominator[0] * x) +
507
+ Math.abs(inverseDenominator[1] * y) +
508
+ Math.abs(inverseDenominator[2])));
509
+ const denominatorScale = Math.max(...cornerDenominators.map(value => Math.abs(value)));
510
+ const denominatorSign = Math.sign(cornerDenominators[0]);
511
+
512
+ // The denominator is affine within each rendered triangle. Reject a
513
+ // transform that reaches or crosses zero anywhere inside the quad.
514
+ const hasInvalidDenominator = cornerDenominators.some((value, index) =>
515
+ !Number.isFinite(value) ||
516
+ Math.abs(value) <= Math.max(perspectiveEpsilon * denominatorScale, cornerDenominatorErrors[index]) ||
517
+ Math.sign(value) !== denominatorSign);
518
+ if (!Number.isFinite(normalizationScale) || normalizationScale === 0 || !Number.isFinite(denominatorScale) ||
519
+ denominatorScale === 0 || hasInvalidDenominator) {
520
+ return identityPerspectiveTransform;
521
+ }
522
+
523
+ // The denominator is homogeneous, so normalize by its largest coefficient
524
+ // instead of assuming that its constant coefficient is nonzero.
525
+ const transform = inverseDenominator.map(value => value / normalizationScale) as RasterPerspectiveTransform;
526
+ return transform.every(Number.isFinite) ? transform : identityPerspectiveTransform;
527
+ }
528
+
529
+ function isParallelogram(cornerCoords: Point[]) {
530
+ const [tl, tr, br, bl] = cornerCoords;
531
+ return Math.abs(tl.x + br.x - tr.x - bl.x) < perspectiveEpsilon &&
532
+ Math.abs(tl.y + br.y - tr.y - bl.y) < perspectiveEpsilon;
533
+ }
@@ -3,6 +3,7 @@ import {fakeServer, type FakeServer} from 'nise';
3
3
  import {RasterDEMTileSource} from './raster_dem_tile_source.ts';
4
4
  import {OverscaledTileID} from '../tile/tile_id.ts';
5
5
  import {RequestManager} from '../util/request_manager.ts';
6
+ import {ImageRequest} from '../util/image_request.ts';
6
7
  import {type Tile} from '../tile/tile.ts';
7
8
  import {getMockDispatcher} from '../util/test/util.ts';
8
9
  import {sleep, waitForEvent, waitForMetadataEvent} from '../util/test/util.ts';
@@ -33,6 +34,7 @@ describe('RasterDEMTileSource', () => {
33
34
 
34
35
  afterEach(() => {
35
36
  server.restore();
37
+ vi.restoreAllMocks();
36
38
  });
37
39
 
38
40
  test('transforms request for TileJSON URL', () => {
@@ -74,7 +76,7 @@ describe('RasterDEMTileSource', () => {
74
76
  expect(server.requests[0].requestHeaders.Authorization).toBe('Bearer token');
75
77
  });
76
78
 
77
- test('transforms tile urls before requesting', async () => {
79
+ test('transforms tile urls and requests them without color space conversion', async () => {
78
80
  server.respondWith('/source.json', JSON.stringify({
79
81
  minzoom: 0,
80
82
  maxzoom: 22,
@@ -84,6 +86,8 @@ describe('RasterDEMTileSource', () => {
84
86
  }));
85
87
  const source = createSource({url: '/source.json'});
86
88
  const transformSpy = vi.spyOn(source.map._requestManager, 'transformRequest');
89
+ const image = await createImageBitmap(new ImageData(16, 16));
90
+ const getImageSpy = vi.spyOn(ImageRequest, 'getImage').mockResolvedValue({data: image});
87
91
  const promise = waitForMetadataEvent(source);
88
92
  await sleep(0);
89
93
  server.respond();
@@ -92,13 +96,15 @@ describe('RasterDEMTileSource', () => {
92
96
  tileID: new OverscaledTileID(10, 0, 10, 5, 5),
93
97
  state: 'loading',
94
98
  loadVectorData () {},
95
- setExpiryData() {}
99
+ setExpiryData() {},
100
+ actor: 1
96
101
  } as any as Tile;
97
- source.loadTile(tile);
102
+ await source.loadTile(tile);
98
103
 
99
104
  expect(transformSpy).toHaveBeenCalledTimes(1);
100
105
  expect(transformSpy.mock.calls[0][0]).toBe('http://example.com/10/5/5.png');
101
106
  expect(transformSpy.mock.calls[0][1]).toBe('Tile');
107
+ expect(getImageSpy.mock.calls[0][3]).toEqual({colorSpaceConversion: 'none'});
102
108
  });
103
109
 
104
110
  test('can asynchronously transform tile request', async () => {
@@ -311,6 +317,34 @@ describe('RasterDEMTileSource', () => {
311
317
  expect(expiryDataSpy).toHaveBeenCalledTimes(1);
312
318
  });
313
319
 
320
+ test('does not request a tile that was aborted while its request was being transformed', async () => {
321
+ let releaseTransform: (params: {url: string}) => void;
322
+ const source = createSource({
323
+ tiles: ['http://example.com/{z}/{x}/{y}.png']
324
+ }, (url, type) => {
325
+ if (type !== 'Tile') return {url};
326
+ return new Promise<{url: string}>((resolve) => {
327
+ releaseTransform = resolve;
328
+ });
329
+ });
330
+ source.tiles = ['http://example.com/{z}/{x}/{y}.png'];
331
+
332
+ const tile = {
333
+ tileID: new OverscaledTileID(10, 0, 10, 5, 5),
334
+ state: 'loading',
335
+ setExpiryData() {}
336
+ } as any as Tile;
337
+
338
+ const loadPromise = source.loadTile(tile);
339
+ tile.aborted = true;
340
+ await source.abortTile(tile);
341
+ releaseTransform({url: 'http://example.com/10/5/5.png'});
342
+ await expect(loadPromise).resolves.toBeUndefined();
343
+
344
+ expect(server.requests).toHaveLength(0);
345
+ expect(tile.state).toBe('unloaded');
346
+ });
347
+
314
348
  test('does not throw when tile is aborted', async () => {
315
349
  server.respondWith('/source.json', JSON.stringify({
316
350
  minzoom: 0,