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

keplergl / kepler.gl / 12207346486

06 Dec 2024 10:55PM UTC coverage: 69.215% (-0.07%) from 69.289%
12207346486

Pull #2814

github

web-flow
Merge fade732f8 into 3950d73ab
Pull Request #2814: [feat] Show selected fields in the tooltip for aggregation layers

5448 of 9121 branches covered (59.73%)

Branch coverage included in aggregate %.

0 of 14 new or added lines in 2 files covered. (0.0%)

7 existing lines in 1 file now uncovered.

11381 of 15193 relevant lines covered (74.91%)

95.02 hits per line

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

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

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

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

33
export const pointPosAccessor =
34
  ({lat, lng}: AggregationLayerColumns) =>
11✔
35
  dc =>
70✔
36
  d =>
70✔
37
    [dc.valueAt(d.index, lng.fieldIdx), dc.valueAt(d.index, lat.fieldIdx)];
941✔
38

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

42
export const getValueAggrFunc = getPointData => (field, aggregation) => points =>
70✔
43
  field
26✔
44
    ? aggregate(
45
        points.map(p => field.valueAccessor(getPointData(p))),
11✔
46
        aggregation
47
      )
48
    : points.length;
49

50
export const getFilterDataFunc =
51
  (
11✔
52
    filterRange: number[][],
53
    getFilterValue: (d: unknown) => (number | number[])[]
54
  ): ((d: unknown) => boolean) =>
55
  pt =>
26✔
56
    getFilterValue(pt).every((val, i) => val >= filterRange[i][0] && val <= filterRange[i][1]);
182✔
57

58
const getLayerColorRange = (colorRange: ColorRange) => colorRange.colors.map(hexToRgb);
11✔
59

60
export const aggregateRequiredColumns: ['lat', 'lng'] = ['lat', 'lng'];
11✔
61

62
export type AggregationLayerVisualChannelConfig = LayerColorConfig & LayerSizeConfig;
63
export type AggregationLayerConfig = Merge<LayerBaseConfig, {columns: AggregationLayerColumns}> &
64
  AggregationLayerVisualChannelConfig;
65
export default class AggregationLayer extends Layer {
66
  getColorRange: any;
67
  declare config: AggregationLayerConfig;
68
  declare getPointData: (any) => any;
69
  declare gpuFilterGetIndex: (any) => number;
70
  declare gpuFilterGetData: (dataContainer, data, fieldIndex) => any;
71

72
  constructor(
73
    props: {
74
      id?: string;
75
    } & LayerBaseConfigPartial
76
  ) {
77
    super(props);
117✔
78

79
    this.getPositionAccessor = dataContainer =>
117✔
80
      pointPosAccessor(this.config.columns)(dataContainer);
70✔
81
    this.getColorRange = memoize(getLayerColorRange);
117✔
82

83
    // Access data of a point from aggregated bins, depends on how BinSorter works
84
    // Deck.gl's BinSorter puts data in point.source
85
    this.getPointData = pt => pt.source;
117✔
86

87
    this.gpuFilterGetIndex = pt => this.getPointData(pt).index;
117✔
88
    this.gpuFilterGetData = (dataContainer, data, fieldIndex) =>
117✔
89
      dataContainer.valueAt(data.index, fieldIndex);
65✔
90
  }
91

92
  get isAggregated(): true {
93
    return true;
3✔
94
  }
95

96
  get requiredLayerColumns() {
97
    return aggregateRequiredColumns;
146✔
98
  }
99

100
  get columnPairs() {
101
    return this.defaultPointColumnPairs;
3✔
102
  }
103

104
  get noneLayerDataAffectingProps() {
105
    return [
×
106
      ...super.noneLayerDataAffectingProps,
107
      'enable3d',
108
      'colorRange',
109
      'colorDomain',
110
      'sizeRange',
111
      'sizeScale',
112
      'sizeDomain',
113
      'percentile',
114
      'coverage',
115
      'elevationPercentile',
116
      'elevationScale',
117
      'enableElevationZoomFactor',
118
      'fixedHeight'
119
    ];
120
  }
121

122
  get visualChannels(): VisualChannels {
123
    return {
320✔
124
      color: {
125
        aggregation: 'colorAggregation',
126
        channelScaleType: CHANNEL_SCALES.colorAggr,
127
        defaultMeasure: 'property.pointCount',
128
        domain: 'colorDomain',
129
        field: 'colorField',
130
        key: 'color',
131
        property: 'color',
132
        range: 'colorRange',
133
        scale: 'colorScale'
134
      },
135
      size: {
136
        aggregation: 'sizeAggregation',
137
        channelScaleType: CHANNEL_SCALES.sizeAggr,
138
        condition: config => config.visConfig.enable3d,
1✔
139
        defaultMeasure: 'property.pointCount',
140
        domain: 'sizeDomain',
141
        field: 'sizeField',
142
        key: 'size',
143
        property: 'height',
144
        range: 'sizeRange',
145
        scale: 'sizeScale'
146
      }
147
    };
148
  }
149

150
  /**
151
   * Get the description of a visualChannel config
152
   * @param key
153
   * @returns
154
   */
155
  getVisualChannelDescription(key: string): VisualChannelDescription {
156
    const channel = this.visualChannels[key];
2✔
157
    if (!channel) return {label: '', measure: undefined};
2!
158
    // e.g. label: Color, measure: Average of ETA
159
    const {range, field, defaultMeasure, aggregation} = channel;
2✔
160
    const fieldConfig = this.config[field];
2✔
161
    const label = this.visConfigSettings[range]?.label;
2✔
162

163
    return {
2✔
164
      label: typeof label === 'function' ? label(this.config) : label || '',
4!
165
      measure:
166
        fieldConfig && aggregation
4!
167
          ? `${this.config.visConfig[aggregation]} of ${
168
              fieldConfig.displayName || fieldConfig.name
×
169
            }`
170
          : defaultMeasure
171
    };
172
  }
173

174
  getHoverData(object: any, dataContainer: DataContainerInterface, fields: Field[]): any {
NEW
175
    if (!object) return object;
×
176

177
    // aggregate all fields for the hovered group
NEW
178
    const aggregatedFields = fields.reduce((accu, field) => {
×
NEW
179
      accu[field.name] = {
×
180
        measure: this.config.visConfig.colorAggregation,
181
        value: aggregate(
182
          object.points,
183
          this.config.visConfig.colorAggregation,
184
          (d: {index: number}) => {
NEW
185
            return dataContainer.valueAt(d.index, field.fieldIdx);
×
186
          }
187
        )
188
      };
NEW
189
      return accu;
×
190
    }, {});
191

192
    // return aggregated object
UNCOV
193
    return {aggregatedData: aggregatedFields, ...object};
×
194
  }
195

196
  getFilteredItemCount() {
197
    // gpu filter not supported
UNCOV
198
    return null;
×
199
  }
200

201
  /**
202
   * Aggregation layer handles visual channel aggregation inside deck.gl layer
203
   */
204
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
205
  updateLayerVisualChannel({dataContainer}, channel) {
UNCOV
206
    this.validateVisualChannel(channel);
×
207
  }
208

209
  /**
210
   * Validate aggregation type on top of basic layer visual channel validation
211
   * @param channel
212
   */
213
  validateVisualChannel(channel) {
214
    // field type decides aggregation type decides scale type
215
    this.validateFieldType(channel);
57✔
216
    this.validateAggregationType(channel);
57✔
217
    this.validateScale(channel);
57✔
218
  }
219

220
  /**
221
   * Validate aggregation type based on selected field
222
   */
223
  validateAggregationType(channel) {
224
    const visualChannel = this.visualChannels[channel];
57✔
225
    const {field, aggregation} = visualChannel;
57✔
226
    const aggregationOptions = this.getAggregationOptions(channel);
57✔
227

228
    if (!aggregation) {
57!
UNCOV
229
      return;
×
230
    }
231

232
    if (!aggregationOptions.length) {
57!
233
      // if field cannot be aggregated, set field to null
UNCOV
234
      this.updateLayerConfig({[field]: null});
×
235
    } else if (!aggregationOptions.includes(this.config.visConfig[aggregation])) {
57✔
236
      // current aggregation type is not supported by this field
237
      // set aggregation to the first supported option
238
      this.updateLayerVisConfig({[aggregation]: aggregationOptions[0]});
35✔
239
    }
240
  }
241

242
  getAggregationOptions(channel) {
243
    const visualChannel = this.visualChannels[channel];
57✔
244
    const {field, channelScaleType} = visualChannel;
57✔
245

246
    return Object.keys(
57✔
247
      this.config[field]
57✔
248
        ? FIELD_OPTS[this.config[field].type].scale[channelScaleType]
249
        : DEFAULT_AGGREGATION[channelScaleType]
250
    );
251
  }
252

253
  /**
254
   * Get scale options based on current field and aggregation type
255
   * @param channel
256
   * @returns
257
   */
258
  getScaleOptions(channel: string): string[] {
259
    const visualChannel = this.visualChannels[channel];
57✔
260
    const {field, aggregation, channelScaleType} = visualChannel;
57✔
261
    const aggregationType = aggregation ? this.config.visConfig[aggregation] : null;
57!
262

263
    if (!aggregationType) {
57!
UNCOV
264
      return [];
×
265
    }
266

267
    return this.config[field]
57✔
268
      ? // scale options based on aggregation
269
        FIELD_OPTS[this.config[field].type].scale[channelScaleType][aggregationType]
270
      : // default scale options for point count
271
        DEFAULT_AGGREGATION[channelScaleType][aggregationType];
272
  }
273

274
  /**
275
   * Aggregation layer handles visual channel aggregation inside deck.gl layer
276
   */
277
  updateLayerDomain(): AggregationLayer {
278
    return this;
35✔
279
  }
280

281
  updateLayerMeta(dataContainer, getPosition) {
282
    // get bounds from points
283
    const bounds = this.getPointsBounds(dataContainer, getPosition);
33✔
284

285
    this.updateMeta({bounds});
33✔
286
  }
287

288
  calculateDataAttribute({filteredIndex}: KeplerTable, getPosition) {
289
    const data: AggregationLayerData[] = [];
33✔
290

291
    for (let i = 0; i < filteredIndex.length; i++) {
33✔
292
      const index = filteredIndex[i];
437✔
293
      const pos = getPosition({index});
437✔
294

295
      // if doesn't have point lat or lng, do not add the point
296
      // deck.gl can't handle position = null
297
      if (pos.every(Number.isFinite)) {
437✔
298
        data.push({
421✔
299
          index
300
        });
301
      }
302
    }
303

304
    return data;
33✔
305
  }
306

307
  formatLayerData(datasets: Datasets, oldLayerData) {
308
    if (this.config.dataId === null) {
35!
UNCOV
309
      return {};
×
310
    }
311
    const {gpuFilter, dataContainer} = datasets[this.config.dataId];
35✔
312
    const getPosition = this.getPositionAccessor(dataContainer);
35✔
313

314
    const aggregatePoints = getValueAggrFunc(this.getPointData);
35✔
315
    const getColorValue = aggregatePoints(
35✔
316
      this.config.colorField,
317
      this.config.visConfig.colorAggregation
318
    );
319

320
    const getElevationValue = aggregatePoints(
35✔
321
      this.config.sizeField,
322
      this.config.visConfig.sizeAggregation
323
    );
324
    const hasFilter = Object.values(gpuFilter.filterRange).some((arr: any) =>
35✔
325
      arr.some(v => v !== 0)
105✔
326
    );
327

328
    const getFilterValue = gpuFilter.filterValueAccessor(dataContainer)(
35✔
329
      this.gpuFilterGetIndex,
330
      this.gpuFilterGetData
331
    );
332
    const filterData = hasFilter
35✔
333
      ? getFilterDataFunc(gpuFilter.filterRange, getFilterValue)
334
      : undefined;
335

336
    const {data} = this.updateData(datasets, oldLayerData);
35✔
337

338
    return {
35✔
339
      data,
340
      getPosition,
341
      _filterData: filterData,
342
      // @ts-expect-error
343
      ...(getColorValue ? {getColorValue} : {}),
35!
344
      // @ts-expect-error
345
      ...(getElevationValue ? {getElevationValue} : {})
35!
346
    };
347
  }
348

349
  getDefaultDeckLayerProps(opts): any {
350
    const baseProp = super.getDefaultDeckLayerProps(opts);
6✔
351
    return {
6✔
352
      ...baseProp,
353
      highlightColor: HIGHLIGH_COLOR_3D,
354
      // gpu data filtering is not supported in aggregation layer
355
      extensions: [],
356
      autoHighlight: this.config.visConfig.enable3d
357
    };
358
  }
359

360
  getDefaultAggregationLayerProp(opts) {
361
    const {gpuFilter, mapState, layerCallbacks = {}} = opts;
5!
362
    const {visConfig} = this.config;
5✔
363
    const eleZoomFactor = this.getElevationZoomFactor(mapState);
5✔
364

365
    const updateTriggers = {
5✔
366
      getColorValue: {
367
        colorField: this.config.colorField,
368
        colorAggregation: this.config.visConfig.colorAggregation
369
      },
370
      getElevationValue: {
371
        sizeField: this.config.sizeField,
372
        sizeAggregation: this.config.visConfig.sizeAggregation
373
      },
374
      _filterData: {
375
        filterRange: gpuFilter.filterRange,
376
        ...gpuFilter.filterValueUpdateTriggers
377
      }
378
    };
379

380
    return {
5✔
381
      ...this.getDefaultDeckLayerProps(opts),
382
      coverage: visConfig.coverage,
383

384
      // color
385
      colorRange: this.getColorRange(visConfig.colorRange),
386
      colorScaleType: this.config.colorScale,
387
      upperPercentile: visConfig.percentile[1],
388
      lowerPercentile: visConfig.percentile[0],
389
      colorAggregation: visConfig.colorAggregation,
390

391
      // elevation
392
      extruded: visConfig.enable3d,
393
      elevationScale: visConfig.elevationScale * eleZoomFactor,
394
      elevationScaleType: this.config.sizeScale,
395
      elevationRange: visConfig.sizeRange,
396
      elevationFixed: visConfig.fixedHeight,
397

398
      elevationLowerPercentile: visConfig.elevationPercentile[0],
399
      elevationUpperPercentile: visConfig.elevationPercentile[1],
400

401
      // updateTriggers
402
      updateTriggers,
403

404
      // callbacks
405
      onSetColorDomain: layerCallbacks.onSetLayerDomain
406
    };
407
  }
408
}
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