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

keplergl / kepler.gl / 12508767309

26 Dec 2024 09:53PM UTC coverage: 67.491% (-0.02%) from 67.511%
12508767309

push

github

web-flow
[Feat] Add custom color scale for aggregate layers (#2860)

Signed-off-by: Ihor Dykhta <dikhta.igor@gmail.com>
Co-authored-by: Ilya Boyandin <iboyandin@foursquare.com>

5841 of 10041 branches covered (58.17%)

Branch coverage included in aggregate %.

54 of 64 new or added lines in 8 files covered. (84.38%)

1 existing line in 1 file now uncovered.

11978 of 16361 relevant lines covered (73.21%)

87.57 hits per line

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

85.46
/src/utils/src/data-scale-utils.ts
1
// SPDX-License-Identifier: MIT
2
// Copyright contributors to the kepler.gl project
3

4
import {bisectLeft, quantileSorted as d3Quantile, extent} from 'd3-array';
5
import moment from 'moment';
6

7
import {notNullorUndefined} from '@kepler.gl/common-utils';
8
import {ALL_FIELD_TYPES, SCALE_FUNC, SCALE_TYPES} from '@kepler.gl/constants';
9
// import {AggregatedBin, Layer, VisualChannel, VisualChannelDomain} from '@kepler.gl/layers';
10
// import {FilterProps, KeplerTable} from '@kepler.gl/layers';
11
import {ColorMap, ColorRange, HexColor, MapState} from '@kepler.gl/types';
12

13
import {isRgbColor, rgbToHex} from './color-utils';
14
import {DataContainerInterface} from './data-container-interface';
15
import {formatNumber, isNumber, reverseFormatNumber, unique} from './data-utils';
16
import {getTimeWidgetHintFormatter} from './filter-utils';
17
import {isPlainObject} from './utils';
18

19
export type ColorBreak = {
20
  data: HexColor;
21
  label: string;
22
  range: number[];
23
  inputs: number[];
24
};
25
export type ColorBreakOrdinal = {
26
  data: HexColor;
27
  label: string;
28
};
29

30
export type D3ScaleFunction = Record<string, any> & ((x: any) => any);
31

32
// TODO isolate types - depends on @kepler.gl/layers
33
type Layer = any;
34
type VisualChannel = any;
35
type VisualChannelDomain = any;
36
type AggregatedBin = any;
37
type FilterProps = any;
38
type KeplerTable = any;
39

40
export type LabelFormat = (n: number) => string;
41
type dataValueAccessor = <T>(param: T) => T;
42
type dataContainerValueAccessor = (d: {index: number}, dc: DataContainerInterface) => any;
43
type sort = (a: any, b: any) => any;
44
/**
45
 * return quantile domain for an array of data
46
 */
47
export function getQuantileDomain(
48
  data: any[],
49
  valueAccessor?: dataValueAccessor,
50
  sortFunc?: sort
51
): number[] {
52
  const values = typeof valueAccessor === 'function' ? data.map(valueAccessor) : data;
50✔
53

54
  return values.filter(notNullorUndefined).sort(sortFunc);
50✔
55
}
56

57
/**
58
 * return ordinal domain for a data container
59
 */
60
export function getOrdinalDomain(
61
  dataContainer: DataContainerInterface,
62
  valueAccessor: dataContainerValueAccessor
63
): string[] {
64
  const values = dataContainer.mapIndex(valueAccessor);
50✔
65

66
  return unique(values).filter(notNullorUndefined).sort();
50✔
67
}
68

69
/**
70
 * return linear domain for an array of data
71
 */
72
export function getLinearDomain(
73
  data: number[],
74
  valueAccessor?: dataValueAccessor
75
): [number, number] {
76
  const range = typeof valueAccessor === 'function' ? extent(data, valueAccessor) : extent(data);
132✔
77
  return range.map((d: undefined | number, i: number) => (d === undefined ? i : d)) as [
264✔
78
    number,
79
    number
80
  ];
81
}
82

83
/**
84
 * return linear domain for an array of data. A log scale domain cannot contain 0
85
 */
86
export function getLogDomain(data: any[], valueAccessor: dataValueAccessor): [number, number] {
87
  const [d0, d1] = getLinearDomain(data, valueAccessor);
3✔
88
  return [d0 === 0 ? 1e-5 : d0, d1];
3✔
89
}
90

91
export type DomainStops = {
92
  stops: number[];
93
  z: number[];
94
};
95

96
/**
97
 * whether field domain is stops
98
 */
99
export function isDomainStops(domain: unknown): domain is DomainStops {
100
  return isPlainObject(domain) && Array.isArray(domain.stops) && Array.isArray(domain.z);
161✔
101
}
102

103
export type DomainQuantiles = {
104
  quantiles: number[];
105
  z: number[];
106
};
107

108
/**
109
 * whether field domain is quantiles
110
 */
111
export function isDomainQuantile(domain: any): domain is DomainQuantiles {
112
  return isPlainObject(domain) && Array.isArray(domain.quantiles) && Array.isArray(domain.z);
161!
113
}
114

115
/**
116
 * get the domain at zoom
117
 */
118
export function getThresholdsFromQuantiles(
119
  quantiles: number[],
120
  buckets: number
121
): (number | undefined)[] {
122
  const thresholds = [];
3✔
123
  if (!Number.isFinite(buckets) || buckets < 1) {
3✔
124
    return [quantiles[0], quantiles[quantiles.length - 1]];
1✔
125
  }
126
  for (let i = 1; i < buckets; i++) {
2✔
127
    // position in sorted array
128
    const position = i / buckets;
2✔
129
    // @ts-ignore
130
    thresholds.push(d3Quantile(quantiles, position));
2✔
131
  }
132

133
  return thresholds;
2✔
134
}
135

136
/**
137
 * get the domain at zoom
138
 */
139
export function getDomainStepsbyZoom(domain: any[], steps: number[], z: number): any {
140
  const i = bisectLeft(steps, z);
13✔
141

142
  if (steps[i] === z) {
13✔
143
    // If z is an integer value exactly matching a step, return the corresponding domain
144
    return domain[i];
3✔
145
  }
146
  // Otherwise, return the next coarsest domain
147
  return domain[Math.max(i - 1, 0)];
10✔
148
}
149

150
/**
151
 * Get d3 scale function
152
 */
153
export function getScaleFunction(
154
  scale: string,
155
  range: any[] | IterableIterator<any>,
156
  domain: (number | undefined)[] | string[] | IterableIterator<any>,
157
  fixed?: boolean
158
): D3ScaleFunction {
159
  const scaleFunction = SCALE_FUNC[fixed ? 'linear' : scale]()
11!
160
    .domain(domain)
161
    .range(fixed ? domain : range);
11!
162
  scaleFunction.scaleType = fixed ? 'linear' : scale;
11!
163
  return scaleFunction;
11✔
164
}
165

166
/**
167
 * Get threshold scale color labels
168
 */
169
function getThresholdLabels(
170
  scale: D3ScaleFunction,
171
  labelFormat: LabelFormat
172
): Omit<ColorBreak, 'data'>[] {
173
  const genLength = scale.range().length;
3✔
174
  return scale.range().map((d, i) => {
3✔
175
    const invert = scale.invertExtent(d);
11✔
176
    const inputs = [
11✔
177
      i === 0 ? null : reverseFormatNumber(labelFormat(invert[0])),
11✔
178
      i === genLength - 1 ? null : reverseFormatNumber(labelFormat(invert[1]))
11✔
179
    ];
180
    return {
11✔
181
      // raw value
182
      range: invert,
183
      // formatted value
184
      inputs,
185
      label:
186
        i === 0
11✔
187
          ? `Less than ${labelFormat(invert[1])}`
188
          : i === genLength - 1
8✔
189
          ? `${labelFormat(invert[0])} or more`
190
          : `${labelFormat(invert[0])} to ${labelFormat(invert[1])}`
191
    };
192
  });
193
}
194

195
/**
196
 * Get linear / quant scale color labels
197
 */
198
function getScaleLabels(
199
  scale: D3ScaleFunction,
200
  labelFormat: LabelFormat
201
): Omit<ColorBreak, 'data'>[] {
202
  return scale.range().map((d, i) => {
11✔
203
    // @ts-ignore
204
    const invert = scale.invertExtent(d);
41✔
205
    const inputs = [
41✔
206
      reverseFormatNumber(labelFormat(invert[0])),
207
      reverseFormatNumber(labelFormat(invert[1]))
208
    ];
209

210
    return {
41✔
211
      label: `${labelFormat(invert[0])} to ${labelFormat(invert[1])}`,
212
      // raw value
213
      range: invert,
214
      // formatted value
215
      inputs
216
    };
217
  });
218
}
219

220
const customScaleLabelFormat = n => (n ? formatNumber(n, 'real') : 'no value');
32!
221
/**
222
 * Get linear / quant scale color breaks
223
 */
224
export function getQuantLegends(scale: D3ScaleFunction, labelFormat: LabelFormat): ColorBreak[] {
225
  if (typeof scale.invertExtent !== 'function') {
14!
226
    return [];
×
227
  }
228
  const labels =
229
    scale.scaleType === 'threshold' || scale.scaleType === 'custom'
14✔
230
      ? getThresholdLabels(
231
          scale,
232
          scale.scaleType === 'custom'
3!
233
            ? customScaleLabelFormat
234
            : n => (n ? formatNumber(n) : 'no value')
×
235
        )
236
      : getScaleLabels(scale, labelFormat);
237

238
  const data = scale.range();
14✔
239

240
  return labels.map((label, index) => ({
52✔
241
    data: Array.isArray(data[index]) ? rgbToHex(data[index]) : data[index],
52!
242
    ...label
243
  }));
244
}
245

246
/**
247
 * Get ordinal color scale legends
248
 */
249
export function getOrdinalLegends(scale: D3ScaleFunction): ColorBreakOrdinal[] {
250
  const domain = scale.domain();
4✔
251
  const labels = scale.domain();
4✔
252
  const data = domain.map(scale);
4✔
253

254
  return data.map((datum, index) => ({
12✔
255
    data: isRgbColor(datum) ? rgbToHex(datum) : datum,
12!
256
    label: labels[index]
257
  }));
258
}
259

260
const defaultFormat = d => d;
12✔
261

262
const getTimeLabelFormat = domain => {
12✔
263
  const formatter = getTimeWidgetHintFormatter(domain);
×
264
  return val => moment.utc(val).format(formatter);
×
265
};
266

267
export function getQuantLabelFormat(domain, fieldType) {
268
  // quant scale can only be assigned to linear Fields: real, timestamp, integer
269
  return fieldType === ALL_FIELD_TYPES.timestamp
15!
270
    ? getTimeLabelFormat(domain)
271
    : !fieldType
15!
272
    ? defaultFormat
273
    : n => (isNumber(n) ? formatNumber(n, fieldType) : 'no value');
168✔
274
}
275

276
/**
277
 * Get legends for scale
278
 */
279
export function getLegendOfScale({
280
  scale,
281
  scaleType,
282
  labelFormat,
283
  fieldType
284
}: {
285
  scale?: D3ScaleFunction | null;
286
  scaleType: string;
287
  labelFormat?: LabelFormat;
288
  fieldType: string | null | undefined;
289
}): ColorBreak[] | ColorBreakOrdinal[] {
290
  if (!scale || scale.byZoom) {
19✔
291
    return [];
1✔
292
  }
293
  if (scaleType === SCALE_TYPES.ordinal) {
18✔
294
    return getOrdinalLegends(scale);
4✔
295
  }
296

297
  const formatLabel = labelFormat || getQuantLabelFormat(scale.domain(), fieldType);
14✔
298

299
  return getQuantLegends(scale, formatLabel);
14✔
300
}
301

302
/**
303
 * Get color scale function
304
 */
305
export function getLayerColorScale({
306
  range,
307
  domain,
308
  scaleType,
309
  layer
310
}: {
311
  range: ColorRange | null | undefined;
312
  domain: VisualChannelDomain;
313
  scaleType: string;
314
  layer: Layer;
315
  isFixed?: boolean;
316
}): D3ScaleFunction | null {
317
  if (range && domain && scaleType) {
19✔
318
    return layer.getColorScale(scaleType, domain, range);
18✔
319
  }
320
  return null;
1✔
321
}
322

323
/**
324
 * Convert colorRange.colorMap into color breaks UI input
325
 */
326
export function initializeLayerColorMap(layer: Layer, visualChannel: VisualChannel): ColorMap {
327
  const domain = layer.config[visualChannel.domain];
1✔
328
  const range = layer.config.visConfig[visualChannel.range];
1✔
329
  const scaleType = layer.config[visualChannel.scale];
1✔
330
  const field = layer.config[visualChannel.field];
1✔
331

332
  const scale = getLayerColorScale({
1✔
333
    range,
334
    domain,
335
    scaleType,
336
    layer
337
  });
338

339
  const colorBreaks = getLegendOfScale({
1✔
340
    scale: scale?.byZoom ? scale(0) : scale,
1!
341
    scaleType,
342
    fieldType: field.type
343
  });
344
  return colorBreaksToColorMap(colorBreaks);
1✔
345
}
346

347
/**
348
 * Get visual chanel scale function if it's based on zoom
349
 */
350
export function getVisualChannelScaleByZoom({
351
  scale,
352
  layer,
353
  mapState
354
}: {
355
  scale: D3ScaleFunction | null;
356
  layer: Layer;
357
  mapState?: MapState;
358
}): D3ScaleFunction | null {
359
  if (scale?.byZoom) {
5!
360
    const z = layer.meta?.getZoom ? layer.meta.getZoom(mapState) : mapState?.zoom;
×
361
    scale = Number.isFinite(z) ? scale(z) : null;
×
362
  }
363
  return scale;
5✔
364
}
365

366
/**
367
 * Convert color breaks UI input into colorRange.colorMap
368
 */
369
export function colorBreaksToColorMap(colorBreaks: ColorBreak[] | ColorBreakOrdinal[]): ColorMap {
370
  const colorMap = colorBreaks.map((colorBreak, i) => {
4✔
371
    // [value, hex]
372
    return [
14✔
373
      colorBreak.inputs
14✔
374
        ? i === colorBreaks.length - 1
11✔
375
          ? null // last
376
          : colorBreak.inputs[1]
377
        : colorBreak.label,
378
      colorBreak.data
379
    ];
380
  });
381

382
  // @ts-ignore tuple
383
  return colorMap;
4✔
384
}
385

386
/**
387
 * Convert colorRange.colorMap into color breaks UI input
388
 */
389
export function colorMapToColorBreaks(colorMap?: ColorMap): ColorBreak[] | null {
390
  if (!colorMap) {
4!
391
    return null;
×
392
  }
393
  const colorBreaks = colorMap.map(([value, color], i) => {
4✔
394
    const range =
395
      i === 0
16✔
396
        ? // first
397
          [-Infinity, value]
398
        : // last
399
        i === colorMap.length - 1
12✔
400
        ? [colorMap[i - 1][0], Infinity]
401
        : // else
402
          [colorMap[i - 1][0], value];
403
    return {
16✔
404
      data: color,
405
      range,
406
      inputs: range,
407
      label:
408
        // first
409
        i === 0
16✔
410
          ? `Less than ${value}`
411
          : // last
412
          i === colorMap.length - 1
12✔
413
          ? `${colorMap[i - 1][0]} or more`
414
          : `${colorMap[i - 1][0]} to ${value}`
415
    };
416
  });
417

418
  // @ts-ignore implement conversion for ordinal
419
  return colorBreaks;
4✔
420
}
421

422
/**
423
 * Whether color breaks is for numeric field
424
 */
425
export function isNumericColorBreaks(colorBreaks: unknown): colorBreaks is ColorBreak[] {
426
  return Array.isArray(colorBreaks) && colorBreaks.length && colorBreaks[0].inputs;
51✔
427
}
428

429
// return domainMin, domainMax, histogramMean
430
export function getHistogramDomain({
431
  aggregatedBins,
432
  columnStats,
433
  dataset,
434
  fieldValueAccessor
435
}: {
436
  aggregatedBins?: AggregatedBin[];
437
  columnStats?: FilterProps['columnStats'];
438
  dataset?: KeplerTable;
439
  fieldValueAccessor: (idx: unknown) => number;
440
}) {
441
  let domainMin = Number.POSITIVE_INFINITY;
11✔
442
  let domainMax = Number.NEGATIVE_INFINITY;
11✔
443
  let nValid = 0;
11✔
444
  let domainSum = 0;
11✔
445

446
  if (aggregatedBins) {
11✔
447
    Object.values(aggregatedBins).forEach(bin => {
1✔
448
      const val = bin.value;
4✔
449
      if (isNumber(val)) {
4!
450
        if (val < domainMin) domainMin = val;
4✔
451
        if (val > domainMax) domainMax = val;
4!
452
        domainSum += val;
4✔
453
        nValid += 1;
4✔
454
      }
455
    });
456
  } else {
457
    if (columnStats && columnStats.quantiles && columnStats.mean) {
10!
458
      // no need to recalcuate min/max/mean if its already in columnStats
NEW
459
      return [
×
460
        columnStats.quantiles[0].value,
461
        columnStats.quantiles[columnStats.quantiles.length - 1].value,
462
        columnStats.mean
463
      ];
464
    }
465
    if (dataset && fieldValueAccessor) {
10✔
466
      dataset.allIndexes.forEach((x, i) => {
9✔
467
        const val = fieldValueAccessor(x);
216✔
468
        if (isNumber(val)) {
216✔
469
          if (val < domainMin) domainMin = val;
112✔
470
          if (val > domainMax) domainMax = val;
112✔
471
          domainSum += val;
112✔
472
          nValid += 1;
112✔
473
        }
474
      });
475
    }
476
  }
477
  const histogramMean = nValid > 0 ? domainSum / nValid : 0;
11✔
478
  return [nValid > 0 ? domainMin : 0, nValid > 0 ? domainMax : 0, histogramMean];
11✔
479
}
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