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

keplergl / kepler.gl / 22361650031

24 Feb 2026 05:09PM UTC coverage: 61.612% (-0.2%) from 61.806%
22361650031

Pull #3219

github

web-flow
Merge 1d9b34cb5 into cc33b0c8f
Pull Request #3219: Update kepler-jupyter to use kepler.gl v3.2.0

6382 of 12288 branches covered (51.94%)

Branch coverage included in aggregate %.

13078 of 19297 relevant lines covered (67.77%)

81.44 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 {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) {
90!
240
      return {props: []};
90✔
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
    if (scale === SCALE_TYPES.quantile && isDomainQuantiles(field?.filterProps?.domainQuantiles)) {
×
369
      return field.filterProps.domainQuantiles;
×
370
    }
371
    if (isDomainStops(field?.filterProps?.domainStops)) {
×
372
      return field.filterProps.domainStops;
×
373
    } else if (Array.isArray(field?.filterProps?.domain)) {
×
374
      return field.filterProps.domain;
×
375
    }
376

377
    return defaultDomain;
×
378
  }
379

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

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

398
    return options;
×
399
  }
400

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

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

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

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

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

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

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

467
          return fetch(requestData.url, requestData.options);
×
468
        };
469

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

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

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

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

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

511
    const tile: Tile2DHeader = props.tile;
×
512
    const zoom = tile.index.z;
×
513

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

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

537
    this.setLayerDomain = layerCallbacks.onSetLayerDomain;
×
538

539
    const defaultLayerProps = this.getDefaultDeckLayerProps(opts);
×
540
    const eleZoomFactor = this.getElevationZoomFactor(mapState);
×
541

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

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

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

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

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

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

593
          binary: false,
594
          elevationScale: visConfig.elevationScale * eleZoomFactor,
595
          extruded: visConfig.enable3d,
596
          stroked: visConfig.stroked,
597

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

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

702
      return layers;
×
703
    }
704
    return [];
×
705
  }
706

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

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

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