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

keplergl / kepler.gl / 26357904384

24 May 2026 09:44AM UTC coverage: 58.209% (+0.4%) from 57.766%
26357904384

push

github

web-flow
feat: geojson mode for aggregation layers (#3455)

* feat: geojson mode for aggregation layers

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* fix tests

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* follow up

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

---------

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>
Co-authored-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

7345 of 15091 branches covered (48.67%)

Branch coverage included in aggregate %.

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

14861 of 23058 relevant lines covered (64.45%)

78.16 hits per line

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

73.02
/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) =>
7✔
55
  (d: {index: number}) =>
7✔
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) {
65✔
203
        return geojsonAccessor(this.config.columns)(dataContainer as DataContainerInterface);
7✔
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 supportedColumnModes() {
223
    return SUPPORTED_COLUMN_MODES;
378✔
224
  }
225

226
  get columnPairs() {
227
    return this.defaultPointColumnPairs;
3✔
228
  }
229

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

248
  static findDefaultLayerProps(dataset: KeplerTable): FindDefaultLayerPropsReturnValue {
249
    const altProps = getGeoArrowPointLayerProps(dataset);
289✔
250

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

260
    const defaultColumns = {
289✔
261
      geojson: [...(GEOJSON_FIELDS.geojson || []), ...geojsonColumns]
289!
262
    };
263
    const foundColumns = this.findDefaultColumnField(defaultColumns, dataset.fields);
289✔
264

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

276
    return {
289✔
277
      props: [],
278
      altProps
279
    };
280
  }
281

282
  getDefaultLayerConfig(props: LayerBaseConfigPartial) {
283
    return {
123✔
284
      ...super.getDefaultLayerConfig(props),
285
      columnMode: props?.columnMode ?? DEFAULT_COLUMN_MODE
234✔
286
    };
287
  }
288

289
  getDataUpdateTriggers(dataset: KeplerTable): any {
290
    const triggers = super.getDataUpdateTriggers(dataset);
32✔
291
    const {columnMode} = this.config;
32✔
292
    return {
32✔
293
      ...triggers,
294
      getData: {...triggers.getData, columnMode},
295
      getMeta: {...triggers.getMeta, columnMode}
296
    };
297
  }
298

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

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

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

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

365
    // return aggregated object
366
    return {aggregatedData, ...object};
×
367
  }
368

369
  getFilteredItemCount() {
370
    // gpu filter not supported
371
    return null;
×
372
  }
373

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

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

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

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

432
    if (!aggregation) {
58!
433
      return;
×
434
    }
435

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

458
  getAggregationOptions(channel) {
459
    const visualChannel = this.visualChannels[channel];
58✔
460
    const {field, channelScaleType} = visualChannel;
58✔
461

462
    return Object.keys(
58✔
463
      this.config[field]
58✔
464
        ? FIELD_OPTS[this.config[field].type].scale[channelScaleType]
465
        : DEFAULT_AGGREGATION[channelScaleType]
466
    );
467
  }
468

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

479
    if (!aggregationType) {
58!
480
      return [];
×
481
    }
482

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

491
  /**
492
   * Aggregation layer handles visual channel aggregation inside deck.gl layer
493
   */
494
  updateLayerDomain(): AggregationLayer {
495
    return this;
32✔
496
  }
497

498
  updateLayerMeta(dataset: KeplerTable, getPosition?) {
499
    const {dataContainer} = dataset;
31✔
500

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

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

528
    let minLng = Infinity;
4✔
529
    let maxLng = -Infinity;
4✔
530
    let minLat = Infinity;
4✔
531
    let maxLat = -Infinity;
4✔
532
    let hasValid = false;
4✔
533

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

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

556
    this._geojsonBounds = hasValid ? [minLng, minLat, maxLng, maxLat] : null;
4!
557
  }
558

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

573
  calculateDataAttribute({filteredIndex}: KeplerTable, getPosition) {
574
    if (this.config.columnMode === COLUMN_MODE_GEOJSON) {
30✔
575
      return this._calculateGeojsonDataAttribute(filteredIndex);
3✔
576
    }
577

578
    const data: AggregationLayerData[] = [];
27✔
579

580
    for (let i = 0; i < filteredIndex.length; i++) {
27✔
581
      const index = filteredIndex[i];
311✔
582
      const pos = getPosition({index});
311✔
583

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

593
    return data;
27✔
594
  }
595

596
  private _calculateGeojsonDataAttribute(filteredIndex: number[]) {
597
    const data: {index: number; position: number[]}[] = [];
3✔
598

599
    for (let i = 0; i < filteredIndex.length; i++) {
3✔
600
      const index = filteredIndex[i];
15✔
601
      const feature = this.dataToFeature[index];
15✔
602
      if (!feature?.geometry) continue;
15!
603

604
      const centroid = getCentroidFromGeometry(feature.geometry);
15✔
605
      if (centroid) {
15!
606
        data.push({index, position: centroid});
15✔
607
      }
608
    }
609

610
    return data;
3✔
611
  }
612

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

620
    const getPosition = isGeojsonMode
32✔
621
      ? (d: {position: number[]}) => d.position
3✔
622
      : this.getPositionAccessor(dataContainer);
623

624
    const hasFilter = Object.values(gpuFilter.filterRange).some((arr: any) =>
32✔
625
      arr.some(v => v !== 0)
76✔
626
    );
627

628
    const getFilterValue = gpuFilter.filterValueAccessor(dataContainer)(
32✔
629
      this.gpuFilterGetIndex,
630
      this.gpuFilterGetData
631
    );
632
    const filterData = hasFilter
32✔
633
      ? getFilterDataFunc(gpuFilter.filterRange, getFilterValue)
634
      : undefined;
635

636
    const aggregatePoints = getValueAggrFunc(this.getPointData);
32✔
637
    let getColorValue = aggregatePoints(
32✔
638
      this.config.colorField,
639
      this.config.visConfig.colorAggregation
640
    );
641

642
    let getElevationValue = aggregatePoints(
32✔
643
      this.config.sizeField,
644
      this.config.visConfig.sizeAggregation
645
    );
646

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

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

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

678
    const result = {
32✔
679
      data,
680
      getPosition,
681
      _filterData: filterData,
682
      ...(getFilteredColorValue ? {getColorValue: getFilteredColorValue} : {}),
32!
683
      ...(getFilteredElevationValue ? {getElevationValue: getFilteredElevationValue} : {})
32!
684
    };
685

686
    return result;
32✔
687
  }
688

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

700
  getDefaultAggregationLayerProp(opts) {
701
    const {gpuFilter, mapState, layerCallbacks = {}} = opts;
5!
702
    const {visConfig} = this.config;
5✔
703
    const eleZoomFactor = this.getElevationZoomFactor(mapState);
5✔
704

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

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

739
    return {
5✔
740
      ...this.getDefaultDeckLayerProps(opts),
741
      coverage: visConfig.coverage,
742

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

752
      // elevation
753
      extruded: visConfig.enable3d,
754
      elevationScale: visConfig.elevationScale * eleZoomFactor,
755
      elevationScaleType: this.config.sizeScale,
756
      elevationRange: visConfig.sizeRange,
757
      elevationFixed: visConfig.fixedHeight,
758

759
      elevationLowerPercentile: visConfig.elevationPercentile[0],
760
      elevationUpperPercentile: visConfig.elevationPercentile[1],
761

762
      // updateTriggers
763
      updateTriggers,
764

765
      // callbacks
766
      onSetColorDomain: layerCallbacks.onSetLayerDomain
767
    };
768
  }
769
}
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