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

keplergl / kepler.gl / 25637948193

10 May 2026 07:40PM UTC coverage: 58.748% (-0.05%) from 58.793%
25637948193

push

github

web-flow
fix: stabilize Color By switching in vector tiles while dynamic color is enabled (#3416)

* fix: stabilize Color By switching in vector tiles while dynamic color is enabled

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

* add comment

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

* follow up

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

---------

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

7034 of 14352 branches covered (49.01%)

Branch coverage included in aggregate %.

0 of 12 new or added lines in 2 files covered. (0.0%)

55 existing lines in 1 file now uncovered.

14332 of 22017 relevant lines covered (65.1%)

79.51 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
  getPropertyByZoom
71
} from './common-tile/tile-utils';
72

73
export {getNumVectorTilesBeingLoaded} from './loading-counter';
74

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

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

115
type VectorTile = Tile2DHeader<TileContent>;
116

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

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

140
export const vectorTileVisConfigs = {
13✔
141
  ...commonTileVisConfigs,
142

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

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

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

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

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

178
    strokeColorField: VisualChannelField;
179

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

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

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

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

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

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

223
  return [layer];
×
224
}
225

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

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

236
  meta = {};
27✔
237

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

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

259
  get type(): string {
260
    return LAYER_TYPES.vectorTile;
×
261
  }
262

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

267
  get layerIcon(): KeplerLayer['layerIcon'] {
268
    return VectorTileIcon;
27✔
269
  }
270

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

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

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

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

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

344
      uniqueIdField: null
345
    };
346
  }
347

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

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

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

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

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

396
    return defaultDomain;
397
  }
UNCOV
398

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

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

417
    return options;
418
  }
419

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

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

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

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

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

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

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

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

×
489
        tilesetDataUrl = datasetMetadata?.tilesetDataUrl;
490
        tileSource = tilesetDataUrl
491
          ? MVTSource.createDataSource(decodeURIComponent(tilesetDataUrl), {
×
492
              mvt: {
493
                metadataUrl: datasetMetadata?.tilesetMetadataUrl ?? null,
494
                loadOptions: {
495
                  core: {fetch: transformFetch}
496
                }
497
              }
UNCOV
498
            })
×
499
          : null;
500
      } else if (remoteTileFormat === RemoteTileFormat.PMTILES) {
×
UNCOV
501
        // 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)
×
502
        tilesetDataUrl = datasetMetadata?.tilesetDataUrl;
503
        tileSource = tilesetDataUrl ? PMTilesSource.createDataSource(tilesetDataUrl, {}) : null;
504
      }
UNCOV
505
    }
×
506

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

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

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

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

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

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

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

556
    this.setLayerDomain = layerCallbacks.onSetLayerDomain;
×
UNCOV
557

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

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

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

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

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

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

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

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

617
          // TODO: this is hard coded, design a UI to allow user assigned unique property id
618
          uniqueIdProperty,
619
          highlightedFeatureId,
620
          renderSubLayers: this.renderSubLayers,
621
          // when radiusUnits is meter
×
622
          getPointRadiusScaleByZoom: getPropertyByZoom(visConfig.radiusByZoom, visConfig.radius),
×
623
          pointRadiusUnits: visConfig.radiusUnits ? 'pixels' : 'meters',
624
          pointRadiusScale: radiusField ? visConfig.radius : 1,
625

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

721
      return layers;
×
722
    }
723
    return [];
724
  }
725

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

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

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