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

keplergl / kepler.gl / 23880914138

02 Apr 2026 02:34AM UTC coverage: 60.661% (-1.0%) from 61.699%
23880914138

Pull #3271

github

web-flow
Merge f1dfa1060 into bc59e880b
Pull Request #3271: chore: deck.gl 9.2 upgrade & loaders.gl, luma.gl upgrades

6519 of 12785 branches covered (50.99%)

Branch coverage included in aggregate %.

270 of 740 new or added lines in 50 files covered. (36.49%)

102 existing lines in 12 files now uncovered.

13280 of 19854 relevant lines covered (66.89%)

79.44 hits per line

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

77.2
/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
} from '@kepler.gl/constants';
22
import {ColorRange, Field, LayerColumn, Merge} from '@kepler.gl/types';
23
import {KeplerTable, Datasets} from '@kepler.gl/table';
24

25
type AggregationLayerColumns = {
26
  lat: LayerColumn;
27
  lng: LayerColumn;
28
};
29

30
export type AggregationLayerData = {
31
  index: number;
32
};
33

34
export const pointPosAccessor =
35
  ({lat, lng}: AggregationLayerColumns) =>
13✔
36
  dc =>
66✔
37
  d =>
66✔
38
    [dc.valueAt(d.index, lng.fieldIdx), dc.valueAt(d.index, lat.fieldIdx)];
881✔
39

40
export const pointPosResolver = ({lat, lng}: AggregationLayerColumns) =>
13✔
41
  `${lat.fieldIdx}-${lng.fieldIdx}`;
×
42

43
export const getValueAggrFunc = getPointData => (field, aggregation) => points =>
66✔
44
  field
33✔
45
    ? aggregate(
46
        points.map(p => field.valueAccessor(getPointData(p))),
9✔
47
        aggregation
48
      )
49
    : points.length;
50

51
export const getFilterDataFunc =
52
  (
13✔
53
    filterRange: number[][],
54
    getFilterValue: (d: unknown) => (number | number[])[]
55
  ): ((d: unknown) => boolean) =>
56
  pt =>
24✔
57
    getFilterValue(pt).every((val, i) => {
78✔
58
      return typeof val === 'number' ? val >= filterRange[i][0] && val <= filterRange[i][1] : false;
222!
59
    });
60

61
const NON_NUMERIC_FIELD_TYPES: Set<string> = new Set([
13✔
62
  ALL_FIELD_TYPES.string,
63
  ALL_FIELD_TYPES.boolean,
64
  ALL_FIELD_TYPES.date
65
]);
66

67
/**
68
 * Wrap a per-bin accessor that may return a non-numeric value (e.g. a string
69
 * from "mode" aggregation) so that it returns a stable numeric index instead.
70
 * deck.gl 9's native CPU aggregation stores results in a Float32Array which
71
 * silently converts strings to NaN — this wrapper prevents that.
72
 */
73
function wrapOrdinalAccessor(
74
  accessor: (points: unknown[]) => unknown
75
): (points: unknown[]) => number {
76
  const valueToIndex = new Map<string, number>();
3✔
77
  return (points: unknown[]) => {
3✔
78
    const value = accessor(points);
1✔
79
    if (value == null) return NaN;
1!
80
    const key = String(value);
1✔
81
    let idx = valueToIndex.get(key);
1✔
82
    if (idx === undefined) {
1!
83
      idx = valueToIndex.size;
1✔
84
      valueToIndex.set(key, idx);
1✔
85
    }
86
    return idx;
1✔
87
  };
88
}
89

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

92
export const aggregateRequiredColumns: ['lat', 'lng'] = ['lat', 'lng'];
13✔
93

94
export type AggregationLayerVisualChannelConfig = LayerColorConfig & LayerSizeConfig;
95
export type AggregationLayerConfig = Merge<LayerBaseConfig, {columns: AggregationLayerColumns}> &
96
  AggregationLayerVisualChannelConfig;
97
export default class AggregationLayer extends Layer {
98
  getColorRange: any;
99
  declare config: AggregationLayerConfig;
100
  declare getPointData: (any) => any;
101
  declare gpuFilterGetIndex: (any) => number;
102
  declare gpuFilterGetData: (dataContainer, data, fieldIndex) => any;
103

104
  constructor(
105
    props: {
106
      id?: string;
107
    } & LayerBaseConfigPartial
108
  ) {
109
    super(props);
115✔
110

111
    this.getPositionAccessor = dataContainer =>
115✔
112
      pointPosAccessor(this.config.columns)(dataContainer);
66✔
113
    this.getColorRange = memoize(getLayerColorRange);
115✔
114

115
    // Access data of a point from aggregated bins
116
    // In deck.gl 9, aggregation layers pass original data items directly to getColorValue/getElevationValue
117
    this.getPointData = pt => pt;
115✔
118

119
    this.gpuFilterGetIndex = pt => this.getPointData(pt).index;
115✔
120
    this.gpuFilterGetData = (dataContainer, data, fieldIndex) =>
115✔
121
      dataContainer.valueAt(data.index, fieldIndex);
78✔
122
  }
123

124
  get isAggregated(): true {
125
    return true;
3✔
126
  }
127

128
  get requiredLayerColumns() {
129
    return aggregateRequiredColumns;
142✔
130
  }
131

132
  get columnPairs() {
133
    return this.defaultPointColumnPairs;
3✔
134
  }
135

136
  get noneLayerDataAffectingProps() {
137
    return [
×
138
      ...super.noneLayerDataAffectingProps,
139
      'enable3d',
140
      'colorRange',
141
      'colorDomain',
142
      'sizeRange',
143
      'sizeScale',
144
      'sizeDomain',
145
      'percentile',
146
      'coverage',
147
      'elevationPercentile',
148
      'elevationScale',
149
      'enableElevationZoomFactor',
150
      'fixedHeight'
151
    ];
152
  }
153

154
  get visualChannels(): VisualChannels {
155
    return {
297✔
156
      color: {
157
        aggregation: 'colorAggregation',
158
        channelScaleType: CHANNEL_SCALES.colorAggr,
159
        defaultMeasure: 'property.pointCount',
160
        domain: 'colorDomain',
161
        field: 'colorField',
162
        key: 'color',
163
        property: 'color',
164
        range: 'colorRange',
165
        scale: 'colorScale'
166
      },
167
      size: {
168
        aggregation: 'sizeAggregation',
169
        channelScaleType: CHANNEL_SCALES.sizeAggr,
170
        condition: config => config.visConfig.enable3d,
1✔
171
        defaultMeasure: 'property.pointCount',
172
        domain: 'sizeDomain',
173
        field: 'sizeField',
174
        key: 'size',
175
        property: 'height',
176
        range: 'sizeRange',
177
        scale: 'sizeScale'
178
      }
179
    };
180
  }
181

182
  /**
183
   * Get the description of a visualChannel config
184
   * @param key
185
   * @returns
186
   */
187
  getVisualChannelDescription(key: string): VisualChannelDescription {
188
    const channel = this.visualChannels[key];
2✔
189
    if (!channel) return {label: '', measure: undefined};
2!
190
    // e.g. label: Color, measure: Average of ETA
191
    const {range, field, defaultMeasure, aggregation} = channel;
2✔
192
    const fieldConfig = this.config[field];
2✔
193
    const label = this.visConfigSettings[range]?.label;
2✔
194

195
    return {
2✔
196
      label: typeof label === 'function' ? label(this.config) : label || '',
4!
197
      measure:
198
        fieldConfig && aggregation
4!
199
          ? `${this.config.visConfig[aggregation]} of ${
200
              fieldConfig.displayName || fieldConfig.name
×
201
            }`
202
          : defaultMeasure
203
    };
204
  }
205

206
  getHoverData(object: any, dataContainer: DataContainerInterface, fields: Field[]): any {
207
    if (!object) return object;
×
208
    const measure = this.config.visConfig.colorAggregation;
×
209
    // aggregate all fields for the hovered group
210
    const aggregatedData = fields.reduce((accu, field) => {
×
211
      accu[field.name] = {
×
212
        measure,
213
        value: aggregate(object.points, measure, (d: {index: number}) => {
214
          return dataContainer.valueAt(d.index, field.fieldIdx);
×
215
        })
216
      };
217
      return accu;
×
218
    }, {});
219

220
    // return aggregated object
221
    return {aggregatedData, ...object};
×
222
  }
223

224
  getFilteredItemCount() {
225
    // gpu filter not supported
226
    return null;
×
227
  }
228

229
  /**
230
   * Aggregation layer handles visual channel aggregation inside deck.gl layer
231
   */
232
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
233
  updateLayerVisualChannel({dataContainer}, channel) {
234
    this.validateVisualChannel(channel);
×
235
  }
236

237
  /**
238
   * Validate aggregation type on top of basic layer visual channel validation
239
   * @param channel
240
   */
241
  validateVisualChannel(channel) {
242
    // field type decides aggregation type decides scale type
243
    this.validateFieldType(channel);
53✔
244
    this.validateAggregationType(channel);
53✔
245
    this.validateScale(channel);
53✔
246
  }
247

248
  /**
249
   * Validate aggregation type based on selected field
250
   */
251
  validateAggregationType(channel) {
252
    const visualChannel = this.visualChannels[channel];
53✔
253
    const {field, aggregation} = visualChannel;
53✔
254
    const aggregationOptions = this.getAggregationOptions(channel);
53✔
255

256
    if (!aggregation) {
53!
257
      return;
×
258
    }
259

260
    if (!aggregationOptions.length) {
53!
261
      // if field cannot be aggregated, set field to null
262
      this.updateLayerConfig({[field]: null});
×
263
    } else if (!aggregationOptions.includes(this.config.visConfig[aggregation])) {
53✔
264
      // current aggregation type is not supported by this field
265
      // set aggregation to the first supported option
266
      this.updateLayerVisConfig({[aggregation]: aggregationOptions[0]});
31✔
267
    } else if (
22!
268
      this.config[field] &&
28✔
269
      this.config.visConfig[aggregation] === AGGREGATION_TYPES.count
270
    ) {
271
      // When a field is selected but aggregation is still 'count' (carried over
272
      // from the no-field / "Count Points" state), switch to a meaningful default.
273
      // 'count' ignores the field values entirely, so keeping it would make the
274
      // field selection appear to have no effect.
NEW
275
      const meaningful = aggregationOptions.find(opt => opt !== AGGREGATION_TYPES.count);
×
NEW
276
      if (meaningful) {
×
NEW
277
        this.updateLayerVisConfig({[aggregation]: meaningful});
×
278
      }
279
    }
280
  }
281

282
  getAggregationOptions(channel) {
283
    const visualChannel = this.visualChannels[channel];
53✔
284
    const {field, channelScaleType} = visualChannel;
53✔
285

286
    return Object.keys(
53✔
287
      this.config[field]
53✔
288
        ? FIELD_OPTS[this.config[field].type].scale[channelScaleType]
289
        : DEFAULT_AGGREGATION[channelScaleType]
290
    );
291
  }
292

293
  /**
294
   * Get scale options based on current field and aggregation type
295
   * @param channel
296
   * @returns
297
   */
298
  getScaleOptions(channel: string): string[] {
299
    const visualChannel = this.visualChannels[channel];
53✔
300
    const {field, aggregation, channelScaleType} = visualChannel;
53✔
301
    const aggregationType = aggregation ? this.config.visConfig[aggregation] : null;
53!
302

303
    if (!aggregationType) {
53!
304
      return [];
×
305
    }
306

307
    return this.config[field]
53✔
308
      ? // scale options based on aggregation
309
        FIELD_OPTS[this.config[field].type].scale[channelScaleType][aggregationType]
310
      : // default scale options for point count: aggregationType should be count since
311
        // LAYER_VIS_CONFIGS.aggregation.defaultValue is AGGREGATION_TYPES.average,
312
        DEFAULT_AGGREGATION[channelScaleType][AGGREGATION_TYPES.count];
313
  }
314

315
  /**
316
   * Aggregation layer handles visual channel aggregation inside deck.gl layer
317
   */
318
  updateLayerDomain(): AggregationLayer {
319
    return this;
33✔
320
  }
321

322
  updateLayerMeta(dataset: KeplerTable, getPosition) {
323
    const {dataContainer} = dataset;
31✔
324
    // get bounds from points
325
    const bounds = this.getPointsBounds(dataContainer, getPosition);
31✔
326

327
    this.updateMeta({bounds});
31✔
328
  }
329

330
  calculateDataAttribute({filteredIndex}: KeplerTable, getPosition) {
331
    const data: AggregationLayerData[] = [];
31✔
332

333
    for (let i = 0; i < filteredIndex.length; i++) {
31✔
334
      const index = filteredIndex[i];
407✔
335
      const pos = getPosition({index});
407✔
336

337
      // if doesn't have point lat or lng, do not add the point
338
      // deck.gl can't handle position = null
339
      if (pos.every(Number.isFinite)) {
407✔
340
        data.push({
391✔
341
          index
342
        });
343
      }
344
    }
345

346
    return data;
31✔
347
  }
348

349
  formatLayerData(datasets: Datasets, oldLayerData) {
350
    if (this.config.dataId === null) {
33!
351
      return {};
×
352
    }
353
    const {gpuFilter, dataContainer} = datasets[this.config.dataId];
33✔
354
    const getPosition = this.getPositionAccessor(dataContainer);
33✔
355

356
    const hasFilter = Object.values(gpuFilter.filterRange).some((arr: any) =>
33✔
357
      arr.some(v => v !== 0)
102✔
358
    );
359

360
    const getFilterValue = gpuFilter.filterValueAccessor(dataContainer)(
33✔
361
      this.gpuFilterGetIndex,
362
      this.gpuFilterGetData
363
    );
364
    const filterData = hasFilter
33✔
365
      ? getFilterDataFunc(gpuFilter.filterRange, getFilterValue)
366
      : undefined;
367

368
    const aggregatePoints = getValueAggrFunc(this.getPointData);
33✔
369
    let getColorValue = aggregatePoints(
33✔
370
      this.config.colorField,
371
      this.config.visConfig.colorAggregation
372
    );
373

374
    let getElevationValue = aggregatePoints(
33✔
375
      this.config.sizeField,
376
      this.config.visConfig.sizeAggregation
377
    );
378

379
    // deck.gl 9's native CPU aggregation stores getColorValue/getElevationValue
380
    // results in a Float32Array. "mode" aggregation on non-numeric fields returns
381
    // a string, which becomes NaN in Float32Array. Wrap with ordinal mapping to
382
    // convert strings to stable numeric indices.
383
    if (
33✔
384
      this.config.colorField &&
42✔
385
      this.config.visConfig.colorAggregation === AGGREGATION_TYPES.mode &&
386
      NON_NUMERIC_FIELD_TYPES.has(this.config.colorField.type)
387
    ) {
388
      getColorValue = wrapOrdinalAccessor(getColorValue);
3✔
389
    }
390
    if (
33!
391
      this.config.sizeField &&
35!
392
      this.config.visConfig.sizeAggregation === AGGREGATION_TYPES.mode &&
393
      NON_NUMERIC_FIELD_TYPES.has(this.config.sizeField.type)
394
    ) {
NEW
395
      getElevationValue = wrapOrdinalAccessor(getElevationValue);
×
396
    }
397

398
    // Wrap accessors to filter points within each bin before aggregating.
399
    // deck.gl 9's native aggregation doesn't support per-bin filtering, so we
400
    // apply gpuFilter at the accessor level to keep bin values in sync with
401
    // active cross-filters / time-filters.
402
    const getFilteredColorValue =
403
      filterData && getColorValue
33✔
404
        ? points => getColorValue(points.filter(filterData))
20✔
405
        : getColorValue;
406
    const getFilteredElevationValue =
407
      filterData && getElevationValue
33✔
408
        ? points => getElevationValue(points.filter(filterData))
13✔
409
        : getElevationValue;
410

411
    const {data} = this.updateData(datasets, oldLayerData);
33✔
412

413
    const result = {
33✔
414
      data,
415
      getPosition,
416
      _filterData: filterData,
417
      ...(getFilteredColorValue ? {getColorValue: getFilteredColorValue} : {}),
33!
418
      ...(getFilteredElevationValue ? {getElevationValue: getFilteredElevationValue} : {})
33!
419
    };
420

421
    return result;
33✔
422
  }
423

424
  getDefaultDeckLayerProps(opts): any {
425
    const baseProp = super.getDefaultDeckLayerProps(opts);
6✔
426
    return {
6✔
427
      ...baseProp,
428
      highlightColor: HIGHLIGH_COLOR_3D,
429
      // gpu data filtering is not supported in aggregation layer
430
      extensions: [],
431
      autoHighlight: this.config.visConfig.enable3d
432
    };
433
  }
434

435
  getDefaultAggregationLayerProp(opts) {
436
    const {gpuFilter, mapState, layerCallbacks = {}} = opts;
5!
437
    const {visConfig} = this.config;
5✔
438
    const eleZoomFactor = this.getElevationZoomFactor(mapState);
5✔
439

440
    const updateTriggers = {
5✔
441
      getColorValue: {
442
        colorField: this.config.colorField,
443
        colorAggregation: this.config.visConfig.colorAggregation,
444
        colorRange: visConfig.colorRange,
445
        colorMap: visConfig.colorRange.colorMap,
446
        filterRange: gpuFilter.filterRange,
447
        ...gpuFilter.filterValueUpdateTriggers
448
      },
449
      getElevationValue: {
450
        sizeField: this.config.sizeField,
451
        sizeAggregation: this.config.visConfig.sizeAggregation,
452
        filterRange: gpuFilter.filterRange,
453
        ...gpuFilter.filterValueUpdateTriggers
454
      }
455
    };
456

457
    return {
5✔
458
      ...this.getDefaultDeckLayerProps(opts),
459
      coverage: visConfig.coverage,
460

461
      // color
462
      colorRange: this.getColorRange(visConfig.colorRange),
463
      colorMap: visConfig.colorRange.colorMap,
464
      colorScaleType: this.config.colorScale,
465
      upperPercentile: visConfig.percentile[1],
466
      lowerPercentile: visConfig.percentile[0],
467
      colorAggregation: visConfig.colorAggregation,
468

469
      // elevation
470
      extruded: visConfig.enable3d,
471
      elevationScale: visConfig.elevationScale * eleZoomFactor,
472
      elevationScaleType: this.config.sizeScale,
473
      elevationRange: visConfig.sizeRange,
474
      elevationFixed: visConfig.fixedHeight,
475

476
      elevationLowerPercentile: visConfig.elevationPercentile[0],
477
      elevationUpperPercentile: visConfig.elevationPercentile[1],
478

479
      // updateTriggers
480
      updateTriggers,
481

482
      // callbacks
483
      onSetColorDomain: layerCallbacks.onSetLayerDomain
484
    };
485
  }
486
}
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