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

keplergl / kepler.gl / 17349237775

30 Aug 2025 10:23PM UTC coverage: 61.826% (-0.1%) from 61.957%
17349237775

push

github

web-flow
[fix] vector tile layer - use highlightedFeatureId for hover (#3202)

* [fix] vector tile layer - hover - group adjacent

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

* smart outline calculation for features that are part of multiple tiles

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

---------

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

6317 of 12113 branches covered (52.15%)

Branch coverage included in aggregate %.

1 of 31 new or added lines in 2 files covered. (3.23%)

1 existing line in 1 file now uncovered.

13019 of 19162 relevant lines covered (67.94%)

81.87 hits per line

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

6.36
/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
>;
184

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

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

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

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

219
  return [layer];
×
220
}
221

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

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

232
  meta = {};
27✔
233

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

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

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

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

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

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

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

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

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

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

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

350
  calculateLayerDomain(
351
    dataset: KeplerDataset,
352
    visualChannel: VisualChannel
353
  ): DomainStops | number[] {
354
    const defaultDomain = [0, 1];
×
355

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

371
    return defaultDomain;
×
372
  }
373

374
  getScaleOptions(channelKey: string): string[] {
375
    let options = KeplerLayer.prototype.getScaleOptions.call(this, channelKey);
×
376

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

392
    return options;
×
393
  }
394

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

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

423
  updateLayerMeta(dataset: KeplerDataset, datasets: KeplerDatasets): void {
424
    if (dataset.type !== DatasetType.VECTOR_TILE) {
×
425
      return;
×
426
    }
427

428
    const datasetMeta = dataset.metadata as VectorTileMetadata & VectorTileDatasetMetadata;
×
429
    this.updateMeta({
×
430
      datasetId: dataset.id,
431
      datasets,
432
      bounds: datasetMeta.bounds
433
    });
434
  }
435

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

447
    let tilesetDataUrl: string | undefined;
448
    let tileSource: LayerData['tileSource'] = null;
×
449

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

461
          return fetch(requestData.url, requestData.options);
×
462
        };
463

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

482
    return {
×
483
      ...super.formatLayerData(datasets, oldLayerData, animationConfig),
484
      tilesetDataUrl: typeof tilesetDataUrl === 'string' ? getTileUrl(tilesetDataUrl) : null,
×
485
      tileSource
486
    };
487
  }
488

489
  hasHoveredObject(objectInfo) {
490
    if (super.hasHoveredObject(objectInfo)) {
×
491
      const features = objectInfo?.tile?.content?.features;
×
492
      return features[objectInfo.index];
×
493
    }
494
    return null;
×
495
  }
496

497
  renderSubLayers(props: Record<string, any>): DeckLayer | DeckLayer[] {
498
    let {data} = props;
×
499

500
    data = data?.shape === 'geojson-table' ? data.features : data;
×
501
    if (!data?.length) {
×
502
      return [];
×
503
    }
504

505
    const tile: Tile2DHeader = props.tile;
×
506
    const zoom = tile.index.z;
×
507

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

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

531
    this.setLayerDomain = layerCallbacks.onSetLayerDomain;
×
532

533
    const defaultLayerProps = this.getDefaultDeckLayerProps(opts);
×
534
    const eleZoomFactor = this.getElevationZoomFactor(mapState);
×
535

536
    const transitions = this.config.visConfig.transition
×
537
      ? {
538
          getFillColor: {
539
            duration: animationConfig.duration
540
          },
541
          getElevation: {
542
            duration: animationConfig.duration
543
          }
544
        }
545
      : undefined;
546

547
    const colorField = this.config.colorField as KeplerField;
×
548
    const heightField = this.config.heightField as KeplerField;
×
549
    const strokeColorField = this.config.strokeColorField as KeplerField;
×
550
    const sizeField = this.config.sizeField as KeplerField;
×
551
    const radiusField = this.config.radiusField as KeplerField;
×
552

553
    if (data.tileSource) {
×
554
      const hoveredObject = this.hasHoveredObject(objectHovered);
×
555

556
      // Try to infer a stable unique id property from the hovered feature so we can
557
      // highlight the same feature across adjacent tiles. If none is found, rely on
558
      // feature.id when available.
559
      let uniqueIdProperty: string | undefined;
560
      let highlightedFeatureId: string | number | undefined;
NEW
561
      if (hoveredObject && hoveredObject.properties) {
×
NEW
562
        uniqueIdProperty = UUID_CANDIDATES.find(
×
NEW
563
          k => hoveredObject.properties && k in hoveredObject.properties
×
564
        );
NEW
565
        highlightedFeatureId = uniqueIdProperty
×
566
          ? hoveredObject.properties[uniqueIdProperty]
567
          : (hoveredObject as any).id;
568
      }
569

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

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

588
          binary: false,
589
          elevationScale: visConfig.elevationScale * eleZoomFactor,
590
          extruded: visConfig.enable3d,
591
          stroked: visConfig.stroked,
592

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

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

697
      return layers;
×
698
    }
699
    return [];
×
700
  }
701

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

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

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