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

visgl / deck.gl / 16278925592

14 Jul 2025 10:06PM UTC coverage: 91.679% (+0.001%) from 91.678%
16278925592

push

github

web-flow
fix(mapbox): Fix view selection logic in getViewport (#9688)

* fix(mapbox): Fix view selection logic in getViewport

Updates getViewport to select the 'mapbox' view from deck.props.views if available, falling back to getDefaultView.

* first element

* Update deck-utils.ts

Co-authored-by: Ib Green <7025232+ibgreen@users.noreply.github.com>

* Refactor mapbox view ID to constant

Introduced MAPBOX_VIEW_ID constant to replace hardcoded 'mapbox' strings for view IDs

* Use find

---------

Co-authored-by: Ib Green <7025232+ibgreen@users.noreply.github.com>

6767 of 7435 branches covered (91.02%)

Branch coverage included in aggregate %.

9 of 10 new or added lines in 1 file covered. (90.0%)

55847 of 60862 relevant lines covered (91.76%)

14490.03 hits per line

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

80.69
/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 {Map} from './types';
1✔
10

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

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

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

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

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

10✔
41
  // Only initialize certain props once per context
10✔
42
  const customRender = deck?.props._customRender;
22✔
43
  const onLoad = deck?.props.onLoad;
22✔
44

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

22✔
59
  let deckInstance: Deck;
22✔
60

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

10✔
83
  if (deck) {
22✔
84
    deckInstance = deck;
9✔
85
    deck.setProps(deckProps);
9✔
86
    (deck.userData as UserData).isExternal = true;
9✔
87
  } else {
22✔
88
    deckInstance = new Deck(deckProps);
1✔
89
    map.on('remove', () => {
1✔
90
      removeDeckInstance(map);
1✔
91
    });
1✔
92
  }
1✔
93

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

10✔
101
  return deckInstance;
10✔
102
}
10✔
103

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

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

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

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

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

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

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

8✔
173
  if (!deck.isInitialized) {
8✔
174
    return;
1✔
175
  }
1✔
176

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

1✔
187
function getProjection(map: Map): 'mercator' | 'globe' {
29✔
188
  const projection = map.getProjection?.();
29!
189
  const type =
29✔
190
    // maplibre projection spec
29✔
191
    projection?.type ||
29!
192
    // mapbox projection spec
29✔
193
    projection?.name;
29!
194
  if (type === 'globe') {
29!
195
    return 'globe';
×
196
  }
×
197
  if (type && type !== 'mercator') {
29!
198
    throw new Error('Unsupported projection');
×
199
  }
×
200
  return 'mercator';
29✔
201
}
29✔
202

1✔
203
export function getDefaultView(map: Map): GlobeView | MapView {
1✔
204
  if (getProjection(map) === 'globe') {
13!
NEW
205
    return new GlobeView({id: MAPBOX_VIEW_ID});
×
206
  }
×
207
  return new MapView({id: MAPBOX_VIEW_ID});
13✔
208
}
13✔
209

1✔
210
export function getViewState(map: Map): MapViewState & {
1✔
211
  repeat: boolean;
18✔
212
  padding: {
18✔
213
    left: number;
18✔
214
    right: number;
18✔
215
    top: number;
18✔
216
    bottom: number;
18✔
217
  };
18✔
218
} {
18✔
219
  const {lng, lat} = map.getCenter();
18✔
220

18✔
221
  const viewState: MapViewState & {
18✔
222
    repeat: boolean;
18✔
223
    padding: {
18✔
224
      left: number;
18✔
225
      right: number;
18✔
226
      top: number;
18✔
227
      bottom: number;
18✔
228
    };
18✔
229
  } = {
18✔
230
    // Longitude returned by getCenter can be outside of [-180, 180] when zooming near the anti meridian
18✔
231
    // https://github.com/visgl/deck.gl/issues/6894
18✔
232
    longitude: ((lng + 540) % 360) - 180,
18✔
233
    latitude: lat,
18✔
234
    zoom: map.getZoom(),
18✔
235
    bearing: map.getBearing(),
18✔
236
    pitch: map.getPitch(),
18✔
237
    padding: map.getPadding(),
18✔
238
    repeat: map.getRenderWorldCopies()
18✔
239
  };
18✔
240

18✔
241
  if (map.getTerrain?.()) {
18!
242
    // When the base map has terrain, we need to target the camera at the terrain surface
×
243
    centerCameraOnTerrain(map, viewState);
×
244
  }
×
245

18✔
246
  return viewState;
18✔
247
}
18✔
248

1✔
249
function centerCameraOnTerrain(map: Map, viewState: MapViewState) {
×
250
  if (map.getFreeCameraOptions) {
×
251
    // mapbox-gl v2
×
252
    const {position} = map.getFreeCameraOptions();
×
253
    if (!position || position.z === undefined) {
×
254
      return;
×
255
    }
×
256

×
257
    // @ts-ignore transform is not typed
×
258
    const height = map.transform.height;
×
259
    const {longitude, latitude, pitch} = viewState;
×
260

×
261
    // Convert mapbox mercator coordinate to deck common space
×
262
    const cameraX = position.x * TILE_SIZE;
×
263
    const cameraY = (1 - position.y) * TILE_SIZE;
×
264
    const cameraZ = position.z * TILE_SIZE;
×
265

×
266
    // Mapbox manipulates zoom in terrain mode, see discussion here: https://github.com/mapbox/mapbox-gl-js/issues/12040
×
267
    const center = lngLatToWorld([longitude, latitude]);
×
268
    const dx = cameraX - center[0];
×
269
    const dy = cameraY - center[1];
×
270
    const cameraToCenterDistanceGround = Math.sqrt(dx * dx + dy * dy);
×
271

×
272
    const pitchRadians = pitch! * DEGREES_TO_RADIANS;
×
273
    const altitudePixels = 1.5 * height;
×
274
    const scale =
×
275
      pitchRadians < 0.001
×
276
        ? // Pitch angle too small to deduce the look at point, assume elevation is 0
×
277
          (altitudePixels * Math.cos(pitchRadians)) / cameraZ
×
278
        : (altitudePixels * Math.sin(pitchRadians)) / cameraToCenterDistanceGround;
×
279
    viewState.zoom = Math.log2(scale);
×
280

×
281
    const cameraZFromSurface = (altitudePixels * Math.cos(pitchRadians)) / scale;
×
282
    const surfaceElevation = cameraZ - cameraZFromSurface;
×
283
    viewState.position = [0, 0, surfaceElevation / unitsPerMeter(latitude)];
×
284
  }
×
285
  // @ts-ignore transform is not typed
×
286
  else if (typeof map.transform.elevation === 'number') {
×
287
    // maplibre-gl
×
288
    // @ts-ignore transform is not typed
×
289
    viewState.position = [0, 0, map.transform.elevation];
×
290
  }
×
291
}
×
292

1✔
293
// Since maplibre-gl@5
1✔
294
// https://github.com/maplibre/maplibre-gl-js/blob/main/src/style/style_layer/custom_style_layer.ts
1✔
295
type MaplibreRenderParameters = {
1✔
296
  farZ: number;
1✔
297
  nearZ: number;
1✔
298
  fov: number;
1✔
299
  modelViewProjectionMatrix: number[];
1✔
300
  projectionMatrix: number[];
1✔
301
};
1✔
302

1✔
303
function getViewport(deck: Deck, map: Map, renderParameters?: unknown): Viewport {
8✔
304
  const viewState = getViewState(map);
8✔
305
  const {views} = deck.props;
8✔
306
  const view =
8✔
307
    (views && flatten(views).find((v: {id: string}) => v.id === MAPBOX_VIEW_ID)) ||
8✔
308
    getDefaultView(map);
1✔
309

8✔
310
  if (renderParameters) {
8!
311
    // Called from MapboxLayer.render
×
312
    // Magic number, matches mapbox-gl@>=1.3.0's projection matrix
×
313
    view.props.nearZMultiplier = 0.2;
×
314
  }
×
315

8✔
316
  // Get the base map near/far plane
8✔
317
  // renderParameters is maplibre API but not mapbox
8✔
318
  // Transform is not an official API, properties could be undefined for older versions
8✔
319
  const nearZ = (renderParameters as MaplibreRenderParameters)?.nearZ ?? map.transform._nearZ;
8!
320
  const farZ = (renderParameters as MaplibreRenderParameters)?.farZ ?? map.transform._farZ;
8!
321
  if (Number.isFinite(nearZ)) {
8!
322
    viewState.nearZ = nearZ / map.transform.height;
×
323
    viewState.farZ = farZ / map.transform.height;
×
324
  }
×
325
  // Otherwise fallback to default calculation using nearZMultiplier/farZMultiplier
8✔
326

8✔
327
  return view.makeViewport({
8✔
328
    width: deck.width,
8✔
329
    height: deck.height,
8✔
330
    viewState
8✔
331
  }) as Viewport;
8✔
332
}
8✔
333

1✔
334
function afterRender(deck: Deck, map: Map): void {
10✔
335
  const {mapboxLayers, isExternal} = deck.userData as UserData;
10✔
336

10✔
337
  if (isExternal) {
10✔
338
    // Draw non-Mapbox layers
9✔
339
    const mapboxLayerIds = Array.from(mapboxLayers, layer => layer.id);
9✔
340
    const deckLayers = flatten(deck.props.layers, Boolean) as Layer[];
9✔
341
    const hasNonMapboxLayers = deckLayers.some(
9✔
342
      layer => layer && !mapboxLayerIds.includes(layer.id)
9✔
343
    );
9✔
344
    let viewports = deck.getViewports();
9✔
345
    const mapboxViewportIdx = viewports.findIndex(vp => vp.id === MAPBOX_VIEW_ID);
9✔
346
    const hasNonMapboxViews = viewports.length > 1 || mapboxViewportIdx < 0;
9✔
347

9✔
348
    if (hasNonMapboxLayers || hasNonMapboxViews) {
9✔
349
      if (mapboxViewportIdx >= 0) {
2✔
350
        viewports = viewports.slice();
1✔
351
        viewports[mapboxViewportIdx] = getViewport(deck, map);
1✔
352
      }
1✔
353

2✔
354
      deck._drawLayers('mapbox-repaint', {
2✔
355
        viewports,
2✔
356
        layerFilter: params =>
2✔
357
          (!deck.props.layerFilter || deck.props.layerFilter(params)) &&
5✔
358
          (params.viewport.id !== MAPBOX_VIEW_ID || !mapboxLayerIds.includes(params.layer.id)),
3✔
359
        clearCanvas: false
2✔
360
      });
2✔
361
    }
2✔
362
  }
9✔
363

10✔
364
  // End of render cycle, clear generated viewport
10✔
365
  (deck.userData as UserData).currentViewport = null;
10✔
366
}
10✔
367

1✔
368
function onMapMove(deck: Deck, map: Map): void {
×
369
  deck.setProps({
×
370
    viewState: getViewState(map)
×
371
  });
×
372
  // Camera changed, will trigger a map repaint right after this
×
373
  // Clear any change flag triggered by setting viewState so that deck does not request
×
374
  // a second repaint
×
375
  deck.needsRedraw({clearRedrawFlags: true});
×
376
}
×
377

1✔
378
function updateLayers(deck: Deck): void {
40✔
379
  if ((deck.userData as UserData).isExternal) {
40✔
380
    return;
36✔
381
  }
36✔
382

4✔
383
  const layers: Layer[] = [];
4✔
384
  (deck.userData as UserData).mapboxLayers.forEach(deckLayer => {
4✔
385
    const LayerType = deckLayer.props.type;
3✔
386
    const layer = new LayerType(deckLayer.props);
3✔
387
    layers.push(layer);
3✔
388
  });
4✔
389
  deck.setProps({layers});
4✔
390
}
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