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

keplergl / kepler.gl / 28970700046

08 Jul 2026 07:42PM UTC coverage: 56.11% (-0.04%) from 56.147%
28970700046

push

github

web-flow
chore: upgrade to React 19 (#3514)

* chore: upgrade to React 19

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* remove unused radius by zoom

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* fix linechart

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* fix transform-origin

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* upgrade react-router

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* bump styled components

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* fix react warnings

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* try to fix CI lint

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* try to fix CI lint

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* try to fix tests

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* try to fix tests

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* fix netlify builds

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* fix testS

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* fix testS

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* fix tests

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* fix tests

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* try to fix browser tests

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* try to fix netlify build

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* follow up

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* revert ts change

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* bump website

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* fix swipe mode regression

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* fixes for website

Signed-off-by: Ihor Dykhta <iho... (continued)

7736 of 16484 branches covered (46.93%)

Branch coverage included in aggregate %.

17 of 90 new or added lines in 12 files covered. (18.89%)

142 existing lines in 6 files now uncovered.

15540 of 24999 relevant lines covered (62.16%)

73.81 hits per line

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

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

4
import {FeatureCollection, Feature} from 'geojson';
5

6
import {Layer as DeckLayer} from '@deck.gl/core';
7
import {_Tile2DHeader as Tile2DHeader} from '@deck.gl/geo-layers';
8
import {GeoJsonLayer, PathLayer} from '@deck.gl/layers';
9
import {ClipExtension} from '@deck.gl/extensions';
10
import {MVTSource, MVTTileSource} from '@loaders.gl/mvt';
11
import {PMTilesSource, PMTilesTileSource} from '@loaders.gl/pmtiles';
12

13
import {notNullorUndefined} from '@kepler.gl/common-utils';
14
import {
15
  getLoaderOptions,
16
  DatasetType,
17
  LAYER_TYPES,
18
  RemoteTileFormat,
19
  VectorTileDatasetMetadata,
20
  SCALE_TYPES,
21
  CHANNEL_SCALES,
22
  DEFAULT_COLOR_UI,
23
  LAYER_VIS_CONFIGS,
24
  CULL_MODE
25
} from '@kepler.gl/constants';
26
import {
27
  getTileUrl,
28
  KeplerTable as KeplerDataset,
29
  Datasets as KeplerDatasets,
30
  GpuFilter,
31
  VectorTileMetadata
32
} from '@kepler.gl/table';
33
import {
34
  AnimationConfig,
35
  Field as KeplerField,
36
  LayerColorConfig,
37
  LayerHeightConfig,
38
  Merge,
39
  MapState,
40
  BindedLayerCallbacks,
41
  VisConfigRange,
42
  VisConfigNumber,
43
  DomainStops
44
} from '@kepler.gl/types';
45
import {DataContainerInterface} from '@kepler.gl/utils';
46

47
import {MVTLayer as CustomMVTLayer} from './mvt-layer';
48
import VectorTileIcon from './vector-tile-icon';
49
import {
50
  default as KeplerLayer,
51
  LayerBaseConfig,
52
  LayerBaseConfigPartial,
53
  VisualChannel,
54
  VisualChannelDomain,
55
  VisualChannelField
56
} from '../base-layer';
57
import {FindDefaultLayerPropsReturnValue} from '../layer-utils';
58

59
import AbstractTileLayer, {
60
  LayerData as CommonLayerData,
61
  commonTileVisConfigs,
62
  AbstractTileLayerConfig,
63
  AbstractTileLayerVisConfigSettings
64
} from './abstract-tile-layer';
65
import TileDataset from './common-tile/tile-dataset';
66
import {
67
  isDomainStops,
68
  isDomainQuantiles,
69
  isIndexedField
70
} from './common-tile/tile-utils';
13✔
71

13✔
72
export {getNumVectorTilesBeingLoaded} from './loading-counter';
13✔
73

13✔
74
export const DEFAULT_HIGHLIGHT_FILL_COLOR = [252, 242, 26, 150];
13✔
75
export const DEFAULT_HIGHLIGHT_STROKE_COLOR = [252, 242, 26, 255];
76
export const MAX_CACHE_SIZE_MOBILE = 1; // Minimize caching, visible tiles will always be loaded
77
export const DEFAULT_STROKE_WIDTH = 1;
78
export const UUID_CANDIDATES = [
79
  'ufid',
80
  'UFID',
81
  'id',
82
  'ID',
83
  'fid',
84
  'FID',
85
  'objectid',
86
  'OBJECTID',
87
  'gid',
88
  'GID',
89
  'feature_id',
90
  'FEATURE_ID',
91
  '_id'
92
];
93
/**
94
 * Type for transformRequest returned parameters.
95
 */
96
export type RequestParameters = {
97
  /** The URL to be requested. */
98
  url: string;
99
  /** Search parameters to be added onto the URL. */
100
  searchParams: URLSearchParams;
101
  /** Options passed to fetch. */
102
  options: RequestInit;
103
};
104

105
// This type *seems* to be what loaders.gl currently returns for tile content.
106
// Apparently this might be different depending on the loaders version, and for...
107
// reasons we use two different versions of loaders right now.
108
// TODO: The Features[] version should not be needed when we update to a newer
109
// version of Deck.gl and use only one version of loaders
110
type TileContent =
111
  | (FeatureCollection & {shape: 'geojson-table'})
112
  | (Feature[] & {shape: undefined});
113

114
type VectorTile = Tile2DHeader<TileContent>;
115

116
type LayerData = CommonLayerData & {
117
  tilesetDataUrl?: string | null;
118
  tileSource: MVTTileSource | PMTilesTileSource | null;
119
};
120

121
type VectorTileLayerRenderOptions = Merge<
122
  {
123
    idx: number;
124
    visible: boolean;
125
    mapState: MapState;
126
    data: any;
127
    animationConfig: AnimationConfig;
128
    gpuFilter: GpuFilter;
129
    layerCallbacks: BindedLayerCallbacks;
130
    objectHovered: {
131
      index: number;
132
      tile: VectorTile;
133
      sourceLayer: typeof GeoJsonLayer;
134
    };
135
  },
13✔
136
  LayerData
137
>;
138

139
export const vectorTileVisConfigs = {
140
  ...commonTileVisConfigs,
141

142
  stroked: {
143
    ...LAYER_VIS_CONFIGS.stroked,
144
    defaultValue: false
145
  },
146

147
  // TODO figure out why strokeColorScale can't be const
148
  strokeColorScale: 'strokeColorScale' as any,
149
  strokeColorRange: 'strokeColorRange' as const,
150

151
  sizeRange: 'strokeWidthRange' as const,
152
  strokeWidth: {
153
    ...LAYER_VIS_CONFIGS.thickness,
154
    property: 'strokeWidth',
155
    defaultValue: 0.5,
156
    allowCustomValue: false
157
  },
158

159
  radiusScale: 'radiusScale' as any,
160
  radiusRange: {
161
    ...LAYER_VIS_CONFIGS.radiusRange,
162
    type: 'number',
163
    defaultValue: [0, 1],
164
    isRanged: true,
165
    range: [0, 1],
166
    step: 0.01
167
  } as VisConfigRange
168
};
169

170
export type VectorTileLayerConfig = Merge<
171
  AbstractTileLayerConfig,
172
  {
173
    sizeField?: VisualChannelField;
174
    sizeScale?: string;
175
    sizeDomain?: VisualChannelDomain;
176

177
    strokeColorField: VisualChannelField;
178

179
    radiusField?: VisualChannelField;
180
    radiusScale?: string;
181
    radiusDomain?: VisualChannelDomain;
182
    radiusRange?: any;
183

184
    uniqueIdField?: string | null;
185
  }
186
>;
187

188
export type VectorTileLayerVisConfigSettings = Merge<
189
  AbstractTileLayerVisConfigSettings,
190
  {
191
    sizeRange: VisConfigRange;
192
    strokeWidth: VisConfigNumber;
UNCOV
193
  }
×
UNCOV
194
>;
×
195

UNCOV
196
export function tileLayerBoundsLayer(id: string, props: {bounds?: number[]}): DeckLayer[] {
×
197
  const {bounds} = props;
198
  if (bounds?.length !== 4) return [];
199

200
  const data = [
201
    {
202
      path: [
203
        [bounds[0], bounds[1]],
204
        [bounds[2], bounds[1]],
205
        [bounds[2], bounds[3]],
206
        [bounds[0], bounds[3]],
207
        [bounds[0], bounds[1]]
UNCOV
208
      ]
×
209
    }
210
  ];
UNCOV
211

×
212
  const layer = new PathLayer({
213
    id: `${id}-vector-tile-bounds`,
214
    data,
215
    getPath: d => d.path,
216
    getColor: [128, 128, 128, 255],
217
    getWidth: 1,
UNCOV
218
    widthUnits: 'pixels',
×
219
    pickable: false
220
  });
221

222
  return [layer];
223
}
224

225
export default class VectorTileLayer extends AbstractTileLayer<VectorTile, Feature[]> {
226
  declare config: VectorTileLayerConfig;
27✔
227
  declare visConfigSettings: VectorTileLayerVisConfigSettings;
27✔
228

27✔
229
  constructor(props: ConstructorParameters<typeof AbstractTileLayer>[0]) {
230
    super(props);
231
    this.registerVisConfig(vectorTileVisConfigs);
27✔
232
    this.tileDataset = this.initTileDataset();
233
  }
234

95!
235
  meta = {};
95✔
236

UNCOV
237
  static findDefaultLayerProps(dataset: KeplerDataset): FindDefaultLayerPropsReturnValue {
×
238
    if (dataset.type !== DatasetType.VECTOR_TILE) {
239
      return {props: []};
240
    }
241
    return super.findDefaultLayerProps(dataset);
54✔
UNCOV
242
  }
×
243

UNCOV
244
  initTileDataset(): TileDataset<VectorTile, Feature[]> {
×
UNCOV
245
    return new TileDataset({
×
246
      getTileId: (tile: VectorTile): string => tile.id,
UNCOV
247
      getIterable: (tile: VectorTile): Feature[] => {
×
248
        if (tile.content) {
249
          return tile.content.shape === 'geojson-table' ? tile.content.features : tile.content;
×
250
        }
251
        return [];
252
      },
253
      getRowCount: (features: Feature[]): number => features.length,
254
      getRowValue: this.accessRowValue
UNCOV
255
    });
×
256
  }
257

258
  get type(): string {
259
    return LAYER_TYPES.vectorTile;
27✔
260
  }
261

262
  get name(): string {
263
    return 'Vector Tile';
27✔
264
  }
265

266
  get layerIcon(): KeplerLayer['layerIcon'] {
UNCOV
267
    return VectorTileIcon;
×
268
  }
269

270
  get supportedDatasetTypes(): DatasetType[] {
271
    return [DatasetType.VECTOR_TILE];
×
UNCOV
272
  }
×
273

274
  get visualChannels(): Record<string, VisualChannel> {
275
    const visualChannels = super.visualChannels;
276
    return {
277
      ...visualChannels,
278
      strokeColor: {
279
        property: 'strokeColor',
280
        field: 'strokeColorField',
281
        scale: 'strokeColorScale',
282
        domain: 'strokeColorDomain',
UNCOV
283
        range: 'strokeColorRange',
×
284
        key: 'strokeColor',
UNCOV
285
        channelScaleType: CHANNEL_SCALES.color,
×
286
        accessor: 'getLineColor',
287
        condition: config => config.visConfig.stroked,
288
        nullValue: visualChannels.color.nullValue,
289
        getAttributeValue: config => config.visConfig.strokeColor || config.color
290
      },
291
      size: {
292
        property: 'stroke',
293
        field: 'sizeField',
294
        scale: 'sizeScale',
295
        domain: 'sizeDomain',
296
        range: 'sizeRange',
UNCOV
297
        key: 'size',
×
UNCOV
298
        channelScaleType: CHANNEL_SCALES.size,
×
299
        nullValue: 0,
300
        accessor: 'getLineWidth',
301
        condition: config => config.visConfig.stroked,
302
        getAttributeValue: config => config.visConfig.strokeWidth || DEFAULT_STROKE_WIDTH
303
      },
304
      radius: {
305
        property: 'radius',
306
        field: 'radiusField',
307
        scale: 'radiusScale',
308
        domain: 'radiusDomain',
309
        range: 'radiusRange',
UNCOV
310
        key: 'radius',
×
311
        channelScaleType: CHANNEL_SCALES.size,
312
        nullValue: 0,
UNCOV
313
        getAttributeValue: config => {
×
314
          return config.visConfig.radius || config.radius;
315
        },
316
        accessor: 'getPointRadius',
317
        defaultValue: config => config.radius
318
      }
319
    };
320
  }
321

27✔
322
  getDefaultLayerConfig(
27✔
323
    props: LayerBaseConfigPartial
324
  ): LayerBaseConfig & Partial<LayerColorConfig & LayerHeightConfig> {
325
    const defaultLayerConfig = super.getDefaultLayerConfig(props);
326
    return {
327
      ...defaultLayerConfig,
328
      colorScale: SCALE_TYPES.quantize,
329

330
      strokeColorField: null,
331
      strokeColorDomain: [0, 1],
332
      strokeColorScale: SCALE_TYPES.quantile,
333
      colorUI: {
334
        ...defaultLayerConfig.colorUI,
335
        // @ts-expect-error LayerConfig
336
        strokeColorRange: DEFAULT_COLOR_UI
337
      },
338

339
      radiusField: null,
340
      radiusDomain: [0, 1],
341
      radiusScale: SCALE_TYPES.linear,
342

343
      uniqueIdField: null
344
    };
345
  }
346

347
  getHoverData(
UNCOV
348
    object: {properties?: Record<string, Record<string, unknown>>},
×
349
    dataContainer: DataContainerInterface,
350
    fields: KeplerField[]
351
  ): (Record<string, unknown> | null)[] {
352
    return fields.map(f => object.properties?.[f.name] ?? null);
353
  }
354

UNCOV
355
  calculateLayerDomain(
×
356
    dataset: KeplerDataset,
UNCOV
357
    visualChannel: VisualChannel
×
UNCOV
358
  ): DomainStops | number[] {
×
359
    const defaultDomain = [0, 1];
×
360

361
    const field = this.config[visualChannel.field];
×
362
    const scale = this.config[visualChannel.scale];
363
    if (!field) {
364
      // if colorField or sizeField were set back to null
365
      return defaultDomain;
366
    }
UNCOV
367

×
UNCOV
368
    // When dynamicColor is enabled, the domain is managed asynchronously by
×
UNCOV
369
    // setDynamicColorDomain(). Preserve the current domain to avoid overwriting
×
UNCOV
370
    // the async result with metadata-based [min, max] values.
×
371
    if (this.config.visConfig.dynamicColor && visualChannel.key === 'color') {
372
      if (scale === SCALE_TYPES.quantile) {
×
373
        const current = this.config.colorDomain;
×
374
        return Array.isArray(current) && current.length > 2
×
375
          ? (current as number[])
376
          : defaultDomain;
377
      }
378
      if (scale === SCALE_TYPES.quantize) {
379
        const current = this.config.colorDomain;
UNCOV
380
        return Array.isArray(current) && current.length === 2
×
UNCOV
381
          ? (current as number[])
×
382
          : defaultDomain;
UNCOV
383
      }
×
384
    }
×
385

×
UNCOV
386
    if (scale === SCALE_TYPES.quantile && isDomainQuantiles(field?.filterProps?.domainQuantiles)) {
×
387
      return field.filterProps.domainQuantiles;
388
    }
389
    if (isDomainStops(field?.filterProps?.domainStops)) {
×
390
      return field.filterProps.domainStops;
391
    } else if (Array.isArray(field?.filterProps?.domain)) {
392
      return field.filterProps.domain;
393
    }
×
394

UNCOV
395
    return defaultDomain;
×
UNCOV
396
  }
×
397

×
398
  getScaleOptions(channelKey: string): string[] {
399
    let options = KeplerLayer.prototype.getScaleOptions.call(this, channelKey);
×
400

401
    const channel = this.visualChannels.strokeColor;
402
    const field = this.config[channel.field];
403
    if (
404
      !(
405
        isDomainQuantiles(field?.filterProps?.domainQuantiles) ||
406
        this.config.visConfig.dynamicColor ||
UNCOV
407
        // If we've set the scale to quantile, we need to include it - there's a loading
×
408
        // period in which the visConfig isn't set yet, but if we don't return the right
409
        // scale type we lose it
UNCOV
410
        this.config.colorScale === SCALE_TYPES.quantile
×
411
      )
412
    ) {
413
      options = options.filter(scale => scale !== SCALE_TYPES.quantile);
414
    }
415

416
    return options;
417
  }
UNCOV
418

×
UNCOV
419
  accessRowValue(
×
UNCOV
420
    field?: KeplerField,
×
UNCOV
421
    indexKey?: number | null
×
422
  ): (field: KeplerField, datum: Feature) => number | null {
×
423
    // if is indexed field
×
424
    if (isIndexedField(field) && indexKey !== null) {
425
      const fieldName = indexKey && field?.indexBy?.mappedValue[indexKey];
426
      if (fieldName) {
×
427
        return (f, datum) => {
428
          if (datum.properties) {
429
            return datum.properties[fieldName];
430
          }
431
          // TODO debug this with indexed tiled dataset
UNCOV
432
          return datum[fieldName];
×
UNCOV
433
        };
×
UNCOV
434
      }
×
435
    }
436

437
    // default
×
438
    return (f, datum) => {
439
      if (f && datum.properties) {
440
        return datum.properties[f.name];
441
      }
UNCOV
442
      // support picking & highlighting
×
UNCOV
443
      return f ? datum[f.fieldIdx] : null;
×
444
    };
445
  }
446

×
447
  updateLayerMeta(dataset: KeplerDataset, datasets: KeplerDatasets): void {
×
448
    if (dataset.type !== DatasetType.VECTOR_TILE) {
449
      return;
450
    }
451

452
    const datasetMeta = dataset.metadata as VectorTileMetadata & VectorTileDatasetMetadata;
453
    this.updateMeta({
454
      datasetId: dataset.id,
455
      datasets,
456
      bounds: datasetMeta.bounds
457
    });
458
  }
UNCOV
459

×
UNCOV
460
  formatLayerData(
×
UNCOV
461
    datasets: KeplerDatasets,
×
462
    oldLayerData: unknown,
463
    animationConfig: AnimationConfig
×
464
  ): LayerData {
465
    const {dataId} = this.config;
UNCOV
466
    if (!notNullorUndefined(dataId)) {
×
467
      return {tileSource: null};
UNCOV
468
    }
×
UNCOV
469
    const dataset = datasets[dataId];
×
470

×
UNCOV
471
    let tilesetDataUrl: string | undefined;
×
472
    let tileSource: LayerData['tileSource'] = null;
×
473

×
474
    if (dataset?.type === DatasetType.VECTOR_TILE) {
475
      const datasetMetadata = dataset.metadata as VectorTileMetadata & VectorTileDatasetMetadata;
476
      const remoteTileFormat = datasetMetadata?.remoteTileFormat;
×
477
      if (remoteTileFormat === RemoteTileFormat.MVT) {
478
        const transformFetch = async (input: RequestInfo | URL, init?: RequestInit | undefined) => {
UNCOV
479
          const requestData: RequestParameters = {
×
480
            url: input as string,
481
            searchParams: new URLSearchParams(),
UNCOV
482
            options: init ?? {}
×
483
          };
×
484

485
          return fetch(requestData.url, requestData.options);
486
        };
×
487

488
        tilesetDataUrl = datasetMetadata?.tilesetDataUrl;
489
        tileSource = tilesetDataUrl
490
          ? MVTSource.createDataSource(decodeURIComponent(tilesetDataUrl), {
491
              mvt: {
492
                metadataUrl: datasetMetadata?.tilesetMetadataUrl ?? null,
UNCOV
493
                loadOptions: {
×
494
                  core: {fetch: transformFetch}
UNCOV
495
                }
×
UNCOV
496
              }
×
497
            })
498
          : null;
499
      } else if (remoteTileFormat === RemoteTileFormat.PMTILES) {
500
        // TODO: to render image pmtiles need to use TileLayer and BitmapLayer (https://github.com/visgl/loaders.gl/blob/master/examples/website/tiles/components/tile-source-layer.ts)
×
501
        tilesetDataUrl = datasetMetadata?.tilesetDataUrl;
502
        tileSource = tilesetDataUrl ? PMTilesSource.createDataSource(tilesetDataUrl, {}) : null;
×
503
      }
504
    }
505

506
    return {
507
      ...super.formatLayerData(datasets, oldLayerData, animationConfig),
UNCOV
508
      tilesetDataUrl: typeof tilesetDataUrl === 'string' ? getTileUrl(tilesetDataUrl) : null,
×
UNCOV
509
      tileSource
×
UNCOV
510
    };
×
511
  }
512

×
513
  hasHoveredObject(objectInfo) {
514
    if (super.hasHoveredObject(objectInfo)) {
515
      const features = objectInfo?.tile?.content?.features;
516
      return features[objectInfo.index];
×
517
    }
UNCOV
518
    return null;
×
UNCOV
519
  }
×
520

×
521
  renderSubLayers(props: Record<string, any>): DeckLayer | DeckLayer[] {
522
    let {data} = props;
523

×
524
    data = data?.shape === 'geojson-table' ? data.features : data;
×
525
    if (!data?.length) {
UNCOV
526
      return [];
×
527
    }
528

529
    const tile: Tile2DHeader = props.tile;
×
530
    const zoom = tile.index.z;
×
531

532
    return new GeoJsonLayer({
533
      ...props,
534
      data,
535
      getFillColor: props.getFillColorByZoom ? props.getFillColor(zoom) : props.getFillColor,
536
      getElevation: props.getElevationByZoom ? props.getElevation(zoom) : props.getElevation,
537
      // radius for points
538
      pointRadiusScale: props.pointRadiusScale,
539
      pointRadiusUnits: props.pointRadiusUnits,
540
      getPointRadius: props.getPointRadius,
541
      // For some reason tile Layer reset autoHighlight to false
542
      pickable: true,
543
      autoHighlight: true,
544
      stroked: props.stroked,
545
      // wrapLongitude: true causes missing side polygon when extrude is enabled
UNCOV
546
      wrapLongitude: false
×
UNCOV
547
    });
×
548
  }
UNCOV
549

×
550
  // generate a deck layer
551
  renderLayer(opts: VectorTileLayerRenderOptions): DeckLayer[] {
×
UNCOV
552
    const {mapState, data, animationConfig, gpuFilter, objectHovered, layerCallbacks} = opts;
×
553
    const {animation, visConfig} = this.config;
UNCOV
554

×
555
    this.setLayerDomain = layerCallbacks.onSetLayerDomain;
556

557
    const defaultLayerProps = this.getDefaultDeckLayerProps(opts);
558
    const eleZoomFactor = this.getElevationZoomFactor(mapState);
559

560
    const transitions = this.config.visConfig.transition
561
      ? {
562
          getFillColor: {
563
            duration: animationConfig.duration
564
          },
UNCOV
565
          getElevation: {
×
UNCOV
566
            duration: animationConfig.duration
×
UNCOV
567
          }
×
UNCOV
568
        }
×
569
      : undefined;
×
570

571
    const colorField = this.config.colorField as KeplerField;
×
572
    const heightField = this.config.heightField as KeplerField;
×
573
    const strokeColorField = this.config.strokeColorField as KeplerField;
574
    const sizeField = this.config.sizeField as KeplerField;
575
    const radiusField = this.config.radiusField as KeplerField;
576

UNCOV
577
    if (data.tileSource) {
×
UNCOV
578
      const hoveredObject = this.hasHoveredObject(objectHovered);
×
579

×
UNCOV
580
      // Resolve unique id property: use configured uniqueIdField if set, otherwise infer
×
581
      let uniqueIdProperty: string | undefined;
×
582
      let highlightedFeatureId: string | number | undefined;
583
      if (hoveredObject && hoveredObject.properties) {
584
        uniqueIdProperty =
585
          this.config.uniqueIdField ??
586
          UUID_CANDIDATES.find(k => hoveredObject.properties && k in hoveredObject.properties);
UNCOV
587
        highlightedFeatureId = uniqueIdProperty
×
588
          ? hoveredObject.properties[uniqueIdProperty]
589
          : (hoveredObject as any).id;
590
      }
591

592
      // Build per-tile clipped overlay to draw only the outer stroke of highlighted feature per tile
UNCOV
593
      const perTileOverlays = this._getPerTileOverlays(hoveredObject, {
×
594
        defaultLayerProps,
595
        visConfig,
596
        uniqueIdProperty
597
      });
598

599
      const layers = [
600
        new CustomMVTLayer({
601
          ...defaultLayerProps,
602
          ...data,
603
          onViewportLoad: this.onViewportLoad,
604
          data: data.tilesetDataUrl,
605
          getTileData: data.tileSource?.getTileData,
606
          tileSource: data.tileSource,
607
          getFilterValue: this.getGpuFilterValueAccessor(opts),
608
          filterRange: gpuFilter.filterRange,
609
          lineWidthUnits: 'pixels',
610

611
          binary: false,
612
          elevationScale: visConfig.elevationScale * eleZoomFactor,
613
          extruded: visConfig.enable3d,
614
          stroked: visConfig.stroked,
×
615

×
616
          // TODO: this is hard coded, design a UI to allow user assigned unique property id
617
          uniqueIdProperty,
618
          highlightedFeatureId,
619
          renderSubLayers: this.renderSubLayers,
620
          pointRadiusUnits: visConfig.radiusUnits ? 'pixels' : 'meters',
621
          pointRadiusScale: radiusField ? visConfig.radius : 1,
622

623
          pointRadiusMinPixels: 1,
624
          autoHighlight: true,
625
          highlightColor: DEFAULT_HIGHLIGHT_FILL_COLOR,
×
626
          pickable: true,
627
          transitions,
628
          updateTriggers: {
629
            getFilterValue: {
630
              ...gpuFilter.filterValueUpdateTriggers,
631
              currentTime: animation.enabled ? animationConfig.currentTime : null
632
            },
633
            getFillColor: {
×
634
              color: this.config.color,
635
              colorField: this.config.colorField,
636
              colorScale: this.config.colorScale,
637
              colorDomain: this.config.colorDomain,
638
              colorRange: visConfig.colorRange,
639
              currentTime: isIndexedField(colorField) ? animationConfig.currentTime : null
×
640
            },
641
            getElevation: {
642
              heightField: this.config.heightField,
643
              heightScaleType: this.config.heightScale,
644
              heightRange: visConfig.heightRange,
645
              currentTime: isIndexedField(heightField) ? animationConfig.currentTime : null
646
            },
647
            getLineColor: {
648
              strokeColor: visConfig.strokeColor,
649
              strokeColorField: this.config.strokeColorField,
650
              // @ts-expect-error prop not in LayerConfig
651
              strokeColorScale: this.config.strokeColorScale,
652
              // @ts-expect-error prop not in LayerConfig
653
              strokeColorDomain: this.config.strokeColorDomain,
654
              // FIXME: Strip out empty arrays from individual color map steps, and replace with `null`, otherwise the layer may show the incorrect color.
655
              // So far it seems that it uses the previous color chosen in the palette rather than the currently chosen color for the specific custom ordinal value when there are "sparse" color maps.
656
              // In other words, a color map with "holes" of colors with unassigned field values, which may have been assigned in the past.
657
              // For example "abc" was green, stored as `["abc"]`. Then "abc" was reassigned to the red color map step, stored as `["abc"]`. Now the green color map step's stored value is `[]`, and the layer will incorrectly still render "abc" in green.
658
              // Quick patch example:
×
659
              // strokeColorRange: visConfig?.strokeColorRange?.colorMap?.map(cm =>
660
              //   cm[0]?.length === 0 ? [null, cm[1]] : cm
661
              // ),
662
              // Note: for regular scales the colorMap in the above patch is undefined and breaks strokeColorRange update trigger.
663
              strokeColorRange: visConfig.strokeColorRange,
664
              currentTime: isIndexedField(strokeColorField) ? animationConfig.currentTime : null
665
            },
666
            getLineWidth: {
×
667
              sizeRange: visConfig.sizeRange,
668
              strokeWidth: visConfig.strokeWidth,
669
              sizeField: this.config.sizeField,
670
              sizeScale: this.config.sizeScale,
671
              sizeDomain: this.config.sizeDomain,
672
              currentTime: isIndexedField(sizeField) ? animationConfig.currentTime : null
673
            },
674
            getPointRadius: {
×
675
              radius: visConfig.radius,
676
              radiusField: this.config.radiusField,
677
              radiusScale: this.config.radiusScale,
678
              radiusDomain: this.config.radiusDomain,
679
              radiusRange: this.config.radiusRange,
680
              currentTime: isIndexedField(radiusField) ? animationConfig.currentTime : null
681
            }
682
          },
683
          _subLayerProps: {
684
            'polygons-stroke': {opacity: visConfig.strokeOpacity},
685
            'polygons-fill': {
686
              parameters: {
687
                cullMode: CULL_MODE.BACK
688
              }
689
            }
690
          },
×
691
          loadOptions: {
692
            mvt: getLoaderOptions().mvt
693
          }
694
        }),
695
        // render hover layer for features with no unique id property and no highlighted feature id
696
        ...(hoveredObject && !uniqueIdProperty && !highlightedFeatureId
697
          ? [
698
              new GeoJsonLayer({
699
                // @ts-expect-error props not typed?
700
                ...objectHovered.sourceLayer?.props,
701
                ...(this.getDefaultHoverLayerProps() as any),
702
                visible: true,
703
                wrapLongitude: false,
704
                data: [hoveredObject],
705
                getLineColor: DEFAULT_HIGHLIGHT_STROKE_COLOR,
706
                getFillColor: DEFAULT_HIGHLIGHT_FILL_COLOR,
707
                getLineWidth: visConfig.strokeWidth + 1,
708
                lineWidthUnits: 'pixels',
709
                stroked: true,
710
                filled: true
711
              })
UNCOV
712
            ]
×
713
          : []),
UNCOV
714
        ...perTileOverlays
×
715
        // ...tileLayerBoundsLayer(defaultLayerProps.id, data),
716
      ];
717

718
      return layers;
719
    }
720
    return [];
721
  }
722

723
  /**
724
   * Build per-tile clipped overlay to draw only the outer stroke of highlighted feature per tile
UNCOV
725
   * @param hoveredObject
×
UNCOV
726
   */
×
UNCOV
727
  _getPerTileOverlays(
×
UNCOV
728
    hoveredObject: Feature,
×
729
    options: {defaultLayerProps: any; visConfig: any; uniqueIdProperty?: string}
730
  ): DeckLayer[] {
×
731
    let perTileOverlays: DeckLayer[] = [];
732
    if (hoveredObject) {
733
      try {
734
        const tiles = this.tileDataset?.getTiles?.() || [];
UNCOV
735
        // Derive hovered id from hoveredObject
×
UNCOV
736
        const hoveredId = options.uniqueIdProperty
×
UNCOV
737
          ? String(hoveredObject?.properties?.[options.uniqueIdProperty])
×
UNCOV
738
          : String((hoveredObject as any)?.id);
×
739

×
740
        // Group matched fragments by tile id
×
741
        const byTile: Record<string, Feature[]> = {};
×
742
        for (const tile of tiles) {
×
743
          const content = (tile as any)?.content;
744
          const features = content?.shape === 'geojson-table' ? content.features : content;
745
          if (!Array.isArray(features)) continue;
×
746
          const tileId = (tile as any).id;
×
747
          for (const f of features) {
748
            const fid = options.uniqueIdProperty
749
              ? f.properties?.[options.uniqueIdProperty]
750
              : (f as any).id;
UNCOV
751
            if (fid !== undefined && String(fid) === hoveredId) {
×
UNCOV
752
              (byTile[tileId] = byTile[tileId] || []).push(f as Feature);
×
UNCOV
753
            }
×
754
          }
755
        }
756

×
757
        perTileOverlays = Object.entries(byTile).map(([tileId, feats]) => {
758
          const tile = tiles.find((t: any) => String(t.id) === String(tileId));
759
          const bounds = tile?.boundingBox
760
            ? [...tile.boundingBox[0], ...tile.boundingBox[1]]
761
            : undefined;
762
          return new GeoJsonLayer({
763
            ...(this.getDefaultHoverLayerProps() as any),
764
            id: `${options.defaultLayerProps.id}-hover-outline-${tileId}`,
765
            visible: true,
766
            wrapLongitude: false,
767
            data: feats,
768
            getLineColor: DEFAULT_HIGHLIGHT_STROKE_COLOR,
769
            getFillColor: [0, 0, 0, 0],
770
            getLineWidth: options.visConfig.strokeWidth + 1,
771
            lineWidthUnits: 'pixels',
×
772
            lineJointRounded: true,
773
            lineCapRounded: true,
774
            stroked: true,
UNCOV
775
            filled: false,
×
776
            clipBounds: bounds,
777
            extensions: bounds ? [new ClipExtension()] : []
UNCOV
778
          });
×
779
        });
780
      } catch {
781
        perTileOverlays = [];
782
      }
783
    }
784
    return perTileOverlays;
785
  }
786
}
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