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

keplergl / kepler.gl / 17358162713

31 Aug 2025 02:03PM UTC coverage: 61.822% (-0.004%) from 61.826%
17358162713

push

github

web-flow
[feat] vector tile layer - add feature uid selector (#3203)

* [feat] vector tile layer - add feature uid selector

Signed-off-by: Ihor Dykhta <dikhta.igor@gmail.com>

* nit

Signed-off-by: Ihor Dykhta <dikhta.igor@gmail.com>

---------

Signed-off-by: Ihor Dykhta <dikhta.igor@gmail.com>

6317 of 12117 branches covered (52.13%)

Branch coverage included in aggregate %.

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

1 existing line in 1 file now uncovered.

13019 of 19160 relevant lines covered (67.95%)

81.9 hits per line

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

6.47
/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/typed';
7
import {_Tile2DHeader as Tile2DHeader} from '@deck.gl/geo-layers/typed';
8
import {GeoJsonLayer, PathLayer} from '@deck.gl/layers/typed';
9
import {MVTSource, MVTTileSource} from '@loaders.gl/mvt';
10
import {PMTilesSource, PMTilesTileSource} from '@loaders.gl/pmtiles';
11
import GL from '@luma.gl/constants';
12
import {ClipExtension} from '@deck.gl/extensions/typed';
13

14
import {notNullorUndefined} from '@kepler.gl/common-utils';
15
import {
16
  getLoaderOptions,
17
  DatasetType,
18
  LAYER_TYPES,
19
  RemoteTileFormat,
20
  VectorTileDatasetMetadata,
21
  SCALE_TYPES,
22
  CHANNEL_SCALES,
23
  DEFAULT_COLOR_UI,
24
  LAYER_VIS_CONFIGS
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 const DEFAULT_HIGHLIGHT_FILL_COLOR = [252, 242, 26, 150];
13✔
74
export const DEFAULT_HIGHLIGHT_STROKE_COLOR = [252, 242, 26, 255];
13✔
75
export const MAX_CACHE_SIZE_MOBILE = 1; // Minimize caching, visible tiles will always be loaded
13✔
76
export const DEFAULT_STROKE_WIDTH = 1;
13✔
77
export const UUID_CANDIDATES = [
13✔
78
  'ufid',
79
  'UFID',
80
  'id',
81
  'ID',
82
  'fid',
83
  'FID',
84
  'objectid',
85
  'OBJECTID',
86
  'gid',
87
  'GID',
88
  'feature_id',
89
  'FEATURE_ID',
90
  '_id'
91
];
92
/**
93
 * Type for transformRequest returned parameters.
94
 */
95
export type RequestParameters = {
96
  /** The URL to be requested. */
97
  url: string;
98
  /** Search parameters to be added onto the URL. */
99
  searchParams: URLSearchParams;
100
  /** Options passed to fetch. */
101
  options: RequestInit;
102
};
103

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

113
type VectorTile = Tile2DHeader<TileContent>;
114

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

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

138
export const vectorTileVisConfigs = {
13✔
139
  ...commonTileVisConfigs,
140

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

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

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

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

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

176
    strokeColorField: VisualChannelField;
177

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

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

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

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

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

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

221
  return [layer];
×
222
}
223

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

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

234
  meta = {};
27✔
235

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

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

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

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

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

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

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

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

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

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

342
      uniqueIdField: null
343
    };
344
  }
345

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

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

360
    const field = this.config[visualChannel.field];
×
361
    const scale = this.config[visualChannel.scale];
×
362
    if (!field) {
×
363
      // if colorField or sizeField were set back to null
364
      return defaultDomain;
×
365
    }
366
    if (scale === SCALE_TYPES.quantile && isDomainQuantiles(field?.filterProps?.domainQuantiles)) {
×
367
      return field.filterProps.domainQuantiles;
×
368
    }
369
    if (isDomainStops(field?.filterProps?.domainStops)) {
×
370
      return field.filterProps.domainStops;
×
371
    } else if (Array.isArray(field?.filterProps?.domain)) {
×
372
      return field.filterProps.domain;
×
373
    }
374

375
    return defaultDomain;
×
376
  }
377

378
  getScaleOptions(channelKey: string): string[] {
379
    let options = KeplerLayer.prototype.getScaleOptions.call(this, channelKey);
×
380

381
    const channel = this.visualChannels.strokeColor;
×
382
    const field = this.config[channel.field];
×
383
    if (
×
384
      !(
385
        isDomainQuantiles(field?.filterProps?.domainQuantiles) ||
×
386
        this.config.visConfig.dynamicColor ||
387
        // If we've set the scale to quantile, we need to include it - there's a loading
388
        // period in which the visConfig isn't set yet, but if we don't return the right
389
        // scale type we lose it
390
        this.config.colorScale === SCALE_TYPES.quantile
391
      )
392
    ) {
393
      options = options.filter(scale => scale !== SCALE_TYPES.quantile);
×
394
    }
395

396
    return options;
×
397
  }
398

399
  accessRowValue(
400
    field?: KeplerField,
401
    indexKey?: number | null
402
  ): (field: KeplerField, datum: Feature) => number | null {
403
    // if is indexed field
404
    if (isIndexedField(field) && indexKey !== null) {
×
405
      const fieldName = indexKey && field?.indexBy?.mappedValue[indexKey];
×
406
      if (fieldName) {
×
407
        return (f, datum) => {
×
408
          if (datum.properties) {
×
409
            return datum.properties[fieldName];
×
410
          }
411
          // TODO debug this with indexed tiled dataset
412
          return datum[fieldName];
×
413
        };
414
      }
415
    }
416

417
    // default
418
    return (f, datum) => {
×
419
      if (f && datum.properties) {
×
420
        return datum.properties[f.name];
×
421
      }
422
      // support picking & highlighting
423
      return f ? datum[f.fieldIdx] : null;
×
424
    };
425
  }
426

427
  updateLayerMeta(dataset: KeplerDataset, datasets: KeplerDatasets): void {
428
    if (dataset.type !== DatasetType.VECTOR_TILE) {
×
429
      return;
×
430
    }
431

432
    const datasetMeta = dataset.metadata as VectorTileMetadata & VectorTileDatasetMetadata;
×
433
    this.updateMeta({
×
434
      datasetId: dataset.id,
435
      datasets,
436
      bounds: datasetMeta.bounds
437
    });
438
  }
439

440
  formatLayerData(
441
    datasets: KeplerDatasets,
442
    oldLayerData: unknown,
443
    animationConfig: AnimationConfig
444
  ): LayerData {
445
    const {dataId} = this.config;
×
446
    if (!notNullorUndefined(dataId)) {
×
447
      return {tileSource: null};
×
448
    }
449
    const dataset = datasets[dataId];
×
450

451
    let tilesetDataUrl: string | undefined;
452
    let tileSource: LayerData['tileSource'] = null;
×
453

454
    if (dataset?.type === DatasetType.VECTOR_TILE) {
×
455
      const datasetMetadata = dataset.metadata as VectorTileMetadata & VectorTileDatasetMetadata;
×
456
      const remoteTileFormat = datasetMetadata?.remoteTileFormat;
×
457
      if (remoteTileFormat === RemoteTileFormat.MVT) {
×
458
        const transformFetch = async (input: RequestInfo | URL, init?: RequestInit | undefined) => {
×
459
          const requestData: RequestParameters = {
×
460
            url: input as string,
461
            searchParams: new URLSearchParams(),
462
            options: init ?? {}
×
463
          };
464

465
          return fetch(requestData.url, requestData.options);
×
466
        };
467

468
        tilesetDataUrl = datasetMetadata?.tilesetDataUrl;
×
469
        tileSource = tilesetDataUrl
×
470
          ? MVTSource.createDataSource(decodeURIComponent(tilesetDataUrl), {
471
              mvt: {
472
                metadataUrl: datasetMetadata?.tilesetMetadataUrl ?? null,
×
473
                loadOptions: {
474
                  fetch: transformFetch
475
                }
476
              }
477
            })
478
          : null;
479
      } else if (remoteTileFormat === RemoteTileFormat.PMTILES) {
×
480
        // 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)
481
        tilesetDataUrl = datasetMetadata?.tilesetDataUrl;
×
482
        tileSource = tilesetDataUrl ? PMTilesSource.createDataSource(tilesetDataUrl, {}) : null;
×
483
      }
484
    }
485

486
    return {
×
487
      ...super.formatLayerData(datasets, oldLayerData, animationConfig),
488
      tilesetDataUrl: typeof tilesetDataUrl === 'string' ? getTileUrl(tilesetDataUrl) : null,
×
489
      tileSource
490
    };
491
  }
492

493
  hasHoveredObject(objectInfo) {
494
    if (super.hasHoveredObject(objectInfo)) {
×
495
      const features = objectInfo?.tile?.content?.features;
×
496
      return features[objectInfo.index];
×
497
    }
498
    return null;
×
499
  }
500

501
  renderSubLayers(props: Record<string, any>): DeckLayer | DeckLayer[] {
502
    let {data} = props;
×
503

504
    data = data?.shape === 'geojson-table' ? data.features : data;
×
505
    if (!data?.length) {
×
506
      return [];
×
507
    }
508

509
    const tile: Tile2DHeader = props.tile;
×
510
    const zoom = tile.index.z;
×
511

512
    return new GeoJsonLayer({
×
513
      ...props,
514
      data,
515
      getFillColor: props.getFillColorByZoom ? props.getFillColor(zoom) : props.getFillColor,
×
516
      getElevation: props.getElevationByZoom ? props.getElevation(zoom) : props.getElevation,
×
517
      // radius for points
518
      pointRadiusScale: props.pointRadiusScale, // props.getPointRadiusScaleByZoom(zoom),
519
      pointRadiusUnits: props.pointRadiusUnits,
520
      getPointRadius: props.getPointRadius,
521
      // For some reason tile Layer reset autoHighlight to false
522
      pickable: true,
523
      autoHighlight: true,
524
      stroked: props.stroked,
525
      // wrapLongitude: true causes missing side polygon when extrude is enabled
526
      wrapLongitude: false
527
    });
528
  }
529

530
  // generate a deck layer
531
  renderLayer(opts: VectorTileLayerRenderOptions): DeckLayer[] {
532
    const {mapState, data, animationConfig, gpuFilter, objectHovered, layerCallbacks} = opts;
×
533
    const {animation, visConfig} = this.config;
×
534

535
    this.setLayerDomain = layerCallbacks.onSetLayerDomain;
×
536

537
    const defaultLayerProps = this.getDefaultDeckLayerProps(opts);
×
538
    const eleZoomFactor = this.getElevationZoomFactor(mapState);
×
539

540
    const transitions = this.config.visConfig.transition
×
541
      ? {
542
          getFillColor: {
543
            duration: animationConfig.duration
544
          },
545
          getElevation: {
546
            duration: animationConfig.duration
547
          }
548
        }
549
      : undefined;
550

551
    const colorField = this.config.colorField as KeplerField;
×
552
    const heightField = this.config.heightField as KeplerField;
×
553
    const strokeColorField = this.config.strokeColorField as KeplerField;
×
554
    const sizeField = this.config.sizeField as KeplerField;
×
555
    const radiusField = this.config.radiusField as KeplerField;
×
556

557
    if (data.tileSource) {
×
558
      const hoveredObject = this.hasHoveredObject(objectHovered);
×
559

560
      // Resolve unique id property: use configured uniqueIdField if set, otherwise infer
561
      let uniqueIdProperty: string | undefined;
562
      let highlightedFeatureId: string | number | undefined;
563
      if (hoveredObject && hoveredObject.properties) {
×
NEW
564
        uniqueIdProperty =
×
565
          this.config.uniqueIdField ??
×
NEW
566
          UUID_CANDIDATES.find(k => hoveredObject.properties && k in hoveredObject.properties);
×
UNCOV
567
        highlightedFeatureId = uniqueIdProperty
×
568
          ? hoveredObject.properties[uniqueIdProperty]
569
          : (hoveredObject as any).id;
570
      }
571

572
      // Build per-tile clipped overlay to draw only the outer stroke of highlighted feature per tile
573
      const perTileOverlays = this._getPerTileOverlays(hoveredObject, {
×
574
        defaultLayerProps,
575
        visConfig,
576
        uniqueIdProperty
577
      });
578

579
      const layers = [
×
580
        new CustomMVTLayer({
581
          ...defaultLayerProps,
582
          ...data,
583
          onViewportLoad: this.onViewportLoad,
584
          data: data.tilesetDataUrl,
585
          getTileData: data.tileSource?.getTileData,
586
          tileSource: data.tileSource,
587
          getFilterValue: this.getGpuFilterValueAccessor(opts),
588
          filterRange: gpuFilter.filterRange,
589
          lineWidthUnits: 'pixels',
590

591
          binary: false,
592
          elevationScale: visConfig.elevationScale * eleZoomFactor,
593
          extruded: visConfig.enable3d,
594
          stroked: visConfig.stroked,
595

596
          // TODO: this is hard coded, design a UI to allow user assigned unique property id
597
          uniqueIdProperty,
598
          highlightedFeatureId,
599
          renderSubLayers: this.renderSubLayers,
600
          // when radiusUnits is meter
601
          getPointRadiusScaleByZoom: getPropertyByZoom(visConfig.radiusByZoom, visConfig.radius),
602
          pointRadiusUnits: visConfig.radiusUnits ? 'pixels' : 'meters',
×
603
          pointRadiusScale: radiusField ? visConfig.radius : 1,
×
604

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

700
      return layers;
×
701
    }
702
    return [];
×
703
  }
704

705
  /**
706
   * Build per-tile clipped overlay to draw only the outer stroke of highlighted feature per tile
707
   * @param hoveredObject
708
   */
709
  _getPerTileOverlays(
710
    hoveredObject: Feature,
711
    options: {defaultLayerProps: any; visConfig: any; uniqueIdProperty?: string}
712
  ): DeckLayer[] {
713
    let perTileOverlays: DeckLayer[] = [];
×
714
    if (hoveredObject) {
×
715
      try {
×
716
        const tiles = this.tileDataset?.getTiles?.() || [];
×
717
        // Derive hovered id from hoveredObject
NEW
718
        const hoveredId = options.uniqueIdProperty
×
719
          ? String(hoveredObject?.properties?.[options.uniqueIdProperty])
720
          : String((hoveredObject as any)?.id);
721

722
        // Group matched fragments by tile id
723
        const byTile: Record<string, Feature[]> = {};
×
724
        for (const tile of tiles) {
×
725
          const content = (tile as any)?.content;
×
726
          const features = content?.shape === 'geojson-table' ? content.features : content;
×
727
          if (!Array.isArray(features)) continue;
×
728
          const tileId = (tile as any).id;
×
729
          for (const f of features) {
×
NEW
730
            const fid = options.uniqueIdProperty
×
731
              ? f.properties?.[options.uniqueIdProperty]
732
              : (f as any).id;
733
            if (fid !== undefined && String(fid) === hoveredId) {
×
734
              (byTile[tileId] = byTile[tileId] || []).push(f as Feature);
×
735
            }
736
          }
737
        }
738

739
        perTileOverlays = Object.entries(byTile).map(([tileId, feats]) => {
×
740
          const tile = tiles.find((t: any) => String(t.id) === String(tileId));
×
741
          const bounds = tile?.boundingBox
×
742
            ? [...tile.boundingBox[0], ...tile.boundingBox[1]]
743
            : undefined;
744
          return new GeoJsonLayer({
×
745
            ...(this.getDefaultHoverLayerProps() as any),
746
            id: `${options.defaultLayerProps.id}-hover-outline-${tileId}`,
747
            visible: true,
748
            wrapLongitude: false,
749
            data: feats,
750
            getLineColor: DEFAULT_HIGHLIGHT_STROKE_COLOR,
751
            getFillColor: [0, 0, 0, 0],
752
            getLineWidth: options.visConfig.strokeWidth + 1,
753
            lineWidthUnits: 'pixels',
754
            lineJointRounded: true,
755
            lineCapRounded: true,
756
            stroked: true,
757
            filled: false,
758
            clipBounds: bounds,
759
            extensions: bounds ? [new ClipExtension()] : []
×
760
          });
761
        });
762
      } catch {
763
        perTileOverlays = [];
×
764
      }
765
    }
766
    return perTileOverlays;
×
767
  }
768
}
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