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

visgl / deck.gl / 22984455925

12 Mar 2026 02:54AM UTC coverage: 91.012% (+0.001%) from 91.011%
22984455925

push

github

web-flow
fix(mapbox): handle null viewport (#10086)

6960 of 7675 branches covered (90.68%)

Branch coverage included in aggregate %.

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

32 existing lines in 1 file now uncovered.

57362 of 62999 relevant lines covered (91.05%)

14244.58 hits per line

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

79.24
/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
type UserData = {
1✔
17
  currentViewport?: Viewport | null;
1✔
18
};
1✔
19

1✔
20
// Mercator constants
1✔
21
const TILE_SIZE = 512;
1✔
22
const DEGREES_TO_RADIANS = Math.PI / 180;
1✔
23

1✔
24
// Create an interleaved deck instance.
1✔
25
export function getDeckInstance({
1✔
26
  map,
16✔
27
  deck
16✔
28
}: {
16✔
29
  map: Map & {__deck?: Deck<any> | null};
16✔
30
  deck: Deck<any>;
16✔
31
}): Deck<any> {
16✔
32
  // Only create one deck instance per context
16✔
33
  if (map.__deck) {
16!
34
    return map.__deck;
×
35
  }
×
36

16✔
37
  // Only initialize certain props once per context
16✔
38
  const customRender = deck.props._customRender;
16✔
39
  const onLoad = deck.props.onLoad;
16✔
40

16✔
41
  const deckProps = {
16✔
42
    ...deck.props,
16✔
43
    _customRender: () => {
16✔
44
      map.triggerRepaint();
15✔
45
      // customRender may be subscribed by DeckGL React component to update child props
15✔
46
      // make sure it is still called
15✔
47
      // Hack - do not pass a redraw reason here to prevent the React component from clearing the context
15✔
48
      // Rerender will be triggered by MapboxLayer's render()
15✔
49
      customRender?.('');
15!
50
    }
15✔
51
  };
16✔
52
  deckProps.views ||= getDefaultView(map);
16✔
53

16✔
54
  // deck is using the WebGLContext created by mapbox,
16✔
55
  // block deck from setting the canvas size, and use the map's viewState to drive deck.
16✔
56
  Object.assign(deckProps, {
16✔
57
    width: null,
16✔
58
    height: null,
16✔
59
    touchAction: 'unset',
16✔
60
    viewState: getViewState(map)
16✔
61
  });
16✔
62
  if (deck.isInitialized) {
16✔
63
    watchMapMove(deck, map);
2✔
64
  } else {
16✔
65
    deckProps.onLoad = () => {
14✔
66
      onLoad?.();
12✔
67
      watchMapMove(deck, map);
12✔
68
    };
12✔
69
  }
14✔
70

16✔
71
  deck.setProps(deckProps);
16✔
72

16✔
73
  map.__deck = deck;
16✔
74
  map.on('render', () => {
16✔
75
    if (deck.isInitialized) afterRender(deck, map);
19✔
76
  });
16✔
77

16✔
78
  return deck;
16✔
79
}
16✔
80

1✔
81
function watchMapMove(deck: Deck, map: Map & {__deck?: Deck | null}) {
14✔
82
  const _handleMapMove = () => {
14✔
83
    if (deck.isInitialized) {
1!
84
      // call view state methods
×
85
      onMapMove(deck, map);
×
86
    } else {
1✔
87
      // deregister itself when deck is finalized
1✔
88
      map.off('move', _handleMapMove);
1✔
89
    }
1✔
90
  };
1✔
91
  map.on('move', _handleMapMove);
14✔
92
}
14✔
93

1✔
94
export function removeDeckInstance(map: Map & {__deck?: Deck | null}) {
1✔
95
  map.__deck?.finalize();
10✔
96
  map.__deck = null;
10✔
97
}
10✔
98

1✔
99
export function getDefaultParameters(map: Map, interleaved: boolean): Parameters {
1✔
100
  const result: Parameters = interleaved
18✔
101
    ? {
14✔
102
        depthWriteEnabled: true,
14✔
103
        depthCompare: 'less-equal',
14✔
104
        depthBias: 0,
14✔
105
        blend: true,
14✔
106
        blendColorSrcFactor: 'src-alpha',
14✔
107
        blendColorDstFactor: 'one-minus-src-alpha',
14✔
108
        blendAlphaSrcFactor: 'one',
14✔
109
        blendAlphaDstFactor: 'one-minus-src-alpha',
14✔
110
        blendColorOperation: 'add',
14✔
111
        blendAlphaOperation: 'add'
14✔
112
      }
14✔
113
    : {};
4✔
114
  if (getProjection(map) === 'globe') {
18✔
115
    result.cullMode = 'back';
1✔
116
  }
1✔
117
  return result;
18✔
118
}
18✔
119

1✔
120
export function drawLayer(
1✔
121
  deck: Deck,
13✔
122
  map: Map,
13✔
123
  layer: MapboxLayer<any>,
13✔
124
  renderParameters: any
13✔
125
): void {
13✔
126
  if (!deck.isInitialized) {
13✔
127
    return;
1✔
128
  }
1✔
129

12✔
130
  let {currentViewport} = deck.userData as UserData;
12✔
131
  let clearStack: boolean = false;
12✔
132
  if (!currentViewport) {
13✔
133
    // This is the first layer drawn in this render cycle.
11✔
134
    // Generate viewport from the current map state.
11✔
135
    currentViewport = getViewport(deck, map, renderParameters);
11✔
136
    (deck.userData as UserData).currentViewport = currentViewport;
11✔
137
    clearStack = true;
11✔
138
  }
11✔
139

12✔
140
  if (!currentViewport) {
13✔
141
    return;
2✔
142
  }
2✔
143

10✔
144
  deck._drawLayers('mapbox-repaint', {
10✔
145
    viewports: [currentViewport],
10✔
146
    layerFilter: params =>
10✔
147
      (!deck.props.layerFilter || deck.props.layerFilter(params)) &&
13✔
148
      (layer.id === params.layer.id || params.layer.props.operation.includes('terrain')),
10✔
149
    clearStack,
10✔
150
    clearCanvas: false
10✔
151
  });
10✔
152
}
10✔
153

1✔
154
export function drawLayerGroup(
1✔
155
  deck: Deck,
3✔
156
  map: Map,
3✔
157
  group: MapboxLayerGroup,
3✔
158
  renderParameters: any
3✔
159
): void {
3✔
160
  if (!deck.isInitialized) {
3!
UNCOV
161
    return;
×
UNCOV
162
  }
×
163

3✔
164
  let {currentViewport} = deck.userData as UserData;
3✔
165
  let clearStack: boolean = false;
3✔
166
  if (!currentViewport) {
3✔
167
    // This is the first layer drawn in this render cycle.
3✔
168
    // Generate viewport from the current map state.
3✔
169
    currentViewport = getViewport(deck, map, renderParameters);
3✔
170
    (deck.userData as UserData).currentViewport = currentViewport;
3✔
171
    clearStack = true;
3✔
172
  }
3✔
173

3✔
174
  if (!currentViewport) {
3!
175
    return;
×
UNCOV
176
  }
×
177

3✔
178
  deck._drawLayers('mapbox-repaint', {
3✔
179
    viewports: [currentViewport],
3✔
180
    layerFilter: params => {
3✔
181
      if (deck.props.layerFilter && !deck.props.layerFilter(params)) {
4!
UNCOV
182
        return false;
×
UNCOV
183
      }
×
184

4✔
185
      const layer = params.layer as Layer<LayerOverlayProps>;
4✔
186
      if (layer.props.beforeId === group.beforeId && layer.props.slot === group.slot) {
4✔
187
        return true;
4✔
188
      }
4!
UNCOV
189
      return false;
×
190
    },
4✔
191
    clearStack,
3✔
192
    clearCanvas: false
3✔
193
  });
3✔
194
}
3✔
195

1✔
196
export function getProjection(map: Map): 'mercator' | 'globe' {
1✔
197
  const projection = map.getProjection?.();
52✔
198
  const type =
52✔
199
    // maplibre projection spec
52✔
200
    projection?.type ||
52!
UNCOV
201
    // mapbox projection spec
×
UNCOV
202
    projection?.name;
×
203
  if (type === 'globe') {
52✔
204
    return 'globe';
4✔
205
  }
4✔
206
  if (type && type !== 'mercator') {
52!
UNCOV
207
    throw new Error('Unsupported projection');
×
UNCOV
208
  }
✔
209
  return 'mercator';
48✔
210
}
48✔
211

1✔
212
export function getDefaultView(map: Map): GlobeView | MapView {
1✔
213
  if (getProjection(map) === 'globe') {
26✔
214
    return new GlobeView({id: MAPBOX_VIEW_ID});
2✔
215
  }
2✔
216
  return new MapView({id: MAPBOX_VIEW_ID});
24✔
217
}
24✔
218

1✔
219
export function getViewState(map: Map): MapViewState & {
1✔
220
  repeat: boolean;
39✔
221
  padding: {
39✔
222
    left: number;
39✔
223
    right: number;
39✔
224
    top: number;
39✔
225
    bottom: number;
39✔
226
  };
39✔
227
} {
39✔
228
  const {lng, lat} = map.getCenter();
39✔
229

39✔
230
  const viewState: MapViewState & {
39✔
231
    repeat: boolean;
39✔
232
    padding: {
39✔
233
      left: number;
39✔
234
      right: number;
39✔
235
      top: number;
39✔
236
      bottom: number;
39✔
237
    };
39✔
238
  } = {
39✔
239
    // Longitude returned by getCenter can be outside of [-180, 180] when zooming near the anti meridian
39✔
240
    // https://github.com/visgl/deck.gl/issues/6894
39✔
241
    longitude: ((lng + 540) % 360) - 180,
39✔
242
    latitude: lat,
39✔
243
    zoom: map.getZoom(),
39✔
244
    bearing: map.getBearing(),
39✔
245
    pitch: map.getPitch(),
39✔
246
    padding: map.getPadding(),
39✔
247
    repeat: map.getRenderWorldCopies()
39✔
248
  };
39✔
249

39✔
250
  if (map.getTerrain?.()) {
39!
251
    // When the base map has terrain, we need to target the camera at the terrain surface
×
252
    centerCameraOnTerrain(map, viewState);
×
253
  }
×
254

39✔
255
  return viewState;
39✔
256
}
39✔
257

1✔
258
function centerCameraOnTerrain(map: Map, viewState: MapViewState) {
×
259
  if (map.getFreeCameraOptions) {
×
260
    // mapbox-gl v2
×
261
    const {position} = map.getFreeCameraOptions();
×
262
    if (!position || position.z === undefined) {
×
263
      return;
×
264
    }
×
265

×
266
    // @ts-ignore transform is not typed
×
267
    const height = map.transform.height;
×
268
    const {longitude, latitude, pitch} = viewState;
×
269

×
270
    // Convert mapbox mercator coordinate to deck common space
×
271
    const cameraX = position.x * TILE_SIZE;
×
272
    const cameraY = (1 - position.y) * TILE_SIZE;
×
273
    const cameraZ = position.z * TILE_SIZE;
×
274

×
275
    // Mapbox manipulates zoom in terrain mode, see discussion here: https://github.com/mapbox/mapbox-gl-js/issues/12040
×
276
    const center = lngLatToWorld([longitude, latitude]);
×
277
    const dx = cameraX - center[0];
×
278
    const dy = cameraY - center[1];
×
279
    const cameraToCenterDistanceGround = Math.sqrt(dx * dx + dy * dy);
×
280

×
281
    const pitchRadians = pitch! * DEGREES_TO_RADIANS;
×
282
    const altitudePixels = 1.5 * height;
×
283
    const scale =
×
284
      pitchRadians < 0.001
×
285
        ? // Pitch angle too small to deduce the look at point, assume elevation is 0
×
286
          (altitudePixels * Math.cos(pitchRadians)) / cameraZ
×
287
        : (altitudePixels * Math.sin(pitchRadians)) / cameraToCenterDistanceGround;
×
288
    viewState.zoom = Math.log2(scale);
×
289

×
290
    const cameraZFromSurface = (altitudePixels * Math.cos(pitchRadians)) / scale;
×
291
    const surfaceElevation = cameraZ - cameraZFromSurface;
×
292
    viewState.position = [0, 0, surfaceElevation / unitsPerMeter(latitude)];
×
UNCOV
293
  }
×
UNCOV
294
  // @ts-ignore transform is not typed
×
UNCOV
295
  else if (typeof map.transform.elevation === 'number') {
×
UNCOV
296
    // maplibre-gl
×
UNCOV
297
    // @ts-ignore transform is not typed
×
UNCOV
298
    viewState.position = [0, 0, map.transform.elevation];
×
UNCOV
299
  }
×
UNCOV
300
}
×
301

1✔
302
// Since maplibre-gl@5
1✔
303
// https://github.com/maplibre/maplibre-gl-js/blob/main/src/style/style_layer/custom_style_layer.ts
1✔
304
type MaplibreRenderParameters = {
1✔
305
  farZ: number;
1✔
306
  nearZ: number;
1✔
307
  fov: number;
1✔
308
  modelViewProjectionMatrix: number[];
1✔
309
  projectionMatrix: number[];
1✔
310
};
1✔
311

1✔
312
function getViewport(deck: Deck, map: Map, renderParameters?: unknown): Viewport | null {
19✔
313
  const viewState = getViewState(map);
19✔
314
  // View is always MapView or GlobeView in this context
19✔
315
  const view = (deck.getView(MAPBOX_VIEW_ID) || getDefaultView(map)) as MapView | GlobeView;
19✔
316

19✔
317
  if (renderParameters) {
19!
UNCOV
318
    // Called from MapboxLayer.render
×
UNCOV
319
    // Magic number, matches mapbox-gl@>=1.3.0's projection matrix
×
UNCOV
320
    view.props.nearZMultiplier = 0.2;
×
321
  }
×
322

19✔
323
  // Get the base map near/far plane
19✔
324
  // renderParameters is maplibre API but not mapbox
19✔
325
  // Transform is not an official API, properties could be undefined for older versions
19✔
326
  const nearZ = (renderParameters as MaplibreRenderParameters)?.nearZ ?? map.transform._nearZ;
19!
327
  const farZ = (renderParameters as MaplibreRenderParameters)?.farZ ?? map.transform._farZ;
19!
328
  if (Number.isFinite(nearZ)) {
19!
UNCOV
329
    viewState.nearZ = nearZ / map.transform.height;
×
UNCOV
330
    viewState.farZ = farZ / map.transform.height;
×
UNCOV
331
  }
×
332
  // Otherwise fallback to default calculation using nearZMultiplier/farZMultiplier
19✔
333

19✔
334
  return view.makeViewport({
19✔
335
    width: deck.width,
19✔
336
    height: deck.height,
19✔
337
    viewState
19✔
338
  });
19✔
339
}
19✔
340

1✔
341
function afterRender(deck: Deck, map: Map): void {
18✔
342
  // Draw non-Mapbox layers (layers that don't have a corresponding MapboxLayer on the map)
18✔
343
  const deckLayers = flatten(deck.props.layers, Boolean) as Layer[];
18✔
344
  const hasNonMapboxLayers = deckLayers.some(layer => layer && !map.getLayer(layer.id));
18✔
345
  let viewports = deck.getViewports();
18✔
346
  const mapboxViewportIdx = viewports.findIndex(vp => vp.id === MAPBOX_VIEW_ID);
18✔
347
  const hasNonMapboxViews = viewports.length > 1 || mapboxViewportIdx < 0;
18✔
348

18✔
349
  if (hasNonMapboxLayers || hasNonMapboxViews) {
18✔
350
    if (mapboxViewportIdx >= 0) {
6✔
351
      viewports = viewports.slice();
5✔
352
      const mapboxViewport = getViewport(deck, map);
5✔
353
      if (mapboxViewport) {
5✔
354
        viewports[mapboxViewportIdx] = mapboxViewport;
4✔
355
      } else {
5✔
356
        viewports.splice(mapboxViewportIdx, 1);
1✔
357
      }
1✔
358
    }
5✔
359

6✔
360
    deck._drawLayers('mapbox-repaint', {
6✔
361
      viewports,
6✔
362
      layerFilter: params =>
6✔
363
        (!deck.props.layerFilter || deck.props.layerFilter(params)) &&
9✔
364
        (params.viewport.id !== MAPBOX_VIEW_ID || !map.getLayer(params.layer.id)),
7✔
365
      clearCanvas: false
6✔
366
    });
6✔
367
  }
6✔
368

18✔
369
  // End of render cycle, clear generated viewport
18✔
370
  (deck.userData as UserData).currentViewport = null;
18✔
371
}
18✔
372

1✔
373
function onMapMove(deck: Deck, map: Map): void {
×
UNCOV
374
  deck.setProps({
×
UNCOV
375
    viewState: getViewState(map)
×
UNCOV
376
  });
×
UNCOV
377
  // Camera changed, will trigger a map repaint right after this
×
UNCOV
378
  // Clear any change flag triggered by setting viewState so that deck does not request
×
UNCOV
379
  // a second repaint
×
UNCOV
380
  deck.needsRedraw({clearRedrawFlags: true});
×
UNCOV
381
}
×
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