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

keplergl / kepler.gl / 24838470154

23 Apr 2026 01:38PM UTC coverage: 59.469% (-0.1%) from 59.564%
24838470154

push

github

web-flow
fix: aggregation layers regressions after deck.gl upgrade (#3383)

* fix: fix Map legend is not following hex aggregation values

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

* more fixes

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

* revert, allow double event fire

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

* igr/fix-aggregation-regression

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

* fix tests

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

* nit

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

* move to shared utils

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

* follow ups

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>

6831 of 13763 branches covered (49.63%)

Branch coverage included in aggregate %.

35 of 82 new or added lines in 5 files covered. (42.68%)

20 existing lines in 2 files now uncovered.

14060 of 21366 relevant lines covered (65.81%)

75.92 hits per line

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

64.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
} 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 =>
58✔
37
  d =>
58✔
38
    [dc.valueAt(d.index, lng.fieldIdx), dc.valueAt(d.index, lat.fieldIdx)];
689✔
39

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

43
export const getValueAggrFunc = getPointData => (field, aggregation) => points =>
58✔
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 =>
29✔
57
    getFilterValue(pt).every((val, i) => {
104✔
58
      return typeof val === 'number' ? val >= filterRange[i][0] && val <= filterRange[i][1] : false;
323!
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);
58✔
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
    // When the color scale type changes, recompute colorDomain from stored aggregatedBins.
237
    // quantile scale needs the full sorted array of bin values; other scales need [min, max].
238
    // aggregatedBins is only populated from onSetColorDomain, so restrict to the color channel.
NEW
239
    const visualChannel = this.visualChannels[channel];
×
NEW
240
    if (channel === 'color' && visualChannel && this.config.aggregatedBins) {
×
NEW
241
      const scaleType = this.config[visualChannel.scale];
×
NEW
242
      const domainKey = visualChannel.domain;
×
NEW
243
      const bins = Object.values(this.config.aggregatedBins) as {value: number}[];
×
NEW
244
      if (bins.length > 0) {
×
NEW
245
        if (scaleType === 'quantile') {
×
NEW
246
          const sorted = bins
×
NEW
247
            .map(b => b.value)
×
248
            .filter(Number.isFinite)
NEW
249
            .sort((a, b) => a - b);
×
NEW
250
          this.updateLayerConfig({[domainKey]: sorted});
×
251
        } else {
NEW
252
          let min = Infinity;
×
NEW
253
          let max = -Infinity;
×
NEW
254
          for (const b of bins) {
×
NEW
255
            if (Number.isFinite(b.value)) {
×
NEW
256
              if (b.value < min) min = b.value;
×
NEW
257
              if (b.value > max) max = b.value;
×
258
            }
259
          }
NEW
260
          if (Number.isFinite(min) && Number.isFinite(max)) {
×
NEW
261
            this.updateLayerConfig({[domainKey]: [min, max]});
×
262
          }
263
        }
264
      }
265
    }
266
  }
267

268
  /**
269
   * Validate aggregation type on top of basic layer visual channel validation
270
   * @param channel
271
   */
272
  validateVisualChannel(channel) {
273
    // field type decides aggregation type decides scale type
274
    this.validateFieldType(channel);
53✔
275
    this.validateAggregationType(channel);
53✔
276
    this.validateScale(channel);
53✔
277
  }
278

279
  /**
280
   * Validate aggregation type based on selected field
281
   */
282
  validateAggregationType(channel) {
283
    const visualChannel = this.visualChannels[channel];
53✔
284
    const {field, aggregation} = visualChannel;
53✔
285
    const aggregationOptions = this.getAggregationOptions(channel);
53✔
286

287
    if (!aggregation) {
53!
288
      return;
×
289
    }
290

291
    if (!aggregationOptions.length) {
53!
292
      // if field cannot be aggregated, set field to null
293
      this.updateLayerConfig({[field]: null});
×
294
    } else if (!aggregationOptions.includes(this.config.visConfig[aggregation])) {
53✔
295
      // current aggregation type is not supported by this field
296
      // set aggregation to the first supported option
297
      this.updateLayerVisConfig({[aggregation]: aggregationOptions[0]});
31✔
298
    } else if (
22!
299
      this.config[field] &&
28✔
300
      this.config.visConfig[aggregation] === AGGREGATION_TYPES.count
301
    ) {
302
      // When a field is selected but aggregation is still 'count' (carried over
303
      // from the no-field / "Count Points" state), switch to a meaningful default.
304
      // 'count' ignores the field values entirely, so keeping it would make the
305
      // field selection appear to have no effect.
306
      const meaningful = aggregationOptions.find(opt => opt !== AGGREGATION_TYPES.count);
×
307
      if (meaningful) {
×
308
        this.updateLayerVisConfig({[aggregation]: meaningful});
×
309
      }
310
    }
311
  }
312

313
  getAggregationOptions(channel) {
314
    const visualChannel = this.visualChannels[channel];
53✔
315
    const {field, channelScaleType} = visualChannel;
53✔
316

317
    return Object.keys(
53✔
318
      this.config[field]
53✔
319
        ? FIELD_OPTS[this.config[field].type].scale[channelScaleType]
320
        : DEFAULT_AGGREGATION[channelScaleType]
321
    );
322
  }
323

324
  /**
325
   * Get scale options based on current field and aggregation type
326
   * @param channel
327
   * @returns
328
   */
329
  getScaleOptions(channel: string): string[] {
330
    const visualChannel = this.visualChannels[channel];
53✔
331
    const {field, aggregation, channelScaleType} = visualChannel;
53✔
332
    const aggregationType = aggregation ? this.config.visConfig[aggregation] : null;
53!
333

334
    if (!aggregationType) {
53!
335
      return [];
×
336
    }
337

338
    return this.config[field]
53✔
339
      ? // scale options based on aggregation
340
        FIELD_OPTS[this.config[field].type].scale[channelScaleType][aggregationType]
341
      : // default scale options for point count: aggregationType should be count since
342
        // LAYER_VIS_CONFIGS.aggregation.defaultValue is AGGREGATION_TYPES.average,
343
        DEFAULT_AGGREGATION[channelScaleType][AGGREGATION_TYPES.count];
344
  }
345

346
  /**
347
   * Aggregation layer handles visual channel aggregation inside deck.gl layer
348
   */
349
  updateLayerDomain(): AggregationLayer {
350
    return this;
29✔
351
  }
352

353
  updateLayerMeta(dataset: KeplerTable, getPosition) {
354
    const {dataContainer} = dataset;
27✔
355
    // get bounds from points
356
    const bounds = this.getPointsBounds(dataContainer, getPosition);
27✔
357

358
    this.updateMeta({bounds});
27✔
359
  }
360

361
  calculateDataAttribute({filteredIndex}: KeplerTable, getPosition) {
362
    const data: AggregationLayerData[] = [];
27✔
363

364
    for (let i = 0; i < filteredIndex.length; i++) {
27✔
365
      const index = filteredIndex[i];
311✔
366
      const pos = getPosition({index});
311✔
367

368
      // if doesn't have point lat or lng, do not add the point
369
      // deck.gl can't handle position = null
370
      if (pos.every(Number.isFinite)) {
311✔
371
        data.push({
295✔
372
          index
373
        });
374
      }
375
    }
376

377
    return data;
27✔
378
  }
379

380
  formatLayerData(datasets: Datasets, oldLayerData) {
381
    if (this.config.dataId === null) {
29!
382
      return {};
×
383
    }
384
    const {gpuFilter, dataContainer} = datasets[this.config.dataId];
29✔
385
    const getPosition = this.getPositionAccessor(dataContainer);
29✔
386

387
    const hasFilter = Object.values(gpuFilter.filterRange).some((arr: any) =>
29✔
388
      arr.some(v => v !== 0)
70✔
389
    );
390

391
    const getFilterValue = gpuFilter.filterValueAccessor(dataContainer)(
29✔
392
      this.gpuFilterGetIndex,
393
      this.gpuFilterGetData
394
    );
395
    const filterData = hasFilter
29✔
396
      ? getFilterDataFunc(gpuFilter.filterRange, getFilterValue)
397
      : undefined;
398

399
    const aggregatePoints = getValueAggrFunc(this.getPointData);
29✔
400
    let getColorValue = aggregatePoints(
29✔
401
      this.config.colorField,
402
      this.config.visConfig.colorAggregation
403
    );
404

405
    let getElevationValue = aggregatePoints(
29✔
406
      this.config.sizeField,
407
      this.config.visConfig.sizeAggregation
408
    );
409

410
    // deck.gl 9's native CPU aggregation stores getColorValue/getElevationValue
411
    // results in a Float32Array. "mode" aggregation on non-numeric fields returns
412
    // a string, which becomes NaN in Float32Array. Wrap with ordinal mapping to
413
    // convert strings to stable numeric indices.
414
    if (
29✔
415
      this.config.colorField &&
38✔
416
      this.config.visConfig.colorAggregation === AGGREGATION_TYPES.mode &&
417
      NON_NUMERIC_FIELD_TYPES.has(this.config.colorField.type)
418
    ) {
419
      getColorValue = wrapOrdinalAccessor(getColorValue);
3✔
420
    }
421
    if (
29!
422
      this.config.sizeField &&
31!
423
      this.config.visConfig.sizeAggregation === AGGREGATION_TYPES.mode &&
424
      NON_NUMERIC_FIELD_TYPES.has(this.config.sizeField.type)
425
    ) {
426
      getElevationValue = wrapOrdinalAccessor(getElevationValue);
×
427
    }
428

429
    // Wrap accessors to filter points within each bin before aggregating.
430
    // deck.gl 9's native aggregation doesn't support per-bin filtering, so we
431
    // apply gpuFilter at the accessor level to keep bin values in sync with
432
    // active cross-filters / time-filters.
433
    const getFilteredColorValue =
434
      filterData && getColorValue
29✔
435
        ? points => getColorValue(points.filter(filterData))
20✔
436
        : getColorValue;
437
    const getFilteredElevationValue =
438
      filterData && getElevationValue
29✔
439
        ? points => getElevationValue(points.filter(filterData))
13✔
440
        : getElevationValue;
441

442
    const {data} = this.updateData(datasets, oldLayerData);
29✔
443

444
    const result = {
29✔
445
      data,
446
      getPosition,
447
      _filterData: filterData,
448
      ...(getFilteredColorValue ? {getColorValue: getFilteredColorValue} : {}),
29!
449
      ...(getFilteredElevationValue ? {getElevationValue: getFilteredElevationValue} : {})
29!
450
    };
451

452
    return result;
29✔
453
  }
454

455
  getDefaultDeckLayerProps(opts): any {
456
    const baseProp = super.getDefaultDeckLayerProps(opts);
6✔
457
    return {
6✔
458
      ...baseProp,
459
      highlightColor: HIGHLIGH_COLOR_3D,
460
      // gpu data filtering is not supported in aggregation layer
461
      extensions: [],
462
      autoHighlight: this.config.visConfig.enable3d
463
    };
464
  }
465

466
  getDefaultAggregationLayerProp(opts) {
467
    const {gpuFilter, mapState, layerCallbacks = {}} = opts;
5!
468
    const {visConfig} = this.config;
5✔
469
    const eleZoomFactor = this.getElevationZoomFactor(mapState);
5✔
470

471
    const updateTriggers = {
5✔
472
      getColorValue: {
473
        colorField: this.config.colorField,
474
        colorAggregation: this.config.visConfig.colorAggregation,
475
        colorRange: visConfig.colorRange,
476
        colorMap: visConfig.colorRange.colorMap,
477
        filterRange: gpuFilter.filterRange,
478
        ...gpuFilter.filterValueUpdateTriggers
479
      },
480
      getElevationValue: {
481
        sizeField: this.config.sizeField,
482
        sizeAggregation: this.config.visConfig.sizeAggregation,
483
        filterRange: gpuFilter.filterRange,
484
        ...gpuFilter.filterValueUpdateTriggers
485
      }
486
    };
487

488
    // deck.gl's aggregation shader maps bin values to a color texture using a
489
    // simple linear interpolation: (value - domain[0]) / (domain[1] - domain[0]).
490
    // It only understands 'quantize', 'quantile', 'ordinal', and 'linear'.
491
    // kepler.gl's 'custom' scale (d3.scaleThreshold with user-defined break
492
    // points) cannot be represented in the shader directly.  Instead, our
493
    // ScaleEnhanced*Layer._onAggregationUpdate reclassifies each bin's raw
494
    // value into a break index [0 … N-1].  We then tell deck.gl to use
495
    // 'quantize' over [0, N-1] so each index maps to the correct color pixel.
496
    let colorScaleType = this.config.colorScale as string;
5✔
497
    let customColorDomain: [number, number] | undefined;
498
    const isCustomScale = colorScaleType === 'custom';
5✔
499
    const colorMap = isCustomScale ? visConfig.colorRange.colorMap : undefined;
5!
500
    if (isCustomScale && colorMap) {
5!
NEW
501
      colorScaleType = 'quantize';
×
NEW
502
      customColorDomain = [0, colorMap.length - 1];
×
503
    }
504

505
    return {
5✔
506
      ...this.getDefaultDeckLayerProps(opts),
507
      coverage: visConfig.coverage,
508

509
      // color
510
      colorRange: this.getColorRange(visConfig.colorRange),
511
      colorMap,
512
      colorScaleType,
513
      ...(customColorDomain ? {colorDomain: customColorDomain} : {}),
5!
514
      upperPercentile: visConfig.percentile[1],
515
      lowerPercentile: visConfig.percentile[0],
516
      colorAggregation: visConfig.colorAggregation,
517

518
      // elevation
519
      extruded: visConfig.enable3d,
520
      elevationScale: visConfig.elevationScale * eleZoomFactor,
521
      elevationScaleType: this.config.sizeScale,
522
      elevationRange: visConfig.sizeRange,
523
      elevationFixed: visConfig.fixedHeight,
524

525
      elevationLowerPercentile: visConfig.elevationPercentile[0],
526
      elevationUpperPercentile: visConfig.elevationPercentile[1],
527

528
      // updateTriggers
529
      updateTriggers,
530

531
      // callbacks
532
      onSetColorDomain: layerCallbacks.onSetLayerDomain
533
    };
534
  }
535
}
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