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

keplergl / kepler.gl / 23950562977

03 Apr 2026 02:56PM UTC coverage: 60.018% (-1.7%) from 61.699%
23950562977

Pull #3271

github

web-flow
Merge 8677ca559 into bc59e880b
Pull Request #3271: chore: deck.gl 9.2 upgrade & loaders.gl, luma.gl upgrades

6517 of 12945 branches covered (50.34%)

Branch coverage included in aggregate %.

310 of 960 new or added lines in 57 files covered. (32.29%)

122 existing lines in 17 files now uncovered.

13299 of 20072 relevant lines covered (66.26%)

78.7 hits per line

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

10.05
/src/layers/src/tile3d-layer/tile3d-layer.ts
1
// SPDX-License-Identifier: MIT
2
// Copyright contributors to the kepler.gl project
3

4
import {Tile3DLayer as DeckTile3DLayer} from '@deck.gl/geo-layers';
5
import {Tiles3DLoader, CesiumIonLoader} from '@loaders.gl/3d-tiles';
6
import {I3SLoader} from '@loaders.gl/i3s';
7
import {Tileset3D, Tile3D} from '@loaders.gl/tiles';
8

9
import Layer from '../base-layer';
10
import Tile3DLayerIcon from './tile3d-layer-icon';
11
import {FindDefaultLayerPropsReturnValue} from '../layer-utils';
12
import {
13
  LAYER_VIS_CONFIGS,
14
  LAYER_TYPES,
15
  DatasetType,
16
  TILE3D_PROVIDERS,
17
  Tile3DDatasetMetadata
18
} from '@kepler.gl/constants';
19
import {KeplerTable as KeplerDataset, Datasets as KeplerDatasets} from '@kepler.gl/table';
20
import {VisConfigNumber} from '@kepler.gl/types';
21

22
/**
23
 * Custom DeckTile3DLayer that catches exceptions in _loadTileset and
24
 * _updateTileset (which calls selectTiles – an unhandled-promise path
25
 * where errors like "boundingVolume must contain …" escape).
26
 * See: https://github.com/visgl/deck.gl/issues/8755
27
 */
28
// @ts-expect-error Types have separate declarations of a private property '_loadTileset'.
29
class KeplerTile3DLayer extends DeckTile3DLayer {
30
  // @ts-ignore override of private method called by deck.gl internals
31
  private async _loadTileset(tilesetUrl: string) {
NEW
32
    try {
×
33
      // @ts-expect-error _loadTileset is private in DeckTile3DLayer
NEW
34
      await super._loadTileset(tilesetUrl);
×
35
    } catch (error: any) {
NEW
36
      if (error?.message?.includes("reading 'refine'")) {
×
NEW
37
        this.raiseError(new Error('Bad tileset format or invalid API key'), '_loadTileset');
×
38
      } else {
NEW
39
        console.error('Tile3DLayer: tileset load error', error);
×
40
      }
41
    }
42
  }
43

44
  /**
45
   * Override _updateTileset to catch errors from selectTiles(),
46
   * which deck.gl calls with .then() but no .catch().
47
   * Errors from tilesetInitializationPromise (e.g. invalid boundingVolume
48
   * in child tiles) surface here as unhandled promise rejections.
49
   */
50
  // @ts-ignore override of private method
51
  private _updateTileset(viewports: Record<string, any> | null): void {
NEW
52
    if (!viewports) return;
×
NEW
53
    const {tileset3d} = this.state as any;
×
NEW
54
    const {timeline} = this.context;
×
NEW
55
    const viewportsNumber = Object.keys(viewports).length;
×
NEW
56
    if (!timeline || !viewportsNumber || !tileset3d) return;
×
57

NEW
58
    tileset3d
×
59
      .selectTiles(Object.values(viewports))
60
      .then((frameNumber: number) => {
NEW
61
        const tilesetChanged = (this.state as any).frameNumber !== frameNumber;
×
NEW
62
        if (tilesetChanged) {
×
NEW
63
          this.setState({frameNumber});
×
64
        }
65
      })
66
      .catch((error: any) => {
NEW
67
        console.error('Tile3DLayer: selectTiles error', error);
×
68
      });
69
  }
70
}
71
(KeplerTile3DLayer as any).layerName = 'KeplerTile3DLayer';
13✔
72

73
export const TILE3D_LAYER_TYPE = LAYER_TYPES.tile3d;
13✔
74

75
export type Tile3DLayerVisConfigSettings = {
76
  opacity: VisConfigNumber;
77
  pointSize: VisConfigNumber;
78
};
79

80
export type Tile3DLayerVisConfig = {
81
  opacity: number;
82
  pointSize: number;
83
};
84

85
export const tile3DVisConfigs = {
13✔
86
  opacity: {
87
    ...LAYER_VIS_CONFIGS.opacity,
88
    defaultValue: 1,
89
    property: 'opacity'
90
  } as VisConfigNumber,
91
  pointSize: {
92
    type: 'number' as const,
93
    defaultValue: 2,
94
    label: 'layerVisConfigs.pointSize',
95
    isRanged: false,
96
    range: [0.5, 20],
97
    step: 0.5,
98
    group: 'display' as const,
99
    property: 'pointSize',
100
    allowCustomValue: false
101
  } as VisConfigNumber
102
};
103

104
const EMPTY_EXTENSIONS: any[] = [];
13✔
105
const DEPTH_TEST_PARAMS = {depthTest: true};
13✔
106

107
function getTile3DProviderFromUrl(url = ''): (typeof TILE3D_PROVIDERS)[string] | null {
×
NEW
108
  for (const key of Object.keys(TILE3D_PROVIDERS)) {
×
NEW
109
    if (url.includes(TILE3D_PROVIDERS[key].urlKey)) {
×
NEW
110
      return TILE3D_PROVIDERS[key];
×
111
    }
112
  }
NEW
113
  return null;
×
114
}
115

116
export default class Tile3DLayer extends Layer {
117
  declare visConfigSettings: Tile3DLayerVisConfigSettings;
118

119
  private _cachedDataAndLoader: {
120
    data: string;
121
    loadOptions?: Record<string, unknown>;
122
    loader?: any;
123
    _cacheKey?: string;
124
  } | null = null;
27✔
125

126
  constructor(props: {dataId: string} & Record<string, any>) {
127
    super(props);
27✔
128
    this.registerVisConfig(tile3DVisConfigs);
27✔
129
    this.meta = {};
27✔
130
  }
131

132
  static findDefaultLayerProps(dataset: KeplerDataset): FindDefaultLayerPropsReturnValue {
133
    if (dataset.type !== DatasetType.TILE_3D) {
90!
134
      return {props: []};
90✔
135
    }
136

NEW
137
    return {
×
138
      props: [
139
        {
140
          label: dataset.label,
141
          isVisible: true
142
        }
143
      ]
144
    };
145
  }
146

147
  get type(): string {
NEW
148
    return TILE3D_LAYER_TYPE;
×
149
  }
150

151
  get name(): string {
152
    return '3D Tile';
27✔
153
  }
154

155
  get requireData(): boolean {
156
    return false;
27✔
157
  }
158

159
  get requiredLayerColumns(): string[] {
160
    return [];
27✔
161
  }
162

163
  get layerIcon(): typeof Tile3DLayerIcon {
164
    return Tile3DLayerIcon;
27✔
165
  }
166

167
  get supportedDatasetTypes(): DatasetType[] {
NEW
168
    return [DatasetType.TILE_3D];
×
169
  }
170

171
  get visualChannels() {
NEW
172
    return {};
×
173
  }
174

175
  shouldRenderLayer(): boolean {
NEW
176
    return Boolean(this.type && this.config.isVisible);
×
177
  }
178

179
  getHoverData(): any {
NEW
180
    return null;
×
181
  }
182

183
  formatLayerData(datasets: KeplerDatasets): Record<string, any> {
NEW
184
    const {dataId} = this.config;
×
NEW
185
    if (!dataId || !datasets[dataId]) {
×
NEW
186
      return {};
×
187
    }
188

NEW
189
    const dataset = datasets[dataId];
×
NEW
190
    const metadata = (dataset.metadata || {}) as Tile3DDatasetMetadata;
×
191

NEW
192
    return {
×
193
      tile3dUrl: metadata.tile3dUrl,
194
      tile3dAccessToken: metadata.tile3dAccessToken
195
    };
196
  }
197

198
  updateLayerMeta(dataset: KeplerDataset): void {
NEW
199
    if (dataset.type !== DatasetType.TILE_3D) {
×
NEW
200
      return;
×
201
    }
NEW
202
    const metadata = (dataset.metadata || {}) as Tile3DDatasetMetadata;
×
NEW
203
    const provider = getTile3DProviderFromUrl(metadata.tile3dUrl);
×
NEW
204
    this.updateMeta({provider});
×
205
  }
206

207
  _getDataAndLoaderOptions(
208
    tileUrl: string,
209
    accessToken?: string
210
  ): {data: string; loadOptions?: Record<string, unknown>; loader?: any} {
NEW
211
    const cacheKey = `${tileUrl}::${accessToken || ''}`;
×
NEW
212
    if (this._cachedDataAndLoader && this._cachedDataAndLoader._cacheKey === cacheKey) {
×
NEW
213
      return this._cachedDataAndLoader;
×
214
    }
215

NEW
216
    const provider = getTile3DProviderFromUrl(tileUrl);
×
217
    let result: {data: string; loadOptions?: Record<string, unknown>; loader?: any};
218

NEW
219
    if (provider === TILE3D_PROVIDERS.google) {
×
NEW
220
      const separator = tileUrl.includes('?') ? '&' : '?';
×
NEW
221
      result = {
×
222
        data: `${tileUrl}${separator}key=${accessToken || ''}`,
×
223
        loader: Tiles3DLoader,
224
        loadOptions: {
225
          fetch: {headers: {'X-GOOG-API-KEY': accessToken || ''}}
×
226
        }
227
      };
NEW
228
    } else if (provider === TILE3D_PROVIDERS.cesium) {
×
NEW
229
      result = {
×
230
        data: tileUrl,
231
        loader: CesiumIonLoader,
232
        loadOptions: {
233
          'cesium-ion': {accessToken}
234
        }
235
      };
NEW
236
    } else if (provider === TILE3D_PROVIDERS.arcgis) {
×
NEW
237
      result = {
×
238
        data: tileUrl,
239
        loader: I3SLoader,
240
        loadOptions: {
241
          i3s: {useCompressedTextures: false}
242
        }
243
      };
244
    } else {
NEW
245
      result = {
×
246
        data: tileUrl,
247
        loader: Tiles3DLoader
248
      };
249
    }
250

NEW
251
    this._cachedDataAndLoader = {...result, _cacheKey: cacheKey};
×
NEW
252
    return this._cachedDataAndLoader;
×
253
  }
254

255
  _onTilesetLoad = (tileset3d: Tileset3D): void => {
27✔
NEW
256
    this._extractBoundsFromTileset(tileset3d);
×
257

NEW
258
    const {tile3dUrl} = (this.config.dataId &&
×
259
      (this as any)._lastDatasets?.[this.config.dataId]?.metadata) || {tile3dUrl: ''};
260

NEW
261
    if (tile3dUrl && getTile3DProviderFromUrl(tile3dUrl) === TILE3D_PROVIDERS.google) {
×
NEW
262
      tileset3d.options.onTraversalComplete = selectedTiles => {
×
NEW
263
        const credits = new Set<string>();
×
NEW
264
        selectedTiles.forEach((tile: Tile3D) => {
×
NEW
265
          const {copyright} = (tile as any).content?.gltf?.asset || {};
×
NEW
266
          if (copyright) copyright.split(';').forEach(c => credits.add(c));
×
267
        });
NEW
268
        const title = Array.from(credits).join('; ');
×
NEW
269
        this.updateMeta({
×
270
          googleAttribution: title
271
        });
NEW
272
        return selectedTiles;
×
273
      };
274
    }
275
  };
276

277
  _extractBoundsFromTileset(tileset3d: Tileset3D): void {
NEW
278
    try {
×
NEW
279
      const root = tileset3d.root;
×
NEW
280
      if (root) {
×
NEW
281
        const bbox = (root as any).boundingBox;
×
NEW
282
        if (bbox && bbox.length === 2) {
×
NEW
283
          const [min, max] = bbox;
×
284
          // bbox = [[westDeg, southDeg, minHeight], [eastDeg, northDeg, maxHeight]]
NEW
285
          this.updateMeta({
×
286
            bounds: [min[0], min[1], max[0], max[1]]
287
          });
NEW
288
          return;
×
289
        }
290
      }
291
    } catch {
292
      // boundingBox getter may throw for malformed volumes
293
    }
294

295
    // Fallback: approximate bounds from cartographicCenter and zoom
NEW
296
    const center = tileset3d.cartographicCenter;
×
NEW
297
    if (center) {
×
NEW
298
      const lng = center[0];
×
NEW
299
      const lat = center[1];
×
NEW
300
      const zoom = tileset3d.zoom || 14;
×
301
      // Approximate half-span in degrees based on zoom level
NEW
302
      const span = 180 / Math.pow(2, zoom);
×
NEW
303
      this.updateMeta({
×
304
        bounds: [lng - span, lat - span, lng + span, lat + span]
305
      });
306
    }
307
  }
308

309
  _onTileLoad = (tile: Tile3D): void => {
27✔
310
    // I3S materials often lack metallicFactor/roughnessFactor.
311
    // PBR spec defaults metallicFactor to 1 (fully metallic),
312
    // causing black surfaces when lighting/shadow effects are active.
NEW
313
    const pbr = tile.content?.material?.pbrMetallicRoughness;
×
NEW
314
    if (pbr) {
×
NEW
315
      if (pbr.metallicFactor === undefined) {
×
NEW
316
        pbr.metallicFactor = 0;
×
317
      }
NEW
318
      if (pbr.roughnessFactor === undefined) {
×
NEW
319
        pbr.roughnessFactor = 1;
×
320
      }
321
    }
322
  };
323

324
  _onTileUnload = (_tile: Tile3D): void => {
27✔
325
    /* noop */
326
  };
327

328
  renderLayer(opts: any): KeplerTile3DLayer[] {
NEW
329
    const {data} = opts;
×
NEW
330
    const {tile3dUrl, tile3dAccessToken} = data || {};
×
NEW
331
    if (!tile3dUrl) {
×
NEW
332
      return [];
×
333
    }
334

NEW
335
    const {visConfig} = this.config;
×
NEW
336
    const defaultLayerProps = this.getDefaultDeckLayerProps(opts);
×
337
    const {
338
      data: tileData,
339
      loadOptions,
340
      loader
NEW
341
    } = this._getDataAndLoaderOptions(tile3dUrl, tile3dAccessToken);
×
342

NEW
343
    return [
×
344
      new KeplerTile3DLayer({
345
        id: defaultLayerProps.id,
346
        coordinateSystem: defaultLayerProps.coordinateSystem,
347
        wrapLongitude: defaultLayerProps.wrapLongitude,
348
        visible: defaultLayerProps.visible,
349
        data: tileData,
350
        loader,
351
        loadOptions,
352
        onTilesetLoad: this._onTilesetLoad,
353
        onTileLoad: this._onTileLoad,
354
        onTileUnload: this._onTileUnload,
355
        pointSize: visConfig.pointSize ?? 2,
×
356
        pickable: false,
357
        opacity: visConfig.opacity ?? 1,
×
358
        extensions: EMPTY_EXTENSIONS,
359
        parameters: DEPTH_TEST_PARAMS
360
      })
361
    ];
362
  }
363
}
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