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

keplergl / kepler.gl / 26314259994

22 May 2026 10:05PM UTC coverage: 58.204% (+0.4%) from 57.76%
26314259994

Pull #3455

github

web-flow
Merge b714cd224 into c757f6006
Pull Request #3455: feat: geojson mode for aggregation layers

7335 of 15074 branches covered (48.66%)

Branch coverage included in aggregate %.

109 of 115 new or added lines in 1 file covered. (94.78%)

22 existing lines in 1 file now uncovered.

14853 of 23047 relevant lines covered (64.45%)

78.06 hits per line

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

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

4
import memoize from 'lodash/memoize';
5
import Layer, {
6
  LayerBaseConfig,
7
  LayerBaseConfigPartial,
8
  LayerColorConfig,
9
  LayerSizeConfig,
10
  VisualChannelDescription,
11
  VisualChannels
12
} from './base-layer';
13
import {hexToRgb, aggregate, DataContainerInterface} from '@kepler.gl/utils';
14
import {
15
  HIGHLIGH_COLOR_3D,
16
  CHANNEL_SCALES,
17
  FIELD_OPTS,
18
  DEFAULT_AGGREGATION,
19
  AGGREGATION_TYPES,
20
  ALL_FIELD_TYPES,
21
  GEOJSON_FIELDS
22
} from '@kepler.gl/constants';
23
import {ColorRange, Field, LayerColumn, Merge} from '@kepler.gl/types';
24
import {KeplerTable, Datasets} from '@kepler.gl/table';
25
import {DATA_TYPES} from 'type-analyzer';
26
import booleanWithin from '@turf/boolean-within';
27
import {point as turfPoint} from '@turf/helpers';
28
import {Feature, Polygon} from 'geojson';
29

30
import {getGeoArrowPointLayerProps, FindDefaultLayerPropsReturnValue} from './layer-utils';
31
import {parseGeoJsonRawFeature} from './geojson-layer/geojson-utils';
32

33
type AggregationLayerColumns = {
34
  lat: LayerColumn;
35
  lng: LayerColumn;
36
  geojson: LayerColumn;
37
};
38

39
export type AggregationLayerData = {
40
  index: number;
41
};
42

43
export const pointPosAccessor =
44
  ({lat, lng}: AggregationLayerColumns) =>
13✔
45
  dc =>
58✔
46
  d =>
58✔
47
    [dc.valueAt(d.index, lng.fieldIdx), dc.valueAt(d.index, lat.fieldIdx)];
689✔
48

49
export const pointPosResolver = ({lat, lng}: AggregationLayerColumns) =>
13✔
50
  `${lat.fieldIdx}-${lng.fieldIdx}`;
×
51

52
export const geojsonAccessor =
53
  ({geojson}: AggregationLayerColumns) =>
13✔
54
  (dc: DataContainerInterface) =>
8✔
55
  (d: {index: number}) =>
8✔
56
    dc.valueAt(d.index, geojson.fieldIdx);
18✔
57

58
export const COLUMN_MODE_POINTS = 'points';
13✔
59
export const COLUMN_MODE_GEOJSON = 'geojson';
13✔
60

61
const SUPPORTED_ANALYZER_TYPES = {
13✔
62
  [DATA_TYPES.GEOMETRY]: true,
63
  [DATA_TYPES.GEOMETRY_FROM_STRING]: true,
64
  [DATA_TYPES.PAIR_GEOMETRY_FROM_STRING]: true
65
};
66

67
const SUPPORTED_COLUMN_MODES = [
13✔
68
  {
69
    key: COLUMN_MODE_POINTS,
70
    label: 'Points',
71
    requiredColumns: ['lat', 'lng']
72
  },
73
  {
74
    key: COLUMN_MODE_GEOJSON,
75
    label: 'GeoJSON',
76
    requiredColumns: ['geojson']
77
  }
78
];
79
const DEFAULT_COLUMN_MODE = COLUMN_MODE_POINTS;
13✔
80

81
export const getValueAggrFunc = getPointData => (field, aggregation) => points =>
64✔
82
  field
33✔
83
    ? aggregate(
84
        points.map(p => field.valueAccessor(getPointData(p))),
9✔
85
        aggregation
86
      )
87
    : points.length;
88

89
export const getFilterDataFunc =
90
  (
13✔
91
    filterRange: number[][],
92
    getFilterValue: (d: unknown) => (number | number[])[]
93
  ): ((d: unknown) => boolean) =>
94
  pt =>
34✔
95
    getFilterValue(pt).every((val, i) => {
116✔
96
      return typeof val === 'number' ? val >= filterRange[i][0] && val <= filterRange[i][1] : false;
356!
97
    });
98

99
const NON_NUMERIC_FIELD_TYPES: Set<string> = new Set([
13✔
100
  ALL_FIELD_TYPES.string,
101
  ALL_FIELD_TYPES.boolean,
102
  ALL_FIELD_TYPES.date
103
]);
104

105
/**
106
 * Wrap a per-bin accessor that may return a non-numeric value (e.g. a string
107
 * from "mode" aggregation) so that it returns a stable numeric index instead.
108
 * deck.gl 9's native CPU aggregation stores results in a Float32Array which
109
 * silently converts strings to NaN — this wrapper prevents that.
110
 */
111
function wrapOrdinalAccessor(
112
  accessor: (points: unknown[]) => unknown
113
): (points: unknown[]) => number {
114
  const valueToIndex = new Map<string, number>();
3✔
115
  return (points: unknown[]) => {
3✔
116
    const value = accessor(points);
1✔
117
    if (value == null) return NaN;
1!
118
    const key = String(value);
1✔
119
    let idx = valueToIndex.get(key);
1✔
120
    if (idx === undefined) {
1!
121
      idx = valueToIndex.size;
1✔
122
      valueToIndex.set(key, idx);
1✔
123
    }
124
    return idx;
1✔
125
  };
126
}
127

128
const getLayerColorRange = (colorRange: ColorRange) => colorRange.colors.map(hexToRgb);
13✔
129

130
export const aggregateRequiredColumns: ['lat', 'lng'] = ['lat', 'lng'];
13✔
131

132
/**
133
 * Compute the centroid [lng, lat] of a GeoJSON geometry.
134
 * For Point returns the coordinate directly; for complex geometries
135
 * averages all vertex positions into a single representative point.
136
 */
137
function getCentroidFromGeometry(geometry: any): number[] | null {
138
  if (!geometry) return null;
33!
139
  const positions = getAllPositions(geometry);
33✔
140
  if (positions.length === 0) return null;
33!
141
  if (positions.length === 1) return positions[0];
33✔
142

143
  let sumLng = 0;
32✔
144
  let sumLat = 0;
32✔
145
  let count = 0;
32✔
146
  for (const pos of positions) {
32✔
147
    if (Number.isFinite(pos[0]) && Number.isFinite(pos[1])) {
151!
148
      sumLng += pos[0];
151✔
149
      sumLat += pos[1];
151✔
150
      count++;
151✔
151
    }
152
  }
153
  return count > 0 ? [sumLng / count, sumLat / count] : null;
32!
154
}
155

156
/**
157
 * Extract all vertex [lng, lat] coordinates from a GeoJSON geometry.
158
 */
159
function getAllPositions(geometry: any): number[][] {
160
  if (!geometry) return [];
51!
161
  switch (geometry.type) {
51!
162
    case 'Point':
163
      return [geometry.coordinates];
2✔
164
    case 'MultiPoint':
165
    case 'LineString':
166
      return geometry.coordinates;
2✔
167
    case 'MultiLineString':
168
    case 'Polygon':
169
      return geometry.coordinates.flat();
47✔
170
    case 'MultiPolygon':
NEW
171
      return geometry.coordinates.flat(2);
×
172
    case 'GeometryCollection':
NEW
173
      return (geometry.geometries || []).flatMap(getAllPositions);
×
174
    default:
NEW
175
      return [];
×
176
  }
177
}
178

179
export type AggregationLayerVisualChannelConfig = LayerColorConfig & LayerSizeConfig;
180
export type AggregationLayerConfig = Merge<LayerBaseConfig, {columns: AggregationLayerColumns}> &
181
  AggregationLayerVisualChannelConfig;
182
export default class AggregationLayer extends Layer {
183
  getColorRange: any;
184
  declare config: AggregationLayerConfig;
185
  declare getPointData: (any) => any;
186
  declare gpuFilterGetIndex: (any) => number;
187
  declare gpuFilterGetData: (dataContainer, data, fieldIndex) => any;
188

189
  dataToFeature: any[] = [];
123✔
190
  centroids: Array<number[] | null> = [];
123✔
191
  private _geojsonFieldIdx = -1;
123✔
192
  private _geojsonBounds: [number, number, number, number] | null = null;
123✔
193

194
  constructor(
195
    props: {
196
      id?: string;
197
    } & LayerBaseConfigPartial
198
  ) {
199
    super(props);
123✔
200

201
    this.getPositionAccessor = dataContainer => {
123✔
202
      if (this.config.columnMode === COLUMN_MODE_GEOJSON) {
66✔
203
        return geojsonAccessor(this.config.columns)(dataContainer as DataContainerInterface);
8✔
204
      }
205
      return pointPosAccessor(this.config.columns)(dataContainer);
58✔
206
    };
207
    this.getColorRange = memoize(getLayerColorRange);
123✔
208

209
    // Access data of a point from aggregated bins
210
    // In deck.gl 9, aggregation layers pass original data items directly to getColorValue/getElevationValue
211
    this.getPointData = pt => pt;
123✔
212

213
    this.gpuFilterGetIndex = pt => this.getPointData(pt).index;
123✔
214
    this.gpuFilterGetData = (dataContainer, data, fieldIndex) =>
123✔
215
      dataContainer.valueAt(data.index, fieldIndex);
78✔
216
  }
217

218
  get isAggregated(): true {
219
    return true;
6✔
220
  }
221

222
  get requiredLayerColumns() {
223
    return aggregateRequiredColumns;
153✔
224
  }
225

226
  get supportedColumnModes() {
227
    return SUPPORTED_COLUMN_MODES;
225✔
228
  }
229

230
  get columnPairs() {
231
    return this.defaultPointColumnPairs;
3✔
232
  }
233

234
  get noneLayerDataAffectingProps() {
UNCOV
235
    return [
×
236
      ...super.noneLayerDataAffectingProps,
237
      'enable3d',
238
      'colorRange',
239
      'colorDomain',
240
      'sizeRange',
241
      'sizeScale',
242
      'sizeDomain',
243
      'percentile',
244
      'coverage',
245
      'elevationPercentile',
246
      'elevationScale',
247
      'enableElevationZoomFactor',
248
      'fixedHeight'
249
    ];
250
  }
251

252
  static findDefaultLayerProps(dataset: KeplerTable): FindDefaultLayerPropsReturnValue {
253
    const altProps = getGeoArrowPointLayerProps(dataset);
289✔
254

255
    const geojsonColumns = dataset.fields
289✔
256
      .filter(
257
        f =>
258
          (f.type === 'geojson' || f.type === 'geoarrow') &&
2,210✔
259
          f.analyzerType &&
260
          SUPPORTED_ANALYZER_TYPES[f.analyzerType]
261
      )
262
      .map(f => f.name);
144✔
263

264
    const defaultColumns = {
289✔
265
      geojson: [...(GEOJSON_FIELDS.geojson || []), ...geojsonColumns]
289!
266
    };
267
    const foundColumns = this.findDefaultColumnField(defaultColumns, dataset.fields);
289✔
268

269
    if (foundColumns?.length) {
289✔
270
      const {label} = dataset;
126✔
271
      altProps.push(
126✔
272
        ...foundColumns.map(columns => ({
153✔
273
          label: (typeof label === 'string' && label.replace(/\.[^/.]+$/, '')) || 'aggregation',
306!
274
          columns,
275
          columnMode: COLUMN_MODE_GEOJSON
276
        }))
277
      );
278
    }
279

280
    return {
289✔
281
      props: [],
282
      altProps
283
    };
284
  }
285

286
  getDefaultLayerConfig(props: LayerBaseConfigPartial) {
287
    return {
123✔
288
      ...super.getDefaultLayerConfig(props),
289
      columnMode: props?.columnMode ?? DEFAULT_COLUMN_MODE
234✔
290
    };
291
  }
292

293
  getDataUpdateTriggers(dataset: KeplerTable): any {
294
    const triggers = super.getDataUpdateTriggers(dataset);
32✔
295
    const {columnMode} = this.config;
32✔
296
    return {
32✔
297
      ...triggers,
298
      getData: {...triggers.getData, columnMode},
299
      getMeta: {...triggers.getMeta, columnMode}
300
    };
301
  }
302

303
  get visualChannels(): VisualChannels {
304
    return {
319✔
305
      color: {
306
        aggregation: 'colorAggregation',
307
        channelScaleType: CHANNEL_SCALES.colorAggr,
308
        defaultMeasure: 'property.pointCount',
309
        domain: 'colorDomain',
310
        field: 'colorField',
311
        key: 'color',
312
        property: 'color',
313
        range: 'colorRange',
314
        scale: 'colorScale'
315
      },
316
      size: {
317
        aggregation: 'sizeAggregation',
318
        channelScaleType: CHANNEL_SCALES.sizeAggr,
319
        condition: config => config.visConfig.enable3d,
1✔
320
        defaultMeasure: 'property.pointCount',
321
        domain: 'sizeDomain',
322
        field: 'sizeField',
323
        key: 'size',
324
        property: 'height',
325
        range: 'sizeRange',
326
        scale: 'sizeScale'
327
      }
328
    };
329
  }
330

331
  /**
332
   * Get the description of a visualChannel config
333
   * @param key
334
   * @returns
335
   */
336
  getVisualChannelDescription(key: string): VisualChannelDescription {
337
    const channel = this.visualChannels[key];
2✔
338
    if (!channel) return {label: '', measure: undefined};
2!
339
    // e.g. label: Color, measure: Average of ETA
340
    const {range, field, defaultMeasure, aggregation} = channel;
2✔
341
    const fieldConfig = this.config[field];
2✔
342
    const label = this.visConfigSettings[range]?.label;
2✔
343

344
    return {
2✔
345
      label: typeof label === 'function' ? label(this.config) : label || '',
4!
346
      measure:
347
        fieldConfig && aggregation
4!
348
          ? `${this.config.visConfig[aggregation]} of ${
349
              fieldConfig.displayName || fieldConfig.name
×
350
            }`
351
          : defaultMeasure
352
    };
353
  }
354

355
  getHoverData(object: any, dataContainer: DataContainerInterface, fields: Field[]): any {
356
    if (!object) return object;
×
UNCOV
357
    const measure = this.config.visConfig.colorAggregation;
×
358
    // aggregate all fields for the hovered group
359
    const aggregatedData = fields.reduce((accu, field) => {
×
UNCOV
360
      accu[field.name] = {
×
361
        measure,
362
        value: aggregate(object.points, measure, (d: {index: number}) => {
UNCOV
363
          return dataContainer.valueAt(d.index, field.fieldIdx);
×
364
        })
365
      };
366
      return accu;
×
367
    }, {});
368

369
    // return aggregated object
UNCOV
370
    return {aggregatedData, ...object};
×
371
  }
372

373
  getFilteredItemCount() {
374
    // gpu filter not supported
UNCOV
375
    return null;
×
376
  }
377

378
  /**
379
   * Aggregation layer handles visual channel aggregation inside deck.gl layer
380
   */
381
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
382
  updateLayerVisualChannel({dataContainer}, channel) {
UNCOV
383
    this.validateVisualChannel(channel);
×
384

385
    // When the color scale type changes, recompute colorDomain from stored aggregatedBins.
386
    // quantile scale needs the full sorted array of bin values; other scales need [min, max].
387
    // aggregatedBins is only populated from onSetColorDomain, so restrict to the color channel.
388
    const visualChannel = this.visualChannels[channel];
×
389
    if (channel === 'color' && visualChannel && this.config.aggregatedBins) {
×
390
      const scaleType = this.config[visualChannel.scale];
×
391
      const domainKey = visualChannel.domain;
×
392
      const bins = Object.values(this.config.aggregatedBins) as {value: number}[];
×
UNCOV
393
      if (bins.length > 0) {
×
394
        if (scaleType === 'quantile') {
×
395
          const sorted = bins
×
UNCOV
396
            .map(b => b.value)
×
397
            .filter(Number.isFinite)
398
            .sort((a, b) => a - b);
×
399
          this.updateLayerConfig({[domainKey]: sorted});
×
400
        } else {
401
          let min = Infinity;
×
402
          let max = -Infinity;
×
UNCOV
403
          for (const b of bins) {
×
UNCOV
404
            if (Number.isFinite(b.value)) {
×
405
              if (b.value < min) min = b.value;
×
406
              if (b.value > max) max = b.value;
×
407
            }
408
          }
UNCOV
409
          if (Number.isFinite(min) && Number.isFinite(max)) {
×
UNCOV
410
            this.updateLayerConfig({[domainKey]: [min, max]});
×
411
          }
412
        }
413
      }
414
    }
415
  }
416

417
  /**
418
   * Validate aggregation type on top of basic layer visual channel validation
419
   * @param channel
420
   */
421
  validateVisualChannel(channel) {
422
    // field type decides aggregation type decides scale type
423
    this.validateFieldType(channel);
58✔
424
    this.validateAggregationType(channel);
58✔
425
    this.validateScale(channel);
58✔
426
  }
427

428
  /**
429
   * Validate aggregation type based on selected field
430
   */
431
  validateAggregationType(channel) {
432
    const visualChannel = this.visualChannels[channel];
58✔
433
    const {field, aggregation} = visualChannel;
58✔
434
    const aggregationOptions = this.getAggregationOptions(channel);
58✔
435

436
    if (!aggregation) {
58!
UNCOV
437
      return;
×
438
    }
439

440
    if (!aggregationOptions.length) {
58!
441
      // if field cannot be aggregated, set field to null
UNCOV
442
      this.updateLayerConfig({[field]: null});
×
443
    } else if (!aggregationOptions.includes(this.config.visConfig[aggregation])) {
58✔
444
      // current aggregation type is not supported by this field
445
      // set aggregation to the first supported option
446
      this.updateLayerVisConfig({[aggregation]: aggregationOptions[0]});
36✔
447
    } else if (
22!
448
      this.config[field] &&
28✔
449
      this.config.visConfig[aggregation] === AGGREGATION_TYPES.count
450
    ) {
451
      // When a field is selected but aggregation is still 'count' (carried over
452
      // from the no-field / "Count Points" state), switch to a meaningful default.
453
      // 'count' ignores the field values entirely, so keeping it would make the
454
      // field selection appear to have no effect.
UNCOV
455
      const meaningful = aggregationOptions.find(opt => opt !== AGGREGATION_TYPES.count);
×
UNCOV
456
      if (meaningful) {
×
UNCOV
457
        this.updateLayerVisConfig({[aggregation]: meaningful});
×
458
      }
459
    }
460
  }
461

462
  getAggregationOptions(channel) {
463
    const visualChannel = this.visualChannels[channel];
58✔
464
    const {field, channelScaleType} = visualChannel;
58✔
465

466
    return Object.keys(
58✔
467
      this.config[field]
58✔
468
        ? FIELD_OPTS[this.config[field].type].scale[channelScaleType]
469
        : DEFAULT_AGGREGATION[channelScaleType]
470
    );
471
  }
472

473
  /**
474
   * Get scale options based on current field and aggregation type
475
   * @param channel
476
   * @returns
477
   */
478
  getScaleOptions(channel: string): string[] {
479
    const visualChannel = this.visualChannels[channel];
58✔
480
    const {field, aggregation, channelScaleType} = visualChannel;
58✔
481
    const aggregationType = aggregation ? this.config.visConfig[aggregation] : null;
58!
482

483
    if (!aggregationType) {
58!
UNCOV
484
      return [];
×
485
    }
486

487
    return this.config[field]
58✔
488
      ? // scale options based on aggregation
489
        FIELD_OPTS[this.config[field].type].scale[channelScaleType][aggregationType]
490
      : // default scale options for point count: aggregationType should be count since
491
        // LAYER_VIS_CONFIGS.aggregation.defaultValue is AGGREGATION_TYPES.average,
492
        DEFAULT_AGGREGATION[channelScaleType][AGGREGATION_TYPES.count];
493
  }
494

495
  /**
496
   * Aggregation layer handles visual channel aggregation inside deck.gl layer
497
   */
498
  updateLayerDomain(): AggregationLayer {
499
    return this;
32✔
500
  }
501

502
  updateLayerMeta(dataset: KeplerTable, getPosition?) {
503
    const {dataContainer} = dataset;
31✔
504

505
    if (this.config.columnMode === COLUMN_MODE_GEOJSON) {
31✔
506
      const getFeature = this.getPositionAccessor(dataContainer);
4✔
507
      this._buildGeojsonDataToFeature(dataContainer, getFeature);
4✔
508
      this.updateMeta({bounds: this._geojsonBounds});
4✔
509
    } else {
510
      this.dataToFeature = [];
27✔
511
      this.centroids = [];
27✔
512
      if (!getPosition) {
27!
NEW
513
        getPosition = this.getPositionAccessor(dataContainer);
×
514
      }
515
      const bounds = this.getPointsBounds(dataContainer, getPosition);
27✔
516
      this.updateMeta({bounds});
27✔
517
    }
518
  }
519

520
  private _buildGeojsonDataToFeature(dataContainer: DataContainerInterface, getFeature: any) {
521
    const fieldIdx = this.config.columns.geojson.fieldIdx;
4✔
522
    if (
4!
523
      this.dataToFeature.length === dataContainer.numRows() &&
4!
524
      this._geojsonFieldIdx === fieldIdx
525
    ) {
NEW
526
      return;
×
527
    }
528
    this._geojsonFieldIdx = fieldIdx;
4✔
529
    this.dataToFeature = [];
4✔
530
    this.centroids = [];
4✔
531

532
    let minLng = Infinity;
4✔
533
    let maxLng = -Infinity;
4✔
534
    let minLat = Infinity;
4✔
535
    let maxLat = -Infinity;
4✔
536
    let hasValid = false;
4✔
537

538
    for (let i = 0; i < dataContainer.numRows(); i++) {
4✔
539
      const rawFeature = getFeature({index: i});
18✔
540
      const feature = parseGeoJsonRawFeature(rawFeature);
18✔
541
      this.dataToFeature[i] = feature;
18✔
542
      this.centroids[i] = feature?.geometry ? getCentroidFromGeometry(feature.geometry) : null;
18!
543

544
      if (feature?.geometry) {
18!
545
        const positions = getAllPositions(feature.geometry);
18✔
546
        for (const pos of positions) {
18✔
547
          const lng = pos[0];
80✔
548
          const lat = pos[1];
80✔
549
          if (Number.isFinite(lng) && Number.isFinite(lat)) {
80!
550
            hasValid = true;
80✔
551
            if (lng < minLng) minLng = lng;
80✔
552
            if (lng > maxLng) maxLng = lng;
80✔
553
            if (lat < minLat) minLat = lat;
80✔
554
            if (lat > maxLat) maxLat = lat;
80✔
555
          }
556
        }
557
      }
558
    }
559

560
    this._geojsonBounds = hasValid ? [minLng, minLat, maxLng, maxLat] : null;
4!
561
  }
562

563
  isInPolygon(data: DataContainerInterface, index: number, polygon: Feature<Polygon>): boolean {
564
    if (this.centroids.length === 0 || !this.centroids[index]) {
4✔
565
      return false;
2✔
566
    }
567
    const point = this.centroids[index];
2✔
568
    if (!point) return false;
2!
569
    const isRectangleSearchBox = polygon.properties?.shape === 'Rectangle';
2✔
570
    if (isRectangleSearchBox && polygon.properties?.bbox) {
2!
571
      const [minX, minY, maxX, maxY] = polygon.properties.bbox;
2✔
572
      return point[0] >= minX && point[0] <= maxX && point[1] >= minY && point[1] <= maxY;
2✔
573
    }
NEW
574
    return booleanWithin(turfPoint(point), polygon);
×
575
  }
576

577
  calculateDataAttribute({filteredIndex}: KeplerTable, getPosition) {
578
    if (this.config.columnMode === COLUMN_MODE_GEOJSON) {
30✔
579
      return this._calculateGeojsonDataAttribute(filteredIndex);
3✔
580
    }
581

582
    const data: AggregationLayerData[] = [];
27✔
583

584
    for (let i = 0; i < filteredIndex.length; i++) {
27✔
585
      const index = filteredIndex[i];
311✔
586
      const pos = getPosition({index});
311✔
587

588
      // if doesn't have point lat or lng, do not add the point
589
      // deck.gl can't handle position = null
590
      if (pos.every(Number.isFinite)) {
311✔
591
        data.push({
295✔
592
          index
593
        });
594
      }
595
    }
596

597
    return data;
27✔
598
  }
599

600
  private _calculateGeojsonDataAttribute(filteredIndex: number[]) {
601
    const data: {index: number; position: number[]}[] = [];
3✔
602

603
    for (let i = 0; i < filteredIndex.length; i++) {
3✔
604
      const index = filteredIndex[i];
15✔
605
      const feature = this.dataToFeature[index];
15✔
606
      if (!feature?.geometry) continue;
15!
607

608
      const centroid = getCentroidFromGeometry(feature.geometry);
15✔
609
      if (centroid) {
15!
610
        data.push({index, position: centroid});
15✔
611
      }
612
    }
613

614
    return data;
3✔
615
  }
616

617
  formatLayerData(datasets: Datasets, oldLayerData) {
618
    if (this.config.dataId === null) {
32!
619
      return {};
×
620
    }
621
    const {gpuFilter, dataContainer} = datasets[this.config.dataId];
32✔
622
    const isGeojsonMode = this.config.columnMode === COLUMN_MODE_GEOJSON;
32✔
623

624
    const getPosition = isGeojsonMode
32✔
625
      ? (d: {position: number[]}) => d.position
3✔
626
      : this.getPositionAccessor(dataContainer);
627

628
    const hasFilter = Object.values(gpuFilter.filterRange).some((arr: any) =>
32✔
629
      arr.some(v => v !== 0)
76✔
630
    );
631

632
    const getFilterValue = gpuFilter.filterValueAccessor(dataContainer)(
32✔
633
      this.gpuFilterGetIndex,
634
      this.gpuFilterGetData
635
    );
636
    const filterData = hasFilter
32✔
637
      ? getFilterDataFunc(gpuFilter.filterRange, getFilterValue)
638
      : undefined;
639

640
    const aggregatePoints = getValueAggrFunc(this.getPointData);
32✔
641
    let getColorValue = aggregatePoints(
32✔
642
      this.config.colorField,
643
      this.config.visConfig.colorAggregation
644
    );
645

646
    let getElevationValue = aggregatePoints(
32✔
647
      this.config.sizeField,
648
      this.config.visConfig.sizeAggregation
649
    );
650

651
    // deck.gl 9's native CPU aggregation stores getColorValue/getElevationValue
652
    // results in a Float32Array. "mode" aggregation on non-numeric fields returns
653
    // a string, which becomes NaN in Float32Array. Wrap with ordinal mapping to
654
    // convert strings to stable numeric indices.
655
    if (
32✔
656
      this.config.colorField &&
41✔
657
      this.config.visConfig.colorAggregation === AGGREGATION_TYPES.mode &&
658
      NON_NUMERIC_FIELD_TYPES.has(this.config.colorField.type)
659
    ) {
660
      getColorValue = wrapOrdinalAccessor(getColorValue);
3✔
661
    }
662
    if (
32!
663
      this.config.sizeField &&
34!
664
      this.config.visConfig.sizeAggregation === AGGREGATION_TYPES.mode &&
665
      NON_NUMERIC_FIELD_TYPES.has(this.config.sizeField.type)
666
    ) {
UNCOV
667
      getElevationValue = wrapOrdinalAccessor(getElevationValue);
×
668
    }
669

670
    // Wrap accessors to filter points within each bin before aggregating.
671
    const getFilteredColorValue =
672
      filterData && getColorValue
32✔
673
        ? points => getColorValue(points.filter(filterData))
20✔
674
        : getColorValue;
675
    const getFilteredElevationValue =
676
      filterData && getElevationValue
32✔
677
        ? points => getElevationValue(points.filter(filterData))
13✔
678
        : getElevationValue;
679

680
    const {data} = this.updateData(datasets, oldLayerData);
32✔
681

682
    const result = {
32✔
683
      data,
684
      getPosition,
685
      _filterData: filterData,
686
      ...(getFilteredColorValue ? {getColorValue: getFilteredColorValue} : {}),
32!
687
      ...(getFilteredElevationValue ? {getElevationValue: getFilteredElevationValue} : {})
32!
688
    };
689

690
    return result;
32✔
691
  }
692

693
  getDefaultDeckLayerProps(opts): any {
694
    const baseProp = super.getDefaultDeckLayerProps(opts);
6✔
695
    return {
6✔
696
      ...baseProp,
697
      highlightColor: HIGHLIGH_COLOR_3D,
698
      // gpu data filtering is not supported in aggregation layer
699
      extensions: [],
700
      autoHighlight: this.config.visConfig.enable3d
701
    };
702
  }
703

704
  getDefaultAggregationLayerProp(opts) {
705
    const {gpuFilter, mapState, layerCallbacks = {}} = opts;
5!
706
    const {visConfig} = this.config;
5✔
707
    const eleZoomFactor = this.getElevationZoomFactor(mapState);
5✔
708

709
    const updateTriggers = {
5✔
710
      getColorValue: {
711
        colorField: this.config.colorField,
712
        colorAggregation: this.config.visConfig.colorAggregation,
713
        colorRange: visConfig.colorRange,
714
        colorMap: visConfig.colorRange.colorMap,
715
        filterRange: gpuFilter.filterRange,
716
        ...gpuFilter.filterValueUpdateTriggers
717
      },
718
      getElevationValue: {
719
        sizeField: this.config.sizeField,
720
        sizeAggregation: this.config.visConfig.sizeAggregation,
721
        filterRange: gpuFilter.filterRange,
722
        ...gpuFilter.filterValueUpdateTriggers
723
      }
724
    };
725

726
    // deck.gl's aggregation shader maps bin values to a color texture using a
727
    // simple linear interpolation: (value - domain[0]) / (domain[1] - domain[0]).
728
    // It only understands 'quantize', 'quantile', 'ordinal', and 'linear'.
729
    // kepler.gl's 'custom' scale (d3.scaleThreshold with user-defined break
730
    // points) cannot be represented in the shader directly.  Instead, our
731
    // ScaleEnhanced*Layer._onAggregationUpdate reclassifies each bin's raw
732
    // value into a break index [0 … N-1].  We then tell deck.gl to use
733
    // 'quantize' over [0, N-1] so each index maps to the correct color pixel.
734
    let colorScaleType = this.config.colorScale as string;
5✔
735
    let customColorDomain: [number, number] | undefined;
736
    const isCustomScale = colorScaleType === 'custom';
5✔
737
    const colorMap = isCustomScale ? visConfig.colorRange.colorMap : undefined;
5!
738
    if (isCustomScale && colorMap) {
5!
UNCOV
739
      colorScaleType = 'quantize';
×
UNCOV
740
      customColorDomain = [0, colorMap.length - 1];
×
741
    }
742

743
    return {
5✔
744
      ...this.getDefaultDeckLayerProps(opts),
745
      coverage: visConfig.coverage,
746

747
      // color
748
      colorRange: this.getColorRange(visConfig.colorRange),
749
      colorMap,
750
      colorScaleType,
751
      ...(customColorDomain ? {colorDomain: customColorDomain} : {}),
5!
752
      upperPercentile: visConfig.percentile[1],
753
      lowerPercentile: visConfig.percentile[0],
754
      colorAggregation: visConfig.colorAggregation,
755

756
      // elevation
757
      extruded: visConfig.enable3d,
758
      elevationScale: visConfig.elevationScale * eleZoomFactor,
759
      elevationScaleType: this.config.sizeScale,
760
      elevationRange: visConfig.sizeRange,
761
      elevationFixed: visConfig.fixedHeight,
762

763
      elevationLowerPercentile: visConfig.elevationPercentile[0],
764
      elevationUpperPercentile: visConfig.elevationPercentile[1],
765

766
      // updateTriggers
767
      updateTriggers,
768

769
      // callbacks
770
      onSetColorDomain: layerCallbacks.onSetLayerDomain
771
    };
772
  }
773
}
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