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

keplergl / kepler.gl / 19768106976

28 Nov 2025 03:32PM UTC coverage: 61.675% (-0.09%) from 61.76%
19768106976

push

github

web-flow
chore: patch release 3.2.3 (#3250)

* draft

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

* patch

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

* fix eslint during release

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

---------

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

6352 of 12229 branches covered (51.94%)

Branch coverage included in aggregate %.

13043 of 19218 relevant lines covered (67.87%)

81.74 hits per line

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

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

4
import {COORDINATE_SYSTEM} from '@deck.gl/core';
5
import {GeoArrowTextLayer} from '@kepler.gl/deckgl-arrow-layers';
6
import {DataFilterExtension} from '@deck.gl/extensions';
7
import {TextLayer} from '@deck.gl/layers';
8
import {console as Console} from 'global/window';
9
import keymirror from 'keymirror';
10
import React from 'react';
11
import * as arrow from 'apache-arrow';
12
import DefaultLayerIcon from './default-layer-icon';
13
import {diffUpdateTriggers} from './layer-update';
14
import {getSatisfiedColumnMode, FindDefaultLayerPropsReturnValue} from './layer-utils';
15

16
import {
17
  CHANNEL_SCALES,
18
  CHANNEL_SCALE_SUPPORTED_FIELDS,
19
  DEFAULT_COLOR_UI,
20
  DEFAULT_HIGHLIGHT_COLOR,
21
  DEFAULT_LAYER_LABEL,
22
  DEFAULT_TEXT_LABEL,
23
  DataVizColors,
24
  FIELD_OPTS,
25
  LAYER_VIS_CONFIGS,
26
  MAX_GPU_FILTERS,
27
  NO_VALUE_COLOR,
28
  PROJECTED_PIXEL_SIZE_MULTIPLIER,
29
  SCALE_FUNC,
30
  SCALE_TYPES,
31
  TEXT_OUTLINE_MULTIPLIER,
32
  UNKNOWN_COLOR_KEY
33
} from '@kepler.gl/constants';
34
import {
35
  DataContainerInterface,
36
  DomainQuantiles,
37
  getApplicationConfig,
38
  getLatLngBounds,
39
  getSampleContainerData,
40
  hasColorMap,
41
  hexToRgb,
42
  isPlainObject,
43
  isDomainStops,
44
  updateColorRangeByMatchingPalette,
45
  isArrowTable
46
} from '@kepler.gl/utils';
47
import {generateHashId, toArray, notNullorUndefined} from '@kepler.gl/common-utils';
48
import {Datasets, GpuFilter, KeplerTable} from '@kepler.gl/table';
49
import {
50
  AggregatedBin,
51
  ColorRange,
52
  ColorUI,
53
  Field,
54
  Filter,
55
  GetVisChannelScaleReturnType,
56
  LayerVisConfigSettings,
57
  MapState,
58
  AnimationConfig,
59
  KeplerLayer,
60
  LayerBaseConfig,
61
  LayerColumns,
62
  LayerColumn,
63
  ColumnPairs,
64
  ColumnLabels,
65
  SupportedColumnMode,
66
  FieldPair,
67
  NestedPartial,
68
  RGBColor,
69
  ValueOf,
70
  VisualChannel,
71
  VisualChannels,
72
  VisualChannelDomain,
73
  VisualChannelField,
74
  VisualChannelScale
75
} from '@kepler.gl/types';
76
import {
77
  getScaleFunction,
78
  initializeLayerColorMap,
79
  getCategoricalColorScale,
80
  updateCustomColorRangeByColorUI
81
} from '@kepler.gl/utils';
82
import memoize from 'lodash/memoize';
83
import {
84
  initializeCustomPalette,
85
  isDomainQuantile,
86
  getDomainStepsbyZoom,
87
  getThresholdsFromQuantiles
88
} from '@kepler.gl/utils';
89

90
export type {
91
  AggregatedBin,
92
  LayerBaseConfig,
93
  VisualChannel,
94
  VisualChannels,
95
  VisualChannelDomain,
96
  VisualChannelField,
97
  VisualChannelScale
98
};
99

100
export type LayerBaseConfigPartial = {dataId: LayerBaseConfig['dataId']} & Partial<LayerBaseConfig>;
101

102
export type LayerColorConfig = {
103
  colorField: VisualChannelField;
104
  colorDomain: VisualChannelDomain;
105
  colorScale: VisualChannelScale;
106
};
107
export type LayerSizeConfig = {
108
  // color by size, domain is set by filters, field, scale type
109
  sizeDomain: VisualChannelDomain;
110
  sizeScale: VisualChannelScale;
111
  sizeField: VisualChannelField;
112
};
113
export type LayerHeightConfig = {
114
  heightField: VisualChannelField;
115
  heightDomain: VisualChannelDomain;
116
  heightScale: VisualChannelScale;
117
};
118
export type LayerStrokeColorConfig = {
119
  strokeColorField: VisualChannelField;
120
  strokeColorDomain: VisualChannelDomain;
121
  strokeColorScale: VisualChannelScale;
122
};
123
export type LayerCoverageConfig = {
124
  coverageField: VisualChannelField;
125
  coverageDomain: VisualChannelDomain;
126
  coverageScale: VisualChannelScale;
127
};
128
export type LayerRadiusConfig = {
129
  radiusField: VisualChannelField;
130
  radiusDomain: VisualChannelDomain;
131
  radiusScale: VisualChannelScale;
132
};
133
export type LayerWeightConfig = {
134
  weightField: VisualChannelField;
135
};
136

137
export type VisualChannelDescription = {
138
  label: string;
139
  measure: string | undefined;
140
};
141

142
type ColumnValidator = (column: LayerColumn, columns: LayerColumns, allFields: Field[]) => boolean;
143

144
export type UpdateTriggers = {
145
  [key: string]: UpdateTrigger;
146
};
147
export type UpdateTrigger = {
148
  [key: string]: any;
149
};
150
export type LayerBounds = [number, number, number, number];
151

152
/**
153
 * Approx. number of points to sample in a large data set
154
 */
155
export const LAYER_ID_LENGTH = 6;
13✔
156

157
const MAX_SAMPLE_SIZE = 5000;
13✔
158
const defaultDomain: [number, number] = [0, 1];
13✔
159
const dataFilterExtension = new DataFilterExtension({
13✔
160
  filterSize: MAX_GPU_FILTERS,
161
  // `countItems` option. It enables the GPU to report the number of objects that pass the filter criteria via the `onFilteredItemsChange` callback.
162
  // @ts-expect-error not typed
163
  countItems: getApplicationConfig().useOnFilteredItemsChange ?? false
13!
164
});
165

166
// eslint-disable-next-line @typescript-eslint/no-unused-vars
167
const defaultDataAccessor = dc => d => d;
68✔
168
const identity = d => d;
13✔
169
// Can't use fiedValueAccesor because need the raw data to render tooltip
170
// SHAN: Revisit here
171
export const defaultGetFieldValue = (field, d) => field.valueAccessor(d);
107✔
172

173
export const OVERLAY_TYPE_CONST = keymirror({
13✔
174
  deckgl: null,
175
  mapboxgl: null
176
});
177

178
export const layerColors = Object.values(DataVizColors).map(hexToRgb);
13✔
179
function* generateColor(): Generator<RGBColor> {
180
  let index = 0;
3✔
181
  while (index < layerColors.length + 1) {
3✔
182
    if (index === layerColors.length) {
1,106✔
183
      index = 0;
53✔
184
    }
185
    yield layerColors[index++];
1,106✔
186
  }
187
}
188

189
export type LayerInfoModal = {
190
  id: string;
191
  template: React.FC<void>;
192
  modalProps: {
193
    title: string;
194
  };
195
};
196

197
export const colorMaker = generateColor();
13✔
198

199
export type BaseLayerConstructorProps = {
200
  id?: string;
201
} & LayerBaseConfigPartial;
202

203
class Layer implements KeplerLayer {
204
  id: string;
205
  meta: Record<string, any>;
206
  visConfigSettings: {
207
    [key: string]: ValueOf<LayerVisConfigSettings>;
208
  };
209
  config: LayerBaseConfig & Partial<LayerColorConfig & LayerSizeConfig>;
210
  // TODO: define _oldDataUpdateTriggers
211
  _oldDataUpdateTriggers: any;
212

213
  isValid: boolean;
214
  errorMessage: string | null;
215
  filteredItemCount: {
216
    [deckLayerId: string]: number;
217
  };
218

219
  constructor(props: BaseLayerConstructorProps) {
220
    this.id = props.id || generateHashId(LAYER_ID_LENGTH);
877✔
221
    // meta
222
    this.meta = {};
877✔
223

224
    // visConfigSettings
225
    this.visConfigSettings = {};
877✔
226

227
    this.config = this.getDefaultLayerConfig(props);
877✔
228

229
    // set columnMode from supported columns
230
    if (!this.config.columnMode) {
877✔
231
      const {supportedColumnModes} = this;
375✔
232
      if (supportedColumnModes?.length) {
375!
233
        this.config.columnMode = supportedColumnModes[0]?.key;
×
234
      }
235
    }
236
    // then set column, columnMode should already been set
237
    this.config.columns = this.getLayerColumns(props.columns);
877✔
238

239
    // false indicates that the layer caused an error, and was disabled
240
    this.isValid = true;
877✔
241
    this.errorMessage = null;
877✔
242
    // item count
243
    this.filteredItemCount = {};
877✔
244
  }
245

246
  get layerIcon(): React.ElementType {
247
    return DefaultLayerIcon;
×
248
  }
249

250
  get overlayType(): keyof typeof OVERLAY_TYPE_CONST {
251
    return OVERLAY_TYPE_CONST.deckgl;
18✔
252
  }
253

254
  get type(): string | null {
255
    return null;
17✔
256
  }
257

258
  get name(): string | null {
259
    return this.type;
216✔
260
  }
261

262
  get isAggregated() {
263
    return false;
15✔
264
  }
265

266
  get requiredLayerColumns(): string[] {
267
    const {supportedColumnModes} = this;
653✔
268
    if (supportedColumnModes) {
653✔
269
      return supportedColumnModes.reduce<string[]>(
606✔
270
        (acc, obj) => (obj.requiredColumns ? acc.concat(obj.requiredColumns) : acc),
1,608!
271
        []
272
      );
273
    }
274
    return [];
47✔
275
  }
276

277
  get optionalColumns(): string[] {
278
    const {supportedColumnModes} = this;
709✔
279
    if (supportedColumnModes) {
709✔
280
      return supportedColumnModes.reduce<string[]>(
362✔
281
        (acc, obj) => (obj.optionalColumns ? acc.concat(obj.optionalColumns) : acc),
876✔
282
        []
283
      );
284
    }
285
    return [];
347✔
286
  }
287

288
  get noneLayerDataAffectingProps() {
289
    return ['label', 'opacity', 'thickness', 'isVisible', 'hidden'];
40✔
290
  }
291

292
  get visualChannels(): VisualChannels {
293
    return {
7,280✔
294
      color: {
295
        property: 'color',
296
        field: 'colorField',
297
        scale: 'colorScale',
298
        domain: 'colorDomain',
299
        range: 'colorRange',
300
        key: 'color',
301
        channelScaleType: CHANNEL_SCALES.color,
302
        nullValue: NO_VALUE_COLOR,
303
        defaultValue: config => config.color
17✔
304
      },
305
      size: {
306
        property: 'size',
307
        field: 'sizeField',
308
        scale: 'sizeScale',
309
        domain: 'sizeDomain',
310
        range: 'sizeRange',
311
        key: 'size',
312
        channelScaleType: CHANNEL_SCALES.size,
313
        nullValue: 0,
314
        defaultValue: 1
315
      }
316
    };
317
  }
318

319
  get columnValidators(): {[key: string]: ColumnValidator} {
320
    return {};
885✔
321
  }
322
  /*
323
   * Column pairs maps layer column to a specific field pairs,
324
   * By default, it is set to null
325
   */
326
  get columnPairs(): ColumnPairs | null {
327
    return null;
×
328
  }
329

330
  /**
331
   * Column labels if its different than column key
332
   */
333
  get columnLabels(): ColumnLabels | null {
334
    return null;
15✔
335
  }
336

337
  /*
338
   * Default point column pairs, can be used for point based layers: point, icon etc.
339
   */
340
  get defaultPointColumnPairs(): ColumnPairs {
341
    return {
30✔
342
      lat: {pair: ['lng', 'altitude'], fieldPairKey: 'lat'},
343
      lng: {pair: ['lat', 'altitude'], fieldPairKey: 'lng'},
344
      altitude: {pair: ['lng', 'lat'], fieldPairKey: 'altitude'}
345
    };
346
  }
347

348
  /*
349
   * Default link column pairs, can be used for link based layers: arc, line etc
350
   */
351
  get defaultLinkColumnPairs(): ColumnPairs {
352
    return {
2✔
353
      lat: {pair: ['lng', 'alt'], fieldPairKey: 'lat'},
354
      lng: {pair: ['lat', 'alt'], fieldPairKey: 'lng'},
355
      alt: {pair: ['lng', 'lat'], fieldPairKey: 'altitude'},
356

357
      lat0: {pair: 'lng0', fieldPairKey: 'lat'},
358
      lng0: {pair: 'lat0', fieldPairKey: 'lng'},
359
      alt0: {pair: ['lng0', 'lat0'], fieldPairKey: 'altitude'},
360

361
      lat1: {pair: 'lng1', fieldPairKey: 'lat'},
362
      lng1: {pair: 'lat1', fieldPairKey: 'lng'},
363
      alt1: {pair: ['lng1', 'lat1'], fieldPairKey: 'altitude'}
364
    };
365
  }
366

367
  /**
368
   * Return a React component for to render layer instructions in a modal
369
   * @returns {object} - an object
370
   * @example
371
   *  return {
372
   *    id: 'iconInfo',
373
   *    template: IconInfoModal,
374
   *    modalProps: {
375
   *      title: 'How to draw icons'
376
   *   };
377
   * }
378
   */
379
  get layerInfoModal(): LayerInfoModal | Record<string, LayerInfoModal> | null {
380
    return null;
23✔
381
  }
382

383
  /**
384
   * Returns which column modes this layer supports
385
   */
386
  get supportedColumnModes(): SupportedColumnMode[] | null {
387
    return null;
772✔
388
  }
389

390
  get supportedDatasetTypes(): string[] | null {
391
    return null;
3✔
392
  }
393

394
  /*
395
   * Given a dataset, automatically find props to create layer based on it
396
   * and return the props and previous found layers.
397
   * By default, no layers will be found
398
   */
399
  static findDefaultLayerProps(
400
    dataset: KeplerTable,
401
    foundLayers?: any[]
402
  ): FindDefaultLayerPropsReturnValue {
403
    return {props: [], foundLayers};
362✔
404
  }
405

406
  /**
407
   * Given a array of preset required column names
408
   * found field that has the same name to set as layer column
409
   *
410
   * @param {object} defaultFields
411
   * @param {object[]} allFields
412
   * @returns {object[] | null} all possible required layer column pairs
413
   */
414
  static findDefaultColumnField(defaultFields, allFields) {
415
    // find all matched fields for each required col
416
    const requiredColumns = Object.keys(defaultFields).reduce((prev, key) => {
273✔
417
      const requiredFields = allFields.filter(
273✔
418
        f => f.name === defaultFields[key] || defaultFields[key].includes(f.name)
2,081✔
419
      );
420

421
      prev[key] = requiredFields.length
273✔
422
        ? requiredFields.map(f => ({
102✔
423
            value: f.name,
424
            fieldIdx: f.fieldIdx
425
          }))
426
        : null;
427
      return prev;
273✔
428
    }, {});
429

430
    if (!Object.values(requiredColumns).every(Boolean)) {
273✔
431
      // if any field missing, return null
432
      return null;
189✔
433
    }
434

435
    return this.getAllPossibleColumnPairs(requiredColumns);
84✔
436
  }
437

438
  static getAllPossibleColumnPairs(requiredColumns) {
439
    // for multiple matched field for one required column, return multiple
440
    // combinations, e. g. if column a has 2 matched, column b has 3 matched
441
    // 6 possible column pairs will be returned
442
    const allKeys = Object.keys(requiredColumns);
87✔
443
    const pointers = allKeys.map((k, i) => (i === allKeys.length - 1 ? -1 : 0));
89✔
444
    const countPerKey = allKeys.map(k => requiredColumns[k].length);
89✔
445
    // TODO: Better typings
446
    const pairs: any[] = [];
87✔
447

448
    /* eslint-disable no-loop-func */
449
    while (incrementPointers(pointers, countPerKey, pointers.length - 1)) {
87✔
450
      const newPair = pointers.reduce((prev, cuur, i) => {
109✔
451
        prev[allKeys[i]] = requiredColumns[allKeys[i]][cuur];
115✔
452
        return prev;
115✔
453
      }, {});
454

455
      pairs.push(newPair);
109✔
456
    }
457
    /* eslint-enable no-loop-func */
458

459
    // recursively increment pointers
460
    function incrementPointers(pts, counts, index) {
461
      if (index === 0 && pts[0] === counts[0] - 1) {
199✔
462
        // nothing to increment
463
        return false;
87✔
464
      }
465

466
      if (pts[index] + 1 < counts[index]) {
112✔
467
        pts[index] = pts[index] + 1;
109✔
468
        return true;
109✔
469
      }
470

471
      pts[index] = 0;
3✔
472
      return incrementPointers(pts, counts, index - 1);
3✔
473
    }
474

475
    return pairs;
87✔
476
  }
477

478
  static hexToRgb(c) {
479
    return hexToRgb(c);
×
480
  }
481

482
  getDefaultLayerConfig(
483
    props: LayerBaseConfigPartial
484
  ): LayerBaseConfig & Partial<LayerColorConfig & LayerSizeConfig> {
485
    return {
1,233✔
486
      dataId: props.dataId,
487
      label: props.label || DEFAULT_LAYER_LABEL,
2,092✔
488
      color: props.color || colorMaker.next().value,
2,296✔
489
      // set columns later
490
      columns: {},
491
      isVisible: props.isVisible ?? true,
2,165✔
492
      isConfigActive: props.isConfigActive ?? false,
2,452✔
493
      highlightColor: props.highlightColor || DEFAULT_HIGHLIGHT_COLOR,
2,427✔
494
      hidden: props.hidden ?? false,
2,405✔
495

496
      // TODO: refactor this into separate visual Channel config
497
      // color by field, domain is set by filters, field, scale type
498
      colorField: null,
499
      colorDomain: [0, 1],
500
      colorScale: SCALE_TYPES.quantile,
501

502
      // color by size, domain is set by filters, field, scale type
503
      sizeDomain: [0, 1],
504
      sizeScale: SCALE_TYPES.linear,
505
      sizeField: null,
506

507
      visConfig: {},
508

509
      textLabel: [DEFAULT_TEXT_LABEL],
510

511
      colorUI: {
512
        color: DEFAULT_COLOR_UI,
513
        colorRange: DEFAULT_COLOR_UI
514
      },
515
      animation: {enabled: false},
516
      ...(props.columnMode ? {columnMode: props.columnMode} : {})
1,233✔
517
    };
518
  }
519

520
  /**
521
   * Get the description of a visualChannel config
522
   * @param key
523
   * @returns
524
   */
525
  getVisualChannelDescription(key: string): VisualChannelDescription {
526
    // e.g. label: Color, measure: Vehicle Type
527
    const channel = this.visualChannels[key];
50✔
528
    if (!channel) return {label: '', measure: undefined};
50!
529
    const rangeSettings = this.visConfigSettings[channel.range];
50✔
530
    const fieldSettings = this.config[channel.field];
50✔
531
    const label = rangeSettings?.label;
50✔
532
    return {
50✔
533
      label: typeof label === 'function' ? label(this.config) : label || '',
100!
534
      measure: fieldSettings
50✔
535
        ? fieldSettings.displayName || fieldSettings.name
4!
536
        : channel.defaultMeasure
537
    };
538
  }
539

540
  /**
541
   * Assign a field to layer column, return column config
542
   */
543
  assignColumn(key: string, field: {name: string; fieldIdx: number}): LayerColumns {
544
    // field value could be null for optional columns
545
    const update = field
×
546
      ? {
547
          value: field.name,
548
          fieldIdx: field.fieldIdx
549
        }
550
      : {value: null, fieldIdx: -1};
551

552
    return {
×
553
      ...this.config.columns,
554
      [key]: {
555
        ...this.config.columns?.[key],
556
        ...update
557
      }
558
    };
559
  }
560

561
  /**
562
   * Assign a field pair to column config, return column config
563
   */
564
  assignColumnPairs(key: string, fieldPairs: FieldPair): LayerColumns {
565
    if (!this.columnPairs || !this.columnPairs?.[key]) {
1!
566
      // should not end in this state
567
      return this.config.columns;
×
568
    }
569
    // key = 'lat'
570
    const {pair, fieldPairKey} = this.columnPairs?.[key] || {};
1!
571

572
    if (typeof fieldPairKey === 'string' && !fieldPairs[fieldPairKey]) {
1!
573
      // do not allow `key: undefined` to creep into the `updatedColumn` object
574
      return this.config.columns;
×
575
    }
576

577
    // pair = ['lng', 'alt] | 'lng'
578
    const updatedColumn = {
1✔
579
      ...this.config.columns,
580
      // @ts-expect-error fieldPairKey can be string[] here?
581
      [key]: fieldPairs[fieldPairKey]
582
    };
583

584
    const partnerKeys = toArray(pair);
1✔
585
    for (const partnerKey of partnerKeys) {
1✔
586
      if (
2✔
587
        this.config.columns[partnerKey] &&
6✔
588
        this.columnPairs?.[partnerKey] &&
589
        // @ts-ignore
590
        fieldPairs[this.columnPairs?.[partnerKey].fieldPairKey]
591
      ) {
592
        // @ts-ignore
593
        updatedColumn[partnerKey] = fieldPairs[this.columnPairs?.[partnerKey].fieldPairKey];
1✔
594
      }
595
    }
596

597
    return updatedColumn;
1✔
598
  }
599

600
  /**
601
   * Calculate a radius zoom multiplier to render points, so they are visible in all zoom level
602
   * @param {object} mapState
603
   * @param {number} mapState.zoom - actual zoom
604
   * @param {number | void} mapState.zoomOffset - zoomOffset when render in the plot container for export image
605
   * @returns {number}
606
   */
607
  getZoomFactor({zoom, zoomOffset = 0}) {
56✔
608
    return Math.pow(2, Math.max(14 - zoom + zoomOffset, 0));
56✔
609
  }
610

611
  /**
612
   * Calculate a elevation zoom multiplier to render points, so they are visible in all zoom level
613
   * @param {object} mapState
614
   * @param {number} mapState.zoom - actual zoom
615
   * @param {number=} mapState.zoomOffset - zoomOffset when render in the plot container for export image
616
   * @returns {number}
617
   */
618
  getElevationZoomFactor({zoom, zoomOffset = 0}: {zoom: number; zoomOffset?: number}): number {
8✔
619
    // enableElevationZoomFactor is used to support existing maps
620
    const {fixedHeight, enableElevationZoomFactor} = this.config.visConfig;
8✔
621
    return fixedHeight || enableElevationZoomFactor === false
8!
622
      ? 1
623
      : Math.pow(2, Math.max(8 - zoom + zoomOffset, 0));
624
  }
625

626
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
627
  formatLayerData(datasets: Datasets, oldLayerData?: unknown, animationConfig?: AnimationConfig) {
628
    return {};
×
629
  }
630

631
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
632
  renderLayer(...args: any[]): any[] {
633
    return [];
×
634
  }
635

636
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
637
  getHoverData(
638
    object: any,
639
    dataContainer: DataContainerInterface,
640
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
641
    fields?: Field[],
642
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
643
    animationConfig?: AnimationConfig,
644
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
645
    hoverInfo?: {index: number}
646
  ): any {
647
    if (!object) {
×
648
      return null;
×
649
    }
650

651
    // By default, each entry of layerData should have an index of a row in the original data container.
652
    // Each layer can implement its own getHoverData method
653
    return dataContainer.row(object.index);
×
654
  }
655

656
  getFilteredItemCount(): number | null {
657
    // use first layer
658
    if (Object.keys(this.filteredItemCount).length) {
×
659
      const firstLayer = Object.keys(this.filteredItemCount)[0];
×
660
      return this.filteredItemCount[firstLayer];
×
661
    }
662
    return null;
×
663
  }
664
  /**
665
   * When change layer type, try to copy over layer configs as much as possible
666
   * @param configToCopy - config to copy over
667
   * @param visConfigSettings - visConfig settings of config to copy
668
   * @param datasets - current datasets.
669
   * @param defaultLayerProps - default layer creation configurations for current layer and datasets.
670
   */
671
  assignConfigToLayer(
672
    configToCopy: LayerBaseConfig & Partial<LayerColorConfig & LayerSizeConfig>,
673
    visConfigSettings: {[key: string]: ValueOf<LayerVisConfigSettings>},
674
    datasets?: Datasets,
675
    defaultLayerProps?: FindDefaultLayerPropsReturnValue | null
676
  ) {
677
    // don't deep merge visualChannel field
678
    // don't deep merge color range, reversed: is not a key by default
679
    const shallowCopy = ['colorRange', 'strokeColorRange'].concat(
6✔
680
      Object.values(this.visualChannels).map(v => v.field)
15✔
681
    );
682

683
    // don't copy over domain and animation
684
    const notToCopy = ['animation'].concat(Object.values(this.visualChannels).map(v => v.domain));
15✔
685
    // if range is for the same property group copy it, otherwise, not to copy
686
    Object.values(this.visualChannels).forEach(v => {
6✔
687
      if (
15!
688
        configToCopy.visConfig[v.range] &&
33✔
689
        this.visConfigSettings[v.range] &&
690
        visConfigSettings[v.range].group !== this.visConfigSettings[v.range].group
691
      ) {
692
        notToCopy.push(v.range);
×
693
      }
694
    });
695

696
    // don't copy over visualChannel range
697
    const currentConfig = this.config;
6✔
698
    const copied = this.copyLayerConfig(currentConfig, configToCopy, {
6✔
699
      shallowCopy,
700
      notToCopy
701
    });
702

703
    // update columNode based on new columns
704
    if (this.config.columnMode && this.supportedColumnModes) {
6✔
705
      const dataset = datasets?.[this.config.dataId];
3✔
706
      // try to find a mode with all requied columns from the source config
707
      let satisfiedColumnMode = getSatisfiedColumnMode(
3✔
708
        this.supportedColumnModes,
709
        copied.columns,
710
        dataset?.fields
711
      );
712

713
      // if no suitable column mode found or no such columMode exists for the layer
714
      // then try use one of the automatically detected layer configs
715
      if (!satisfiedColumnMode) {
3✔
716
        const options = [
1✔
717
          ...(defaultLayerProps?.props || []),
1!
718
          ...(defaultLayerProps?.altProps || [])
2✔
719
        ];
720
        if (options.length) {
1!
721
          // Use the first of the default configurations
722
          const defaultColumnConfig = options[0].columns;
×
723

724
          satisfiedColumnMode = getSatisfiedColumnMode(
×
725
            this.supportedColumnModes,
726
            defaultColumnConfig,
727
            dataset?.fields
728
          );
729

730
          if (satisfiedColumnMode) {
×
731
            copied.columns = {
×
732
              ...copied.columns,
733
              ...defaultColumnConfig
734
            };
735
          }
736
        }
737
      }
738

739
      copied.columnMode = satisfiedColumnMode?.key || copied.columnMode;
3✔
740
    }
741

742
    this.updateLayerConfig(copied);
6✔
743
    // validate visualChannel field type and scale types
744
    Object.keys(this.visualChannels).forEach(channel => {
6✔
745
      this.validateVisualChannel(channel);
15✔
746
    });
747
  }
748

749
  /*
750
   * Recursively copy config over to an empty layer
751
   * when received saved config, or copy config over from a different layer type
752
   * make sure to only copy over value to existing keys
753
   * @param {object} currentConfig - existing config to be override
754
   * @param {object} configToCopy - new Config to copy over
755
   * @param {string[]} shallowCopy - array of properties to not to be deep copied
756
   * @param {string[]} notToCopy - array of properties not to copy
757
   * @returns {object} - copied config
758
   */
759
  copyLayerConfig(
760
    currentConfig,
761
    configToCopy,
762
    {shallowCopy = [], notToCopy = []}: {shallowCopy?: string[]; notToCopy?: string[]} = {}
157!
763
  ) {
764
    const copied: {columnMode?: string; columns?: LayerColumns} = {};
221✔
765
    Object.keys(currentConfig).forEach(key => {
221✔
766
      if (
2,195✔
767
        isPlainObject(currentConfig[key]) &&
2,798✔
768
        isPlainObject(configToCopy[key]) &&
769
        !shallowCopy.includes(key) &&
770
        !notToCopy.includes(key)
771
      ) {
772
        // recursively assign object value
773
        copied[key] = this.copyLayerConfig(currentConfig[key], configToCopy[key], {
58✔
774
          shallowCopy,
775
          notToCopy
776
        });
777
      } else if (notNullorUndefined(configToCopy[key]) && !notToCopy.includes(key)) {
2,137✔
778
        // copy
779
        copied[key] = configToCopy[key];
1,004✔
780
      } else {
781
        // keep existing
782
        copied[key] = currentConfig[key];
1,133✔
783
      }
784
    });
785

786
    return copied;
221✔
787
  }
788

789
  registerVisConfig(layerVisConfigs: {
790
    [key: string]: keyof LayerVisConfigSettings | ValueOf<LayerVisConfigSettings>;
791
  }) {
792
    Object.keys(layerVisConfigs).forEach(item => {
990✔
793
      const configItem = layerVisConfigs[item];
10,714✔
794
      if (typeof configItem === 'string' && LAYER_VIS_CONFIGS[configItem]) {
10,714✔
795
        // if assigned one of default LAYER_CONFIGS
796
        this.config.visConfig[item] = LAYER_VIS_CONFIGS[configItem].defaultValue;
8,303✔
797
        this.visConfigSettings[item] = LAYER_VIS_CONFIGS[configItem];
8,303✔
798
      } else if (
2,411✔
799
        typeof configItem === 'object' &&
4,730✔
800
        ['type', 'defaultValue'].every(p => Object.prototype.hasOwnProperty.call(configItem, p))
4,638✔
801
      ) {
802
        // if provided customized visConfig, and has type && defaultValue
803
        // TODO: further check if customized visConfig is valid
804
        this.config.visConfig[item] = configItem.defaultValue;
2,319✔
805
        this.visConfigSettings[item] = configItem;
2,319✔
806
      }
807
    });
808
  }
809

810
  getLayerColumns(propsColumns = {}) {
833✔
811
    const columnValidators = this.columnValidators || {};
1,037!
812
    const required = this.requiredLayerColumns.reduce(
1,037✔
813
      (accu, key) => ({
3,795✔
814
        ...accu,
815
        [key]: columnValidators[key]
3,795✔
816
          ? {
817
              value: propsColumns[key]?.value ?? null,
1,612✔
818
              fieldIdx: propsColumns[key]?.fieldIdx ?? -1,
1,566✔
819
              validator: columnValidators[key]
820
            }
821
          : {value: propsColumns[key]?.value ?? null, fieldIdx: propsColumns[key]?.fieldIdx ?? -1}
10,967✔
822
      }),
823
      {}
824
    );
825
    const optional = this.optionalColumns.reduce(
1,037✔
826
      (accu, key) => ({
1,051✔
827
        ...accu,
828
        [key]: {
829
          value: propsColumns[key]?.value ?? null,
2,099✔
830
          fieldIdx: propsColumns[key]?.fieldIdx ?? -1,
1,934✔
831
          optional: true
832
        }
833
      }),
834
      {}
835
    );
836

837
    const columns = {...required, ...optional};
1,037✔
838

839
    return columns;
1,037✔
840
  }
841

842
  updateLayerConfig<
843
    LayerConfig extends LayerBaseConfig &
844
      Partial<LayerColorConfig & LayerSizeConfig> = LayerBaseConfig
845
  >(newConfig: Partial<LayerConfig>): Layer {
846
    this.config = {...this.config, ...newConfig};
1,899✔
847
    return this;
1,899✔
848
  }
849

850
  updateLayerVisConfig(newVisConfig) {
851
    this.config.visConfig = {...this.config.visConfig, ...newVisConfig};
137✔
852
    return this;
137✔
853
  }
854

855
  updateLayerColorUI(prop: string, newConfig: NestedPartial<ColorUI>): Layer {
856
    const {colorUI: previous, visConfig} = this.config;
53✔
857

858
    if (!isPlainObject(newConfig) || typeof prop !== 'string') {
53!
859
      return this;
×
860
    }
861

862
    const colorUIProp = Object.entries(newConfig).reduce((accu, [key, value]) => {
53✔
863
      return {
60✔
864
        ...accu,
865
        [key]:
866
          isPlainObject(accu[key]) && isPlainObject(value)
161✔
867
            ? {...accu[key], ...(value as Record<string, unknown>)}
868
            : value
869
      };
870
    }, previous[prop] || DEFAULT_COLOR_UI);
53!
871

872
    const colorUI = {
53✔
873
      ...previous,
874
      [prop]: colorUIProp
875
    };
876

877
    this.updateLayerConfig({colorUI});
53✔
878
    // if colorUI[prop] is colorRange
879
    const isColorRange = visConfig[prop] && visConfig[prop].colors;
53✔
880

881
    if (isColorRange) {
53✔
882
      // if open dropdown and prop is color range
883
      // Automatically set colorRangeConfig's step and reversed
884
      this.updateColorUIByColorRange(newConfig, prop);
49✔
885

886
      // if changes in UI is made to 'reversed', 'steps' or steps
887
      // update current layer colorRange
888
      this.updateColorRangeByColorUI(newConfig, previous, prop);
49✔
889

890
      // if set colorRangeConfig to custom
891
      // initiate customPalette to be edited in the ui
892
      this.updateCustomPalette(newConfig, previous, prop);
49✔
893
    }
894

895
    return this;
53✔
896
  }
897

898
  // if set colorRangeConfig to custom palette or custom breaks
899
  // initiate customPalette to be edited in the ui
900
  updateCustomPalette(newConfig, previous, prop) {
901
    if (!newConfig.colorRangeConfig?.custom && !newConfig.colorRangeConfig?.customBreaks) {
49✔
902
      return;
38✔
903
    }
904

905
    if (newConfig.customPalette) {
11✔
906
      // if new config also set customPalette, no need to initiate new
907
      return;
2✔
908
    }
909
    const {colorUI, visConfig} = this.config;
9✔
910

911
    if (!visConfig[prop]) return;
9!
912
    // make copy of current color range to customPalette
913
    let customPalette = {
9✔
914
      ...visConfig[prop]
915
    };
916

917
    if (newConfig.colorRangeConfig.customBreaks && !customPalette.colorMap) {
9✔
918
      // find visualChanel
919
      const visualChannels = this.visualChannels;
1✔
920
      const channelKey = Object.keys(visualChannels).find(
1✔
921
        key => visualChannels[key].range === prop
1✔
922
      );
923
      if (!channelKey) {
1!
924
        // should never happn
925
        Console.warn(`updateColorUI: Can't find visual channel which range is ${prop}`);
×
926
        return;
×
927
      }
928
      // add name|type|category to updateCustomPalette if customBreaks, so that
929
      // colors will not be override as well when inverse palette with custom break
930
      // initiate colorMap from current scale
931

932
      const colorMap = initializeLayerColorMap(this, visualChannels[channelKey]);
1✔
933
      customPalette = initializeCustomPalette(visConfig[prop], colorMap);
1✔
934
    } else if (newConfig.colorRangeConfig.custom) {
8!
935
      customPalette = initializeCustomPalette(visConfig[prop]);
8✔
936
    }
937

938
    this.updateLayerConfig({
9✔
939
      colorUI: {
940
        ...colorUI,
941
        [prop]: {
942
          ...colorUI[prop],
943
          customPalette
944
        }
945
      }
946
    });
947
  }
948

949
  /**
950
   * if open dropdown and prop is color range
951
   * Automatically set colorRangeConfig's step and reversed
952
   * @param {*} newConfig
953
   * @param {*} prop
954
   */
955
  updateColorUIByColorRange(newConfig, prop) {
956
    const {colorUI, visConfig} = this.config;
49✔
957

958
    // when custom palette adds/removes step, the number in "Steps" input control
959
    // should be updated as well
960
    const isCustom = newConfig.customPalette?.category === 'Custom';
49✔
961
    const customStepsChanged = isCustom
49✔
962
      ? newConfig.customPalette.colors.length !== visConfig[prop].colors.length
963
      : false;
964

965
    if (typeof newConfig.showDropdown !== 'number' && !customStepsChanged) return;
49✔
966

967
    this.updateLayerConfig({
10✔
968
      colorUI: {
969
        ...colorUI,
970
        [prop]: {
971
          ...colorUI[prop],
972
          colorRangeConfig: {
973
            ...colorUI[prop].colorRangeConfig,
974
            steps: customStepsChanged
10✔
975
              ? colorUI[prop].customPalette.colors.length
976
              : visConfig[prop].colors.length,
977
            reversed: Boolean(visConfig[prop].reversed)
978
          }
979
        }
980
      }
981
    });
982
  }
983

984
  updateColorRangeByColorUI(newConfig, previous, prop) {
985
    // only update colorRange if changes in UI is made to 'reversed', 'steps' or steps
986
    const shouldUpdate =
987
      newConfig.colorRangeConfig &&
49✔
988
      ['reversed', 'steps', 'colorBlindSafe', 'type'].some(
989
        key =>
990
          Object.prototype.hasOwnProperty.call(newConfig.colorRangeConfig, key) &&
128✔
991
          newConfig.colorRangeConfig[key] !==
992
            (previous[prop] || DEFAULT_COLOR_UI).colorRangeConfig[key]
54!
993
      );
994
    if (!shouldUpdate) return;
49✔
995

996
    const {colorUI, visConfig} = this.config;
6✔
997

998
    // for custom palette, one can only 'reverse' the colors in custom palette.
999
    // changing 'steps', 'colorBindSafe', 'type' should fall back to predefined palette.
1000
    const isCustomColorReversed =
1001
      visConfig.colorRange.category === 'Custom' &&
6✔
1002
      newConfig.colorRangeConfig &&
1003
      Object.prototype.hasOwnProperty.call(newConfig.colorRangeConfig, 'reversed');
1004

1005
    const update = isCustomColorReversed
6✔
1006
      ? updateCustomColorRangeByColorUI(visConfig[prop], colorUI[prop].colorRangeConfig)
1007
      : updateColorRangeByMatchingPalette(visConfig[prop], colorUI[prop].colorRangeConfig);
1008

1009
    if (update) {
6!
1010
      this.updateLayerVisConfig({[prop]: update});
6✔
1011
    }
1012
  }
1013
  hasColumnValue(column?: LayerColumn) {
1014
    return Boolean(column && column.value && column.fieldIdx > -1);
1,060✔
1015
  }
1016
  hasRequiredColumn(column?: LayerColumn) {
1017
    return Boolean(column && (column.optional || this.hasColumnValue(column)));
137✔
1018
  }
1019
  /**
1020
   * Check whether layer has all columns
1021
   * @returns yes or no
1022
   */
1023
  hasAllColumns(): boolean {
1024
    const {columns, columnMode} = this.config;
542✔
1025
    // if layer has different column mode, check if have all required columns of current column Mode
1026
    if (columnMode) {
542✔
1027
      const currentColumnModes = (this.supportedColumnModes || []).find(
464✔
1028
        colMode => colMode.key === columnMode
469✔
1029
      );
1030
      return Boolean(
464✔
1031
        currentColumnModes !== undefined &&
926✔
1032
          currentColumnModes.requiredColumns?.every(colKey => this.hasColumnValue(columns[colKey]))
788✔
1033
      );
1034
    }
1035
    return Boolean(
78✔
1036
      columns &&
156✔
1037
        Object.values(columns).every((column?: LayerColumn) => this.hasRequiredColumn(column))
137✔
1038
    );
1039
  }
1040

1041
  /**
1042
   * Check whether layer has data
1043
   *
1044
   * @param {Array | Object} layerData
1045
   * @returns {boolean} yes or no
1046
   */
1047
  hasLayerData(layerData: {data: unknown[] | arrow.Table}) {
1048
    if (!layerData) {
38!
1049
      return false;
×
1050
    }
1051

1052
    return Boolean(
38✔
1053
      layerData.data &&
76!
1054
        ((layerData.data as unknown[]).length || (layerData.data as arrow.Table).numRows)
1055
    );
1056
  }
1057

1058
  isValidToSave(): boolean {
1059
    return Boolean(this.type && this.hasAllColumns());
139✔
1060
  }
1061

1062
  shouldRenderLayer(data): boolean {
1063
    return (
38✔
1064
      Boolean(this.type) &&
152✔
1065
      this.hasAllColumns() &&
1066
      this.hasLayerData(data) &&
1067
      typeof this.renderLayer === 'function'
1068
    );
1069
  }
1070

1071
  getColorScale(
1072
    colorScale: string,
1073
    colorDomain: VisualChannelDomain,
1074
    colorRange: ColorRange
1075
  ): GetVisChannelScaleReturnType {
1076
    if (colorScale === SCALE_TYPES.customOrdinal) {
140!
1077
      return getCategoricalColorScale(colorDomain, colorRange);
×
1078
    }
1079

1080
    if (hasColorMap(colorRange) && colorScale === SCALE_TYPES.custom) {
140✔
1081
      const cMap = new Map();
7✔
1082
      colorRange.colorMap?.forEach(([k, v]) => {
7✔
1083
        cMap.set(k, typeof v === 'string' ? hexToRgb(v) : v);
25!
1084
      });
1085

1086
      const scaleType = colorScale === SCALE_TYPES.custom ? colorScale : SCALE_TYPES.ordinal;
7!
1087

1088
      const scale = getScaleFunction(scaleType, cMap.values(), cMap.keys(), false);
7✔
1089
      scale.unknown(cMap.get(UNKNOWN_COLOR_KEY) || NO_VALUE_COLOR);
7✔
1090

1091
      return scale as GetVisChannelScaleReturnType;
7✔
1092
    }
1093
    return this.getVisChannelScale(colorScale, colorDomain, colorRange.colors.map(hexToRgb));
133✔
1094
  }
1095

1096
  accessVSFieldValue(_field, _indexKey) {
1097
    return defaultGetFieldValue;
146✔
1098
  }
1099
  /**
1100
   * Mapping from visual channels to deck.gl accesors
1101
   * @param param Parameters
1102
   * @param param.dataAccessor Access kepler.gl layer data from deck.gl layer
1103
   * @param param.dataContainer DataContainer to use use with dataAccessor
1104
   * @return {Object} attributeAccessors - deck.gl layer attribute accessors
1105
   */
1106
  getAttributeAccessors({
1107
    dataAccessor = defaultDataAccessor,
342✔
1108
    dataContainer,
1109
    indexKey
1110
  }: {
1111
    dataAccessor?: typeof defaultDataAccessor;
1112
    dataContainer: DataContainerInterface;
1113
    indexKey?: number | null;
1114
  }) {
1115
    const attributeAccessors: {[key: string]: any} = {};
444✔
1116

1117
    Object.keys(this.visualChannels).forEach(channel => {
444✔
1118
      const {
1119
        field,
1120
        fixed,
1121
        scale,
1122
        domain,
1123
        range,
1124
        accessor,
1125
        defaultValue,
1126
        getAttributeValue,
1127
        nullValue,
1128
        channelScaleType
1129
      } = this.visualChannels[channel];
1,485✔
1130

1131
      if (accessor) {
1,485!
1132
        const shouldGetScale = this.config[field];
1,485✔
1133

1134
        if (shouldGetScale) {
1,485✔
1135
          const isFixed = fixed && this.config.visConfig[fixed];
149✔
1136

1137
          const scaleFunction =
1138
            channelScaleType === CHANNEL_SCALES.color
149✔
1139
              ? this.getColorScale(
1140
                  this.config[scale],
1141
                  this.config[domain],
1142
                  this.config.visConfig[range]
1143
                )
1144
              : this.getVisChannelScale(
1145
                  this.config[scale],
1146
                  this.config[domain],
1147
                  this.config.visConfig[range],
1148
                  isFixed
1149
                );
1150

1151
          const getFieldValue = this.accessVSFieldValue(this.config[field], indexKey);
149✔
1152

1153
          if (scaleFunction) {
149!
1154
            attributeAccessors[accessor] = scaleFunction.byZoom
149✔
1155
              ? memoize(z => {
1156
                  const scaleFunc = scaleFunction(z);
1✔
1157
                  return d =>
1✔
1158
                    this.getEncodedChannelValue(
6✔
1159
                      scaleFunc,
1160
                      dataAccessor(dataContainer)(d),
1161
                      this.config[field],
1162
                      nullValue,
1163
                      getFieldValue
1164
                    );
1165
                })
1166
              : d =>
1167
                  this.getEncodedChannelValue(
76✔
1168
                    scaleFunction,
1169
                    dataAccessor(dataContainer)(d),
1170
                    this.config[field],
1171
                    nullValue,
1172
                    getFieldValue
1173
                  );
1174

1175
            // set getFillColorByZoom to true
1176
            if (scaleFunction.byZoom) {
149✔
1177
              attributeAccessors[`${accessor}ByZoom`] = true;
1✔
1178
            }
1179
          }
1180
        } else if (typeof getAttributeValue === 'function') {
1,336✔
1181
          attributeAccessors[accessor] = getAttributeValue(this.config);
427✔
1182
        } else {
1183
          attributeAccessors[accessor] =
909✔
1184
            typeof defaultValue === 'function' ? defaultValue(this.config) : defaultValue;
909✔
1185
        }
1186

1187
        if (!attributeAccessors[accessor]) {
1,485!
1188
          Console.warn(`Failed to provide accessor function for ${accessor || channel}`);
×
1189
        }
1190
      }
1191
    });
1192

1193
    return attributeAccessors;
444✔
1194
  }
1195

1196
  getVisChannelScale(
1197
    scale: string,
1198
    domain: VisualChannelDomain | DomainQuantiles,
1199
    range: any,
1200
    fixed?: boolean
1201
  ): GetVisChannelScaleReturnType {
1202
    // if quantile is provided per zoom
1203
    if (isDomainQuantile(domain) && scale === SCALE_TYPES.quantile) {
161!
1204
      const zSteps = domain.z;
×
1205

1206
      const getScale = function getScaleByZoom(z) {
×
1207
        const scaleDomain = getDomainStepsbyZoom(domain.quantiles, zSteps, z);
×
1208
        const thresholds = getThresholdsFromQuantiles(scaleDomain, range.length);
×
1209

1210
        return getScaleFunction('threshold', range, thresholds, false);
×
1211
      };
1212

1213
      getScale.byZoom = true;
×
1214
      return getScale;
×
1215
    } else if (isDomainStops(domain)) {
161✔
1216
      // color is based on zoom
1217
      const zSteps = domain.z;
2✔
1218
      // get scale function by z
1219
      // {
1220
      //  z: [z, z, z],
1221
      //  stops: [[min, max], [min, max]],
1222
      //  interpolation: 'interpolate'
1223
      // }
1224

1225
      const getScale = function getScaleByZoom(z) {
2✔
1226
        const scaleDomain = getDomainStepsbyZoom(domain.stops, zSteps, z);
4✔
1227

1228
        return getScaleFunction(scale, range, scaleDomain, fixed);
4✔
1229
      };
1230

1231
      getScale.byZoom = true;
2✔
1232
      return getScale;
2✔
1233
    }
1234

1235
    return SCALE_FUNC[fixed ? 'linear' : scale]()
159✔
1236
      .domain(domain)
1237
      .range(fixed ? domain : range);
159✔
1238
  }
1239

1240
  /**
1241
   * Get longitude and latitude bounds of the data.
1242
   */
1243
  getPointsBounds(
1244
    dataContainer: DataContainerInterface,
1245
    getPosition: (x: any, dc: DataContainerInterface) => number[] = identity
×
1246
  ): number[] | null {
1247
    // no need to loop through the entire dataset
1248
    // get a sample of data to calculate bounds
1249
    const sampleData =
1250
      dataContainer.numRows() > MAX_SAMPLE_SIZE
333!
1251
        ? getSampleContainerData(dataContainer, MAX_SAMPLE_SIZE)
1252
        : dataContainer;
1253

1254
    const points = getPosition ? sampleData.mapIndex(getPosition) : [];
333!
1255

1256
    const latBounds = getLatLngBounds(points, 1, [-90, 90]);
333✔
1257
    const lngBounds = getLatLngBounds(points, 0, [-180, 180]);
333✔
1258

1259
    if (!latBounds || !lngBounds) {
333!
1260
      return null;
×
1261
    }
1262

1263
    return [lngBounds[0], latBounds[0], lngBounds[1], latBounds[1]];
333✔
1264
  }
1265

1266
  getChangedTriggers(dataUpdateTriggers) {
1267
    const triggerChanged = diffUpdateTriggers(dataUpdateTriggers, this._oldDataUpdateTriggers);
491✔
1268
    this._oldDataUpdateTriggers = dataUpdateTriggers;
491✔
1269

1270
    return triggerChanged;
491✔
1271
  }
1272

1273
  getEncodedChannelValue(
1274
    scale: (value) => any,
1275
    data: any[],
1276
    field: VisualChannelField,
1277
    nullValue = NO_VALUE_COLOR,
5✔
1278
    getValue = defaultGetFieldValue
×
1279
  ) {
1280
    const value = getValue(field, data);
82✔
1281

1282
    if (!notNullorUndefined(value)) {
82✔
1283
      return nullValue;
4✔
1284
    }
1285

1286
    let attributeValue;
1287
    if (Array.isArray(value)) {
78!
1288
      attributeValue = value.map(scale);
×
1289
    } else {
1290
      attributeValue = scale(value);
78✔
1291
    }
1292

1293
    if (!notNullorUndefined(attributeValue)) {
78!
1294
      attributeValue = nullValue;
×
1295
    }
1296

1297
    return attributeValue;
78✔
1298
  }
1299

1300
  updateMeta(meta: Layer['meta']) {
1301
    this.meta = {...this.meta, ...meta};
352✔
1302
  }
1303

1304
  getDataUpdateTriggers({filteredIndex, id, dataContainer}: KeplerTable): any {
1305
    const {columns} = this.config;
484✔
1306

1307
    return {
484✔
1308
      getData: {datasetId: id, dataContainer, columns, filteredIndex},
1309
      getMeta: {datasetId: id, dataContainer, columns},
1310
      ...(this.config.textLabel || []).reduce(
484!
1311
        (accu, tl, i) => ({
493✔
1312
          ...accu,
1313
          [`getLabelCharacterSet-${i}`]: tl.field ? tl.field.name : null
493✔
1314
        }),
1315
        {}
1316
      )
1317
    };
1318
  }
1319

1320
  updateData(datasets: Datasets, oldLayerData: any) {
1321
    if (!this.config.dataId) {
486!
1322
      return {};
×
1323
    }
1324
    const layerDataset = datasets[this.config.dataId];
486✔
1325
    const {dataContainer} = layerDataset;
486✔
1326

1327
    const getPosition = this.getPositionAccessor(dataContainer, layerDataset);
486✔
1328
    const dataUpdateTriggers = this.getDataUpdateTriggers(layerDataset);
486✔
1329
    const triggerChanged = this.getChangedTriggers(dataUpdateTriggers);
486✔
1330

1331
    if (triggerChanged && (triggerChanged.getMeta || triggerChanged.getData)) {
486✔
1332
      this.updateLayerMeta(layerDataset, getPosition);
346✔
1333

1334
      // reset filteredItemCount
1335
      this.filteredItemCount = {};
346✔
1336
    }
1337

1338
    let data = [];
486✔
1339

1340
    if (!(triggerChanged && triggerChanged.getData) && oldLayerData && oldLayerData.data) {
486✔
1341
      // same data
1342
      data = oldLayerData.data;
139✔
1343
    } else {
1344
      data = this.calculateDataAttribute(layerDataset, getPosition);
347✔
1345
    }
1346

1347
    return {data, triggerChanged};
486✔
1348
  }
1349

1350
  /**
1351
   * helper function to update one layer domain when state.data changed
1352
   * if state.data change is due ot update filter, newFiler will be passed
1353
   * called by updateAllLayerDomainData
1354
   * @param datasets
1355
   * @param newFilter
1356
   * @returns layer
1357
   */
1358
  updateLayerDomain(datasets: Datasets, newFilter?: Filter): Layer {
1359
    const table = this.getDataset(datasets);
321✔
1360
    if (!table) {
321!
1361
      return this;
×
1362
    }
1363
    Object.values(this.visualChannels).forEach(channel => {
321✔
1364
      const {scale} = channel;
1,098✔
1365
      const scaleType = this.config[scale];
1,098✔
1366
      // ordinal domain is based on dataContainer, if only filter changed
1367
      // no need to update ordinal domain
1368
      if (!newFilter || scaleType !== SCALE_TYPES.ordinal) {
1,098✔
1369
        const {domain} = channel;
1,094✔
1370
        const updatedDomain = this.calculateLayerDomain(table, channel);
1,094✔
1371
        this.updateLayerConfig({[domain]: updatedDomain});
1,094✔
1372
      }
1373
    });
1374

1375
    return this;
321✔
1376
  }
1377

1378
  getDataset(datasets) {
1379
    return this.config.dataId ? datasets[this.config.dataId] : null;
321!
1380
  }
1381

1382
  /**
1383
   * Validate visual channel field and scales based on supported field & scale type
1384
   * @param channel
1385
   */
1386
  validateVisualChannel(channel: string) {
1387
    this.validateFieldType(channel);
456✔
1388
    this.validateScale(channel);
456✔
1389
  }
1390

1391
  /**
1392
   * Validate field type based on channelScaleType
1393
   */
1394
  validateFieldType(channel: string) {
1395
    const visualChannel = this.visualChannels[channel];
509✔
1396
    const {field, channelScaleType, supportedFieldTypes} = visualChannel;
509✔
1397

1398
    if (this.config[field]) {
509✔
1399
      // if field is selected, check if field type is supported
1400
      const channelSupportedFieldTypes =
1401
        supportedFieldTypes || CHANNEL_SCALE_SUPPORTED_FIELDS[channelScaleType];
122✔
1402

1403
      if (!channelSupportedFieldTypes.includes(this.config[field].type)) {
122!
1404
        // field type is not supported, set it back to null
1405
        // set scale back to default
1406
        this.updateLayerConfig({[field]: null});
×
1407
      }
1408
    }
1409
  }
1410

1411
  /**
1412
   * Validate scale type based on aggregation
1413
   */
1414
  validateScale(channel) {
1415
    const visualChannel = this.visualChannels[channel];
509✔
1416
    const {scale} = visualChannel;
509✔
1417
    if (!scale) {
509!
1418
      // visualChannel doesn't have scale
1419
      return;
×
1420
    }
1421
    const scaleOptions = this.getScaleOptions(channel);
509✔
1422
    // check if current selected scale is
1423
    // supported, if not, change to default
1424
    if (!scaleOptions.includes(this.config[scale])) {
509✔
1425
      this.updateLayerConfig({[scale]: scaleOptions[0]});
41✔
1426
    }
1427
  }
1428

1429
  /**
1430
   * Get scale options based on current field
1431
   * @param {string} channel
1432
   * @returns {string[]}
1433
   */
1434
  getScaleOptions(channel: string): string[] {
1435
    const visualChannel = this.visualChannels[channel];
464✔
1436
    const {field, scale, channelScaleType} = visualChannel;
464✔
1437

1438
    return this.config[field]
464✔
1439
      ? FIELD_OPTS[this.config[field].type].scale[channelScaleType]
1440
      : [this.getDefaultLayerConfig({dataId: ''})[scale]];
1441
  }
1442

1443
  updateLayerVisualChannel(dataset: KeplerTable, channel: string) {
1444
    const visualChannel = this.visualChannels[channel];
33✔
1445
    this.validateVisualChannel(channel);
33✔
1446
    // calculate layer channel domain
1447
    const updatedDomain = this.calculateLayerDomain(dataset, visualChannel);
33✔
1448
    this.updateLayerConfig({[visualChannel.domain]: updatedDomain});
33✔
1449
  }
1450

1451
  getVisualChannelUpdateTriggers(): UpdateTriggers {
1452
    const updateTriggers: UpdateTriggers = {};
43✔
1453
    Object.values(this.visualChannels).forEach(visualChannel => {
43✔
1454
      // field range scale domain
1455
      const {accessor, field, scale, domain, range, defaultValue, fixed} = visualChannel;
153✔
1456

1457
      if (accessor) {
153!
1458
        updateTriggers[accessor] = {
153✔
1459
          [field]: this.config[field],
1460
          [scale]: this.config[scale],
1461
          [domain]: this.config[domain],
1462
          [range]: this.config.visConfig[range],
1463
          defaultValue:
1464
            typeof defaultValue === 'function' ? defaultValue(this.config) : defaultValue,
153✔
1465
          ...(fixed ? {[fixed]: this.config.visConfig[fixed]} : {})
153✔
1466
        };
1467
      }
1468
    });
1469
    return updateTriggers;
43✔
1470
  }
1471

1472
  calculateLayerDomain(dataset, visualChannel) {
1473
    const {scale} = visualChannel;
1,127✔
1474
    const scaleType = this.config[scale];
1,127✔
1475

1476
    const field = this.config[visualChannel.field];
1,127✔
1477
    if (!field) {
1,127✔
1478
      // if colorField or sizeField were set back to null
1479
      return defaultDomain;
996✔
1480
    }
1481

1482
    return dataset.getColumnLayerDomain(field, scaleType) || defaultDomain;
131!
1483
  }
1484

1485
  hasHoveredObject(objectInfo) {
1486
    return this.isLayerHovered(objectInfo) && objectInfo.object ? objectInfo.object : null;
47✔
1487
  }
1488

1489
  isLayerHovered(objectInfo): boolean {
1490
    return objectInfo?.picked && objectInfo?.layer?.props?.id === this.id;
94✔
1491
  }
1492

1493
  getRadiusScaleByZoom(mapState: MapState, fixedRadius?: boolean) {
1494
    const radiusChannel = Object.values(this.visualChannels).find(vc => vc.property === 'radius');
140✔
1495

1496
    if (!radiusChannel) {
40!
1497
      return 1;
×
1498
    }
1499

1500
    const field = radiusChannel.field;
40✔
1501
    const fixed = fixedRadius === undefined ? this.config.visConfig.fixedRadius : fixedRadius;
40✔
1502
    const {radius} = this.config.visConfig;
40✔
1503

1504
    return fixed ? 1 : (this.config[field] ? 1 : radius) * this.getZoomFactor(mapState);
40!
1505
  }
1506

1507
  shouldCalculateLayerData(props: string[]) {
1508
    return props.some(p => !this.noneLayerDataAffectingProps.includes(p));
40✔
1509
  }
1510

1511
  getBrushingExtensionProps(interactionConfig, brushingTarget?) {
1512
    const {brush} = interactionConfig;
28✔
1513

1514
    return {
28✔
1515
      // brushing
1516
      autoHighlight: !brush.enabled,
1517
      brushingRadius: brush.config.size * 1000,
1518
      brushingTarget: brushingTarget || 'source',
52✔
1519
      brushingEnabled: brush.enabled
1520
    };
1521
  }
1522

1523
  getDefaultDeckLayerProps({
1524
    idx,
1525
    gpuFilter,
1526
    mapState,
1527
    layerCallbacks,
1528
    visible
1529
  }: {
1530
    idx: number;
1531
    gpuFilter: GpuFilter;
1532
    mapState: MapState;
1533
    layerCallbacks: any;
1534
    visible: boolean;
1535
  }) {
1536
    return {
57✔
1537
      id: this.id,
1538
      idx,
1539
      coordinateSystem: COORDINATE_SYSTEM.LNGLAT,
1540
      pickable: true,
1541
      wrapLongitude: true,
1542
      parameters: {depthTest: Boolean(mapState.dragRotate || this.config.visConfig.enable3d)},
114✔
1543
      hidden: this.config.hidden,
1544
      // visconfig
1545
      opacity: this.config.visConfig.opacity,
1546
      highlightColor: this.config.highlightColor,
1547
      // data filtering
1548
      extensions: [dataFilterExtension],
1549
      filterRange: gpuFilter ? gpuFilter.filterRange : undefined,
57✔
1550
      onFilteredItemsChange: gpuFilter ? layerCallbacks?.onFilteredItemsChange : undefined,
57✔
1551

1552
      // layer should be visible and if splitMap, shown in to one of panel
1553
      visible: this.config.isVisible && visible
114✔
1554
    };
1555
  }
1556

1557
  getDefaultHoverLayerProps() {
1558
    return {
1✔
1559
      id: `${this.id}-hovered`,
1560
      pickable: false,
1561
      wrapLongitude: true,
1562
      coordinateSystem: COORDINATE_SYSTEM.LNGLAT
1563
    };
1564
  }
1565

1566
  renderTextLabelLayer(
1567
    {
1568
      getPosition,
1569
      getFiltered,
1570
      getPixelOffset,
1571
      backgroundProps,
1572
      updateTriggers,
1573
      sharedProps
1574
    }: {
1575
      getPosition?: ((d: any) => number[]) | arrow.Vector;
1576
      getFiltered?: (data: {index: number}, objectInfo: {index: number}) => number;
1577
      getPixelOffset: (textLabel: any) => number[] | ((d: any) => number[]);
1578
      backgroundProps?: {background: boolean};
1579
      updateTriggers: {
1580
        [key: string]: any;
1581
      };
1582
      sharedProps: any;
1583
    },
1584
    renderOpts
1585
  ) {
1586
    const {data, mapState} = renderOpts;
24✔
1587
    const {textLabel} = this.config;
24✔
1588

1589
    const TextLayerClass = isArrowTable(data.data) ? GeoArrowTextLayer : TextLayer;
24!
1590

1591
    return data.textLabels.reduce((accu, d, i) => {
24✔
1592
      if (d.getText) {
26✔
1593
        const background = textLabel[i].background || backgroundProps?.background;
6✔
1594

1595
        accu.push(
6✔
1596
          // @ts-expect-error
1597
          new TextLayerClass({
1598
            ...sharedProps,
1599
            id: `${this.id}-label-${textLabel[i].field?.name}`,
1600
            data: data.data,
1601
            visible: this.config.isVisible,
1602
            getText: d.getText,
1603
            getPosition,
1604
            getFiltered,
1605
            characterSet: d.characterSet,
1606
            getPixelOffset: getPixelOffset(textLabel[i]),
1607
            getSize: PROJECTED_PIXEL_SIZE_MULTIPLIER,
1608
            sizeScale: textLabel[i].size,
1609
            getTextAnchor: textLabel[i].anchor,
1610
            getAlignmentBaseline: textLabel[i].alignment,
1611
            getColor: textLabel[i].color,
1612
            outlineWidth: textLabel[i].outlineWidth * TEXT_OUTLINE_MULTIPLIER,
1613
            outlineColor: textLabel[i].outlineColor,
1614
            background,
1615
            getBackgroundColor: textLabel[i].backgroundColor,
1616
            fontSettings: {
1617
              sdf: textLabel[i].outlineWidth > 0
1618
            },
1619
            parameters: {
1620
              // text will always show on top of all layers
1621
              depthTest: false
1622
            },
1623

1624
            getFilterValue: data.getFilterValue,
1625
            updateTriggers: {
1626
              ...updateTriggers,
1627
              getText: textLabel[i].field?.name,
1628
              getPixelOffset: {
1629
                ...updateTriggers.getRadius,
1630
                mapState,
1631
                anchor: textLabel[i].anchor,
1632
                alignment: textLabel[i].alignment
1633
              },
1634
              getTextAnchor: textLabel[i].anchor,
1635
              getAlignmentBaseline: textLabel[i].alignment,
1636
              getColor: textLabel[i].color
1637
            },
1638
            _subLayerProps: {
1639
              ...(background
6!
1640
                ? {
1641
                    background: {
1642
                      parameters: {
1643
                        cull: false
1644
                      }
1645
                    }
1646
                  }
1647
                : null)
1648
            }
1649
          })
1650
        );
1651
      }
1652
      return accu;
26✔
1653
    }, []);
1654
  }
1655

1656
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
1657
  calculateDataAttribute(keplerTable: KeplerTable, getPosition): any {
1658
    // implemented in subclasses
1659
    return [];
×
1660
  }
1661

1662
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
1663
  updateLayerMeta(dataset: KeplerTable, getPosition) {
1664
    // implemented in subclasses
1665
  }
1666

1667
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
1668
  getPositionAccessor(
1669
    _dataContainer?: DataContainerInterface,
1670
    // TODO refactor for the next major version to pass only dataset
1671
    _dataset?: KeplerTable
1672
  ): (...args: any[]) => any {
1673
    // implemented in subclasses
1674
    return () => null;
×
1675
  }
1676

1677
  getLegendVisualChannels(): {[key: string]: VisualChannel} {
1678
    return this.visualChannels;
18✔
1679
  }
1680
}
1681

1682
export default Layer;
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