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

keplergl / kepler.gl / 12564978170

31 Dec 2024 11:33PM UTC coverage: 67.342% (-0.03%) from 67.376%
12564978170

push

github

web-flow
[fix] Custom Color Scale fixes (#2875)

- [Chore] add exports for scenegraph to layers/index
- [fix] Apply custom column format on color legend
- [feat] support custom color scale for layers using colorScale.byZoom
- [fix] disable custom ordinal scale
- [chore] add FSQ Cool Tone color palette

Signed-off-by: Ihor Dykhta <dikhta.igor@gmail.com>

5894 of 10159 branches covered (58.02%)

Branch coverage included in aggregate %.

14 of 17 new or added lines in 7 files covered. (82.35%)

6 existing lines in 2 files now uncovered.

12087 of 16542 relevant lines covered (73.07%)

89.78 hits per line

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

84.35
/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 {
12
  AggregatedBin,
13
  ColorMap,
14
  ColorRange,
15
  HexColor,
16
  KeplerLayer as Layer,
17
  MapState,
18
  VisualChannel,
19
  VisualChannelDomain
20
} from '@kepler.gl/types';
21

22
import {isRgbColor, rgbToHex} from './color-utils';
23
import {DataContainerInterface} from './data-container-interface';
24
import {formatNumber, isNumber, reverseFormatNumber, unique} from './data-utils';
25
import {getTimeWidgetHintFormatter} from './filter-utils';
26
import {isPlainObject} from './utils';
27

28
export type ColorBreak = {
29
  data: HexColor;
30
  label: string;
31
  range: number[];
32
  inputs: number[];
33
};
34
export type ColorBreakOrdinal = {
35
  data: HexColor;
36
  label: string;
37
};
38

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

41
// TODO isolate types - depends on @kepler.gl/layers
42
type FilterProps = any;
43
type KeplerTable = any;
44

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

59
  return values.filter(notNullorUndefined).sort(sortFunc);
50✔
60
}
61

62
/**
63
 * return ordinal domain for a data container
64
 */
65
export function getOrdinalDomain(
66
  dataContainer: DataContainerInterface,
67
  valueAccessor: dataContainerValueAccessor
68
): string[] {
69
  const values = dataContainer.mapIndex(valueAccessor);
50✔
70

71
  return unique(values).filter(notNullorUndefined).sort();
50✔
72
}
73

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

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

96
export type DomainStops = {
97
  stops: number[];
98
  z: number[];
99
};
100

101
/**
102
 * whether field domain is stops
103
 */
104
export function isDomainStops(domain: unknown): domain is DomainStops {
105
  return isPlainObject(domain) && Array.isArray(domain.stops) && Array.isArray(domain.z);
161✔
106
}
107

108
export type DomainQuantiles = {
109
  quantiles: number[];
110
  z: number[];
111
};
112

113
/**
114
 * whether field domain is quantiles
115
 */
116
export function isDomainQuantile(domain: any): domain is DomainQuantiles {
117
  return isPlainObject(domain) && Array.isArray(domain.quantiles) && Array.isArray(domain.z);
161!
118
}
119

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

138
  return thresholds;
2✔
139
}
140

141
/**
142
 * get the domain at zoom
143
 */
144
export function getDomainStepsbyZoom(domain: any[], steps: number[], z: number): any {
145
  const i = bisectLeft(steps, z);
13✔
146

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

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

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

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

215
    return {
44✔
216
      label: `${labelFormat(invert[0])} to ${labelFormat(invert[1])}`,
217
      // raw value
218
      range: invert,
219
      // formatted value
220
      inputs
221
    };
222
  });
223
}
224

225
const customScaleLabelFormat = n => (n ? formatNumber(n, 'real') : 'no value');
44!
226
/**
227
 * Get linear / quant scale color breaks
228
 */
229
export function getQuantLegends(scale: D3ScaleFunction, labelFormat: LabelFormat): ColorBreak[] {
230
  if (typeof scale.invertExtent !== 'function') {
16!
231
    return [];
×
232
  }
233
  const thresholdLabelFormat = (n, type) =>
16✔
NEW
234
    n && labelFormat ? labelFormat(n) : n ? formatNumber(n, type) : 'no value';
×
235
  const labels =
236
    scale.scaleType === 'threshold'
16!
237
      ? getThresholdLabels(scale, thresholdLabelFormat)
238
      : scale.scaleType === 'custom'
16✔
239
      ? getThresholdLabels(scale, customScaleLabelFormat)
240
      : getScaleLabels(scale, labelFormat);
241

242
  const data = scale.range();
16✔
243

244
  return labels.map((label, index) => ({
59✔
245
    data: Array.isArray(data[index]) ? rgbToHex(data[index]) : data[index],
59✔
246
    ...label
247
  }));
248
}
249

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

258
  return data.map((datum, index) => ({
12✔
259
    data: isRgbColor(datum) ? rgbToHex(datum) : datum,
12!
260
    label: labels[index]
261
  }));
262
}
263

264
const defaultFormat = d => d;
14✔
265

266
const getTimeLabelFormat = domain => {
14✔
267
  const formatter = getTimeWidgetHintFormatter(domain);
×
268
  return val => moment.utc(val).format(formatter);
×
269
};
270

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

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

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

303
  return getQuantLegends(scale, formatLabel);
14✔
304
}
305

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

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

336
  const scale = getLayerColorScale({
1✔
337
    range,
338
    domain,
339
    scaleType,
340
    layer
341
  });
342

343
  const colorBreaks = getLegendOfScale({
1✔
344
    scale: scale?.byZoom ? scale(0) : scale,
1!
345
    scaleType,
346
    fieldType: field.type
347
  });
348
  return colorBreaksToColorMap(colorBreaks);
1✔
349
}
350

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

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

386
  // @ts-ignore tuple
387
  return colorMap;
4✔
388
}
389

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

422
  // @ts-ignore implement conversion for ordinal
423
  return colorBreaks;
4✔
424
}
425

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

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

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

© 2025 Coveralls, Inc