• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

visgl / deck.gl / 21138408902

19 Jan 2026 12:59PM UTC coverage: 91.116% (+0.001%) from 91.115%
21138408902

Pull #9944

github

web-flow
Merge 8ae6f540a into 13ac1a84d
Pull Request #9944: fix(mapbox): In interleaved, grouped mode don't render layers twice

6882 of 7558 branches covered (91.06%)

Branch coverage included in aggregate %.

3 of 3 new or added lines in 1 file covered. (100.0%)

35 existing lines in 2 files now uncovered.

56872 of 62412 relevant lines covered (91.12%)

14377.96 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

82.79
/modules/mapbox/src/deck-utils.ts
1
// deck.gl
1✔
2
// SPDX-License-Identifier: MIT
1✔
3
// Copyright (c) vis.gl contributors
1✔
4

1✔
5
import {Deck, MapView, _GlobeView as GlobeView, _flatten as flatten} from '@deck.gl/core';
1✔
6
import type {Viewport, MapViewState, Layer} from '@deck.gl/core';
1✔
7
import type {Parameters} from '@luma.gl/core';
1✔
8
import type MapboxLayer from './mapbox-layer';
1✔
9
import type MapboxLayerGroup from './mapbox-layer-group';
1✔
10
import type {LayerOverlayProps, Map} from './types';
1✔
11

1✔
12
import {lngLatToWorld, unitsPerMeter} from '@math.gl/web-mercator';
1✔
13

1✔
14
const MAPBOX_VIEW_ID = 'mapbox';
1✔
15

1✔
16
export type UserData = {
1✔
17
  isExternal: boolean;
1✔
18
  interleaved: boolean;
1✔
19
  currentViewport?: Viewport | null;
1✔
20
  mapboxLayers: Set<MapboxLayer<any>>;
1✔
21
  // mapboxVersion: {minor: number; major: number};
1✔
22
};
1✔
23

1✔
24
// Mercator constants
1✔
25
const TILE_SIZE = 512;
1✔
26
const DEGREES_TO_RADIANS = Math.PI / 180;
1✔
27

1✔
28
// Create an interleaved deck instance.
1✔
29
export function getDeckInstance({
1✔
30
  map,
49✔
31
  gl,
49✔
32
  deck
49✔
33
}: {
49✔
34
  map: Map & {__deck?: Deck<any> | null};
49✔
35
  gl: WebGL2RenderingContext;
49✔
36
  deck?: Deck<any>;
49✔
37
}): Deck<any> {
49✔
38
  // Only create one deck instance per context
49✔
39
  if (map.__deck) {
49✔
40
    return map.__deck;
28✔
41
  }
28✔
42

21✔
43
  // Only initialize certain props once per context
21✔
44
  const customRender = deck?.props._customRender;
49✔
45
  const onLoad = deck?.props.onLoad;
49✔
46

49✔
47
  const deckProps = {
49✔
48
    ...deck?.props,
49✔
49
    _customRender: () => {
49✔
50
      map.triggerRepaint();
21✔
51
      // customRender may be subscribed by DeckGL React component to update child props
21✔
52
      // make sure it is still called
21✔
53
      // Hack - do not pass a redraw reason here to prevent the React component from clearing the context
21✔
54
      // Rerender will be triggered by MapboxLayer's render()
21✔
55
      customRender?.('');
21!
56
    }
21✔
57
  };
49✔
58
  deckProps.parameters = {...getDefaultParameters(map, true), ...deckProps.parameters};
49✔
59
  deckProps.views ||= getDefaultView(map);
49✔
60

49✔
61
  let deckInstance: Deck;
49✔
62

49✔
63
  if (!deck || deck.props.gl === gl) {
49✔
64
    // If deck isn't defined (Internal MapboxLayer use case),
17✔
65
    // or if deck is defined and is using the WebGLContext created by mapbox (MapboxOverlay and External MapboxLayer use case),
17✔
66
    // block deck from setting the canvas size, and use the map's viewState to drive deck.
17✔
67
    // Otherwise, we use deck's viewState to drive the map.
17✔
68
    Object.assign(deckProps, {
17✔
69
      gl,
17✔
70
      width: null,
17✔
71
      height: null,
17✔
72
      touchAction: 'unset',
17✔
73
      viewState: getViewState(map)
17✔
74
    });
17✔
75
    if (deck?.isInitialized) {
17!
UNCOV
76
      watchMapMove(deck, map);
×
77
    } else {
17✔
78
      deckProps.onLoad = () => {
17✔
79
        onLoad?.();
14✔
80
        watchMapMove(deckInstance, map);
14✔
81
      };
14✔
82
    }
17✔
83
  }
17✔
84

21✔
85
  if (deck) {
49✔
86
    deckInstance = deck;
14✔
87
    deck.setProps(deckProps);
14✔
88
    (deck.userData as UserData).isExternal = true;
14✔
89
  } else {
49✔
90
    deckInstance = new Deck(deckProps);
7✔
91
    map.on('remove', () => {
7✔
92
      removeDeckInstance(map);
1✔
93
    });
7✔
94
  }
7✔
95

21✔
96
  (deckInstance.userData as UserData).mapboxLayers = new Set();
21✔
97
  // (deckInstance.userData as UserData).mapboxVersion = getMapboxVersion(map);
21✔
98
  map.__deck = deckInstance;
21✔
99
  map.on('render', () => {
21✔
100
    if (deckInstance.isInitialized) afterRender(deckInstance, map);
25✔
101
  });
21✔
102

21✔
103
  return deckInstance;
21✔
104
}
21✔
105

1✔
106
function watchMapMove(deck: Deck, map: Map & {__deck?: Deck | null}) {
14✔
107
  const _handleMapMove = () => {
14✔
108
    if (deck.isInitialized) {
1!
109
      // call view state methods
×
UNCOV
110
      onMapMove(deck, map);
×
111
    } else {
1✔
112
      // deregister itself when deck is finalized
1✔
113
      map.off('move', _handleMapMove);
1✔
114
    }
1✔
115
  };
1✔
116
  map.on('move', _handleMapMove);
14✔
117
}
14✔
118

1✔
119
export function removeDeckInstance(map: Map & {__deck?: Deck | null}) {
1✔
120
  map.__deck?.finalize();
11✔
121
  map.__deck = null;
11✔
122
}
11✔
123

1✔
124
export function getDefaultParameters(map: Map, interleaved: boolean): Parameters {
1✔
125
  const result: Parameters = interleaved
39✔
126
    ? {
35✔
127
        depthWriteEnabled: true,
35✔
128
        depthCompare: 'less-equal',
35✔
129
        depthBias: 0,
35✔
130
        blend: true,
35✔
131
        blendColorSrcFactor: 'src-alpha',
35✔
132
        blendColorDstFactor: 'one-minus-src-alpha',
35✔
133
        blendAlphaSrcFactor: 'one',
35✔
134
        blendAlphaDstFactor: 'one-minus-src-alpha',
35✔
135
        blendColorOperation: 'add',
35✔
136
        blendAlphaOperation: 'add'
35✔
137
      }
35✔
138
    : {};
4✔
139
  if (getProjection(map) === 'globe') {
39✔
140
    result.cullMode = 'back';
2✔
141
  }
2✔
142
  return result;
39✔
143
}
39✔
144

1✔
145
export function addLayer(deck: Deck, layer: MapboxLayer<any>): void {
1✔
146
  (deck.userData as UserData).mapboxLayers.add(layer);
20✔
147
  updateLayers(deck);
20✔
148
}
20✔
149

1✔
150
export function removeLayer(deck: Deck, layer: MapboxLayer<any>): void {
1✔
151
  (deck.userData as UserData).mapboxLayers.delete(layer);
13✔
152
  updateLayers(deck);
13✔
153
}
13✔
154

1✔
155
export function updateLayer(deck: Deck, layer: MapboxLayer<any>): void {
1✔
156
  updateLayers(deck);
13✔
157
}
13✔
158

1✔
159
export function drawLayer(
1✔
160
  deck: Deck,
11✔
161
  map: Map,
11✔
162
  layer: MapboxLayer<any>,
11✔
163
  renderParameters: any
11✔
164
): void {
11✔
165
  let {currentViewport} = deck.userData as UserData;
11✔
166
  let clearStack: boolean = false;
11✔
167
  if (!currentViewport) {
11✔
168
    // This is the first layer drawn in this render cycle.
10✔
169
    // Generate viewport from the current map state.
10✔
170
    currentViewport = getViewport(deck, map, renderParameters);
10✔
171
    (deck.userData as UserData).currentViewport = currentViewport;
10✔
172
    clearStack = true;
10✔
173
  }
10✔
174

11✔
175
  if (!deck.isInitialized) {
11✔
176
    return;
1✔
177
  }
1✔
178

10✔
179
  deck._drawLayers('mapbox-repaint', {
10✔
180
    viewports: [currentViewport],
10✔
181
    layerFilter: params =>
10✔
182
      (!deck.props.layerFilter || deck.props.layerFilter(params)) &&
13✔
183
      (layer.id === params.layer.id || params.layer.props.operation.includes('terrain')),
10✔
184
    clearStack,
10✔
185
    clearCanvas: false
10✔
186
  });
10✔
187
}
10✔
188

1✔
189
export function drawLayerGroup(
1✔
190
  deck: Deck,
15✔
191
  map: Map,
15✔
192
  group: MapboxLayerGroup,
15✔
193
  renderParameters: any
15✔
194
): void {
15✔
195
  let {currentViewport} = deck.userData as UserData;
15✔
196
  let clearStack: boolean = false;
15✔
197
  if (!currentViewport) {
15✔
198
    // This is the first layer drawn in this render cycle.
9✔
199
    // Generate viewport from the current map state.
9✔
200
    currentViewport = getViewport(deck, map, renderParameters);
9✔
201
    (deck.userData as UserData).currentViewport = currentViewport;
9✔
202
    clearStack = true;
9✔
203
  }
9✔
204

15✔
205
  if (!deck.isInitialized) {
15!
206
    return;
×
UNCOV
207
  }
×
208

15✔
209
  deck._drawLayers('mapbox-repaint', {
15✔
210
    viewports: [currentViewport],
15✔
211
    layerFilter: params => {
15✔
212
      if (deck.props.layerFilter && !deck.props.layerFilter(params)) {
4!
213
        return false;
×
UNCOV
214
      }
×
215

4✔
216
      const layer = params.layer as Layer<LayerOverlayProps>;
4✔
217
      if (layer.props.beforeId === group.beforeId && layer.props.slot === group.slot) {
4✔
218
        return true;
4✔
219
      }
4!
UNCOV
220
      return false;
×
221
    },
4✔
222
    clearStack,
15✔
223
    clearCanvas: false
15✔
224
  });
15✔
225
}
15✔
226

1✔
227
export function getProjection(map: Map): 'mercator' | 'globe' {
1✔
228
  const projection = map.getProjection?.();
79✔
229
  const type =
79✔
230
    // maplibre projection spec
79✔
231
    projection?.type ||
79!
232
    // mapbox projection spec
×
UNCOV
233
    projection?.name;
×
234
  if (type === 'globe') {
79✔
235
    return 'globe';
5✔
236
  }
5✔
237
  if (type && type !== 'mercator') {
79!
238
    throw new Error('Unsupported projection');
×
UNCOV
239
  }
✔
240
  return 'mercator';
74✔
241
}
74✔
242

1✔
243
export function getDefaultView(map: Map): GlobeView | MapView {
1✔
244
  if (getProjection(map) === 'globe') {
32✔
245
    return new GlobeView({id: MAPBOX_VIEW_ID});
2✔
246
  }
2✔
247
  return new MapView({id: MAPBOX_VIEW_ID});
30✔
248
}
30✔
249

1✔
250
export function getViewState(map: Map): MapViewState & {
1✔
251
  repeat: boolean;
41✔
252
  padding: {
41✔
253
    left: number;
41✔
254
    right: number;
41✔
255
    top: number;
41✔
256
    bottom: number;
41✔
257
  };
41✔
258
} {
41✔
259
  const {lng, lat} = map.getCenter();
41✔
260

41✔
261
  const viewState: MapViewState & {
41✔
262
    repeat: boolean;
41✔
263
    padding: {
41✔
264
      left: number;
41✔
265
      right: number;
41✔
266
      top: number;
41✔
267
      bottom: number;
41✔
268
    };
41✔
269
  } = {
41✔
270
    // Longitude returned by getCenter can be outside of [-180, 180] when zooming near the anti meridian
41✔
271
    // https://github.com/visgl/deck.gl/issues/6894
41✔
272
    longitude: ((lng + 540) % 360) - 180,
41✔
273
    latitude: lat,
41✔
274
    zoom: map.getZoom(),
41✔
275
    bearing: map.getBearing(),
41✔
276
    pitch: map.getPitch(),
41✔
277
    padding: map.getPadding(),
41✔
278
    repeat: map.getRenderWorldCopies()
41✔
279
  };
41✔
280

41✔
281
  if (map.getTerrain?.()) {
41!
282
    // When the base map has terrain, we need to target the camera at the terrain surface
×
283
    centerCameraOnTerrain(map, viewState);
×
UNCOV
284
  }
×
285

41✔
286
  return viewState;
41✔
287
}
41✔
288

1✔
289
function centerCameraOnTerrain(map: Map, viewState: MapViewState) {
×
290
  if (map.getFreeCameraOptions) {
×
291
    // mapbox-gl v2
×
292
    const {position} = map.getFreeCameraOptions();
×
293
    if (!position || position.z === undefined) {
×
294
      return;
×
295
    }
×
296

×
297
    // @ts-ignore transform is not typed
×
298
    const height = map.transform.height;
×
299
    const {longitude, latitude, pitch} = viewState;
×
300

×
301
    // Convert mapbox mercator coordinate to deck common space
×
302
    const cameraX = position.x * TILE_SIZE;
×
303
    const cameraY = (1 - position.y) * TILE_SIZE;
×
304
    const cameraZ = position.z * TILE_SIZE;
×
305

×
306
    // Mapbox manipulates zoom in terrain mode, see discussion here: https://github.com/mapbox/mapbox-gl-js/issues/12040
×
307
    const center = lngLatToWorld([longitude, latitude]);
×
308
    const dx = cameraX - center[0];
×
309
    const dy = cameraY - center[1];
×
310
    const cameraToCenterDistanceGround = Math.sqrt(dx * dx + dy * dy);
×
311

×
312
    const pitchRadians = pitch! * DEGREES_TO_RADIANS;
×
313
    const altitudePixels = 1.5 * height;
×
314
    const scale =
×
315
      pitchRadians < 0.001
×
316
        ? // Pitch angle too small to deduce the look at point, assume elevation is 0
×
317
          (altitudePixels * Math.cos(pitchRadians)) / cameraZ
×
318
        : (altitudePixels * Math.sin(pitchRadians)) / cameraToCenterDistanceGround;
×
319
    viewState.zoom = Math.log2(scale);
×
320

×
321
    const cameraZFromSurface = (altitudePixels * Math.cos(pitchRadians)) / scale;
×
322
    const surfaceElevation = cameraZ - cameraZFromSurface;
×
323
    viewState.position = [0, 0, surfaceElevation / unitsPerMeter(latitude)];
×
324
  }
×
325
  // @ts-ignore transform is not typed
×
326
  else if (typeof map.transform.elevation === 'number') {
×
327
    // maplibre-gl
×
328
    // @ts-ignore transform is not typed
×
329
    viewState.position = [0, 0, map.transform.elevation];
×
330
  }
×
UNCOV
331
}
×
332

1✔
333
// Since maplibre-gl@5
1✔
334
// https://github.com/maplibre/maplibre-gl-js/blob/main/src/style/style_layer/custom_style_layer.ts
1✔
335
type MaplibreRenderParameters = {
1✔
336
  farZ: number;
1✔
337
  nearZ: number;
1✔
338
  fov: number;
1✔
339
  modelViewProjectionMatrix: number[];
1✔
340
  projectionMatrix: number[];
1✔
341
};
1✔
342

1✔
343
function getViewport(deck: Deck, map: Map, renderParameters?: unknown): Viewport {
20✔
344
  const viewState = getViewState(map);
20✔
345
  const {views} = deck.props;
20✔
346
  const view =
20✔
347
    (views && flatten(views).find((v: {id: string}) => v.id === MAPBOX_VIEW_ID)) ||
20✔
348
    getDefaultView(map);
1✔
349

20✔
350
  if (renderParameters) {
20!
351
    // Called from MapboxLayer.render
×
352
    // Magic number, matches mapbox-gl@>=1.3.0's projection matrix
×
353
    view.props.nearZMultiplier = 0.2;
×
UNCOV
354
  }
×
355

20✔
356
  // Get the base map near/far plane
20✔
357
  // renderParameters is maplibre API but not mapbox
20✔
358
  // Transform is not an official API, properties could be undefined for older versions
20✔
359
  const nearZ = (renderParameters as MaplibreRenderParameters)?.nearZ ?? map.transform._nearZ;
20!
360
  const farZ = (renderParameters as MaplibreRenderParameters)?.farZ ?? map.transform._farZ;
20!
361
  if (Number.isFinite(nearZ)) {
20!
362
    viewState.nearZ = nearZ / map.transform.height;
×
363
    viewState.farZ = farZ / map.transform.height;
×
UNCOV
364
  }
×
365
  // Otherwise fallback to default calculation using nearZMultiplier/farZMultiplier
20✔
366

20✔
367
  return view.makeViewport({
20✔
368
    width: deck.width,
20✔
369
    height: deck.height,
20✔
370
    viewState
20✔
371
  }) as Viewport;
20✔
372
}
20✔
373

1✔
374
function afterRender(deck: Deck, map: Map): void {
23✔
375
  const {mapboxLayers, isExternal, interleaved} = deck.userData as UserData;
23✔
376

23✔
377
  if (isExternal) {
23✔
378
    // Draw non-Mapbox layers
16✔
379
    const mapboxLayerIds = Array.from(mapboxLayers, layer => layer.id);
16✔
380
    const deckLayers = flatten(deck.props.layers, Boolean) as Layer[];
16✔
381
    const hasNonMapboxLayers =
16✔
382
      // In interleaved mode _all_ layers are rendered through custom mapbox layer API (either one by one or in groups,
16✔
383
      // so assume we never have "non-mapbox-managed" layers.
16✔
384
      !interleaved && deckLayers.some(layer => layer && !mapboxLayerIds.includes(layer.id));
16✔
385
    let viewports = deck.getViewports();
16✔
386
    const mapboxViewportIdx = viewports.findIndex(vp => vp.id === MAPBOX_VIEW_ID);
16✔
387
    const hasNonMapboxViews = viewports.length > 1 || mapboxViewportIdx < 0;
16✔
388

16✔
389
    if (hasNonMapboxLayers || hasNonMapboxViews) {
16✔
390
      if (mapboxViewportIdx >= 0) {
2✔
391
        viewports = viewports.slice();
1✔
392
        viewports[mapboxViewportIdx] = getViewport(deck, map);
1✔
393
      }
1✔
394

2✔
395
      deck._drawLayers('mapbox-repaint', {
2✔
396
        viewports,
2✔
397
        layerFilter: params =>
2✔
398
          (!deck.props.layerFilter || deck.props.layerFilter(params)) &&
5✔
399
          (params.viewport.id !== MAPBOX_VIEW_ID ||
3✔
400
            (hasNonMapboxLayers && !mapboxLayerIds.includes(params.layer.id))),
1✔
401
        clearCanvas: false
2✔
402
      });
2✔
403
    }
2✔
404
  }
16✔
405

23✔
406
  // End of render cycle, clear generated viewport
23✔
407
  (deck.userData as UserData).currentViewport = null;
23✔
408
}
23✔
409

1✔
410
function onMapMove(deck: Deck, map: Map): void {
×
UNCOV
411
  deck.setProps({
×
UNCOV
412
    viewState: getViewState(map)
×
UNCOV
413
  });
×
UNCOV
414
  // Camera changed, will trigger a map repaint right after this
×
UNCOV
415
  // Clear any change flag triggered by setting viewState so that deck does not request
×
UNCOV
416
  // a second repaint
×
UNCOV
417
  deck.needsRedraw({clearRedrawFlags: true});
×
UNCOV
418
}
×
419

1✔
420
function updateLayers(deck: Deck): void {
46✔
421
  if ((deck.userData as UserData).isExternal) {
46✔
422
    return;
42✔
423
  }
42✔
424

4✔
425
  const layers: Layer[] = [];
4✔
426
  (deck.userData as UserData).mapboxLayers.forEach(deckLayer => {
4✔
427
    const LayerType = deckLayer.props.type;
3✔
428
    const layer = new LayerType(deckLayer.props);
3✔
429
    layers.push(layer);
3✔
430
  });
4✔
431
  deck.setProps({layers});
4✔
432
}
4✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc