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

keplergl / kepler.gl / 19768106976

28 Nov 2025 03:32PM UTC coverage: 61.675% (-0.09%) from 61.76%
19768106976

push

github

web-flow
chore: patch release 3.2.3 (#3250)

* draft

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

* patch

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

* fix eslint during release

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

---------

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

6352 of 12229 branches covered (51.94%)

Branch coverage included in aggregate %.

13043 of 19218 relevant lines covered (67.87%)

81.74 hits per line

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

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

4
import assert from 'assert';
5
import {format as d3Format} from 'd3-format';
6
import moment from 'moment-timezone';
7

8
import {parseGeometryFromArrow} from '@loaders.gl/arrow';
9

10
import {
11
  ALL_FIELD_TYPES,
12
  TOOLTIP_FORMATS,
13
  TOOLTIP_FORMAT_TYPES,
14
  TOOLTIP_KEY,
15
  TooltipFormat
16
} from '@kepler.gl/constants';
17
import {notNullorUndefined} from '@kepler.gl/common-utils';
18
import {Field, Millisecond, ProtoDatasetField} from '@kepler.gl/types';
19

20
import {snapToMarks} from './plot';
21
import {isPlainObject} from './utils';
22
import {isArrowVector} from './arrow-data-container';
23

24
export type FieldFormatter = (value: any, field?: ProtoDatasetField) => string;
25

26
// We need threat latitude differently otherwise mercator project view throws
27
// a projection matrix error
28
// Uncaught Error: Pixel project matrix not invertible
29
// at WebMercatorViewport16.Viewport6 (viewport.js:81:13)
30
export const MAX_LATITUDE = 89.9;
15✔
31
export const MIN_LATITUDE = -89.9;
15✔
32
export const MAX_LONGITUDE = 180;
15✔
33
export const MIN_LONGITUDE = -180;
15✔
34

35
/**
36
 * Validates a latitude value.
37
 * Ensures that the latitude is within the defined minimum and maximum latitude bounds.
38
 * If the value is out of bounds, it returns the nearest bound value.
39
 * @param latitude - The latitude value to validate.
40
 * @returns The validated latitude value.
41
 */
42
export function validateLatitude(latitude: number | undefined): number {
43
  return validateCoordinate(latitude ?? 0, MIN_LATITUDE, MAX_LATITUDE);
260!
44
}
45

46
/**
47
 * Validates a longitude value.
48
 * Ensures that the longitude is within the defined minimum and maximum longitude bounds.
49
 * If the value is out of bounds, it returns the nearest bound value.
50
 * @param longitude - The longitude value to validate.
51
 * @returns The validated longitude value.
52
 */
53
export function validateLongitude(longitude: number | undefined): number {
54
  return validateCoordinate(longitude ?? 0, MIN_LONGITUDE, MAX_LONGITUDE);
260!
55
}
56

57
/**
58
 * Validates a coordinate value.
59
 * Ensures that the value is within the specified minimum and maximum bounds.
60
 * If the value is out of bounds, it returns the nearest bound value.
61
 * @param value - The coordinate value to validate.
62
 * @param minValue - The minimum bound for the value.
63
 * @param maxValue - The maximum bound for the value.
64
 * @returns The validated coordinate value.
65
 */
66
export function validateCoordinate(value: number, minValue: number, maxValue: number): number {
67
  if (value <= minValue) {
520✔
68
    return minValue;
7✔
69
  }
70
  if (value >= maxValue) {
513!
71
    return maxValue;
×
72
  }
73

74
  return value;
513✔
75
}
76

77
/**
78
 * simple getting unique values of an array
79
 * Note: filters out null and undefined values
80
 *
81
 * @param values
82
 * @returns unique values
83
 */
84
export function unique<T>(values: T[]) {
85
  const results: T[] = [];
102✔
86
  const uniqueSet = new Set(values);
102✔
87
  uniqueSet.forEach(v => {
102✔
88
    if (notNullorUndefined(v)) {
1,480✔
89
      results.push(v);
1,448✔
90
    }
91
  });
92
  return results;
102✔
93
}
94

95
export function getLatLngBounds(
96
  points: number[][],
97
  idx: number,
98
  limit: [number, number]
99
): [number, number] | null {
100
  const lats = points
666✔
101
    .map(d => Number(Array.isArray(d)) && d[idx])
6,756✔
102
    .filter(Number.isFinite)
103
    .sort(numberSort);
104

105
  if (!lats.length) {
666!
106
    return null;
×
107
  }
108

109
  // clamp to limit
110
  return [Math.max(lats[0], limit[0]), Math.min(lats[lats.length - 1], limit[1])];
666✔
111
}
112

113
export function clamp([min, max]: [number, number], val = 0): number {
×
114
  return val <= min ? min : val >= max ? max : val;
24✔
115
}
116

117
export function getSampleData(data, sampleSize = 500, getValue = d => d) {
×
118
  const sampleStep = Math.max(Math.floor(data.length / sampleSize), 1);
×
119
  const output: any[] = [];
×
120
  for (let i = 0; i < data.length; i += sampleStep) {
×
121
    output.push(getValue(data[i]));
×
122
  }
123

124
  return output;
×
125
}
126

127
/**
128
 * Convert different time format to unix milliseconds
129
 */
130
export function timeToUnixMilli(value: string | number | Date, format: string): Millisecond | null {
131
  if (notNullorUndefined(value)) {
2,342✔
132
    if (typeof value === 'string') {
2,289✔
133
      return moment.utc(value, format).valueOf();
648✔
134
    }
135
    if (typeof value === 'number') {
1,641!
136
      return format === 'x' ? value * 1000 : value;
1,641✔
137
    }
138
    if (value instanceof Date) {
×
139
      return value.valueOf();
×
140
    }
141
  }
142
  return null;
53✔
143
}
144

145
/**
146
 * Whether d is a number, this filtered out NaN as well
147
 */
148
export function isNumber(d: unknown): d is number {
149
  return Number.isFinite(d);
1,054✔
150
}
151

152
/**
153
 * whether object has property
154
 * @param {string} prop
155
 * @returns {boolean} - yes or no
156
 */
157
export function hasOwnProperty<X extends object, Y extends PropertyKey>(
158
  obj: X,
159
  prop: Y
160
): obj is X & Record<Y, unknown> {
161
  return Object.prototype.hasOwnProperty.call(obj, prop);
9✔
162
}
163

164
export function numberSort(a: number, b: number): number {
165
  return a - b;
17,076✔
166
}
167

168
export function getSortingFunction(fieldType: string): typeof numberSort | undefined {
169
  switch (fieldType) {
131✔
170
    case ALL_FIELD_TYPES.real:
171
    case ALL_FIELD_TYPES.integer:
172
    case ALL_FIELD_TYPES.timestamp:
173
      return numberSort;
96✔
174
    default:
175
      return undefined;
35✔
176
  }
177
}
178

179
/**
180
 * round number with exact number of decimals
181
 * return as a string
182
 */
183
export function preciseRound(num: number, decimals: number): string {
184
  const t = Math.pow(10, decimals);
82✔
185
  return (
82✔
186
    Math.round(
187
      num * t + (decimals > 0 ? 1 : 0) * (Math.sign(num) * (10 / Math.pow(100, decimals)))
82✔
188
    ) / t
189
  ).toFixed(decimals);
190
}
191

192
/**
193
 * round a giving number at most 4 decimal places
194
 * e.g. 10 -> 10, 1.12345 -> 1.2345, 2.0 -> 2
195
 */
196
export function roundToFour(num: number): number {
197
  // @ts-expect-error
198
  return Number(`${Math.round(`${num}e+4`)}e-4`);
5✔
199
}
200
/**
201
 * get number of decimals to round to for slider from step
202
 * @param step
203
 * @returns- number of decimal
204
 */
205
export function getRoundingDecimalFromStep(step: number): number {
206
  if (isNaN(step)) {
30!
207
    assert('step is not a number');
×
208
    assert(step);
×
209
  }
210

211
  const stepStr = step.toString();
30✔
212

213
  // in case the step is a very small number e.g. 1e-7, return decimal e.g. 7 directly
214
  const splitExponential = stepStr.split('e-');
30✔
215
  if (splitExponential.length === 2) {
30✔
216
    const coeffZero = splitExponential[0].split('.');
2✔
217
    const coeffDecimal = coeffZero.length === 1 ? 0 : coeffZero[1].length;
2✔
218
    return parseInt(splitExponential[1], 10) + coeffDecimal;
2✔
219
  }
220

221
  const splitZero = stepStr.split('.');
28✔
222
  if (splitZero.length === 1) {
28✔
223
    return 0;
9✔
224
  }
225
  return splitZero[1].length;
19✔
226
}
227

228
/**
229
 * If marks is provided, snap to marks, if not normalize to step
230
 * @param val
231
 * @param minValue
232
 * @param step
233
 * @param marks
234
 */
235
export function normalizeSliderValue(
236
  val: number,
237
  minValue: number | undefined,
238
  step: number,
239
  marks?: number[] | null
240
): number {
241
  if (marks && marks.length) {
8✔
242
    // Use in slider, given a number and an array of numbers, return the nears number from the array
243
    return snapToMarks(val, marks);
1✔
244
  }
245

246
  return roundValToStep(minValue, step, val);
7✔
247
}
248

249
/**
250
 * round the value to step for the slider
251
 * @param minValue
252
 * @param step
253
 * @param val
254
 * @returns - rounded number
255
 */
256
export function roundValToStep(minValue: number | undefined, step: number, val: number): number {
257
  if (!isNumber(step) || !isNumber(minValue)) {
22✔
258
    return val;
2✔
259
  }
260

261
  const decimal = getRoundingDecimalFromStep(step);
20✔
262
  const steps = Math.floor((val - minValue) / step);
20✔
263
  let remain = val - (steps * step + minValue);
20✔
264

265
  // has to round because javascript turns 0.1 into 0.9999999999999987
266
  remain = Number(preciseRound(remain, 8));
20✔
267

268
  let closest: number;
269
  if (remain === 0) {
20✔
270
    closest = val;
5✔
271
  } else if (remain < step / 2) {
15✔
272
    closest = steps * step + minValue;
4✔
273
  } else {
274
    closest = (steps + 1) * step + minValue;
11✔
275
  }
276

277
  // precise round return a string rounded to the defined decimal
278
  const rounded = preciseRound(closest, decimal);
20✔
279

280
  return Number(rounded);
20✔
281
}
282

283
/**
284
 * Get the value format based on field and format options
285
 * Used in render tooltip value
286
 */
287
export const defaultFormatter: FieldFormatter = v => (notNullorUndefined(v) ? String(v) : '');
1,707✔
288

289
export const floatFormatter = v => (isNumber(v) ? String(roundToFour(v)) : '');
15!
290

291
/**
292
 * Transforms a WKB in Uint8Array form into a hex WKB string.
293
 * @param uint8Array WKB in Uint8Array form.
294
 * @returns hex WKB string.
295
 */
296
export function uint8ArrayToHex(data: Uint8Array): string {
297
  return Array.from(data)
×
298
    .map(byte => (byte as any).toString(16).padStart(2, '0'))
×
299
    .join('');
300
}
301

302
export const FIELD_DISPLAY_FORMAT: {
303
  [key: string]: FieldFormatter;
304
} = {
15✔
305
  [ALL_FIELD_TYPES.string]: defaultFormatter,
306
  [ALL_FIELD_TYPES.timestamp]: defaultFormatter,
307
  [ALL_FIELD_TYPES.integer]: defaultFormatter,
308
  [ALL_FIELD_TYPES.real]: defaultFormatter,
309
  [ALL_FIELD_TYPES.boolean]: defaultFormatter,
310
  [ALL_FIELD_TYPES.date]: defaultFormatter,
311
  [ALL_FIELD_TYPES.geojson]: d =>
312
    typeof d === 'string'
23✔
313
      ? d
314
      : isPlainObject(d)
20!
315
      ? JSON.stringify(d)
316
      : Array.isArray(d)
×
317
      ? `[${String(d)}]`
318
      : '',
319
  [ALL_FIELD_TYPES.geoarrow]: (data, field) => {
320
    if (isArrowVector(data)) {
×
321
      try {
×
322
        const encoding = field?.metadata?.get('ARROW:extension:name');
×
323
        if (encoding) {
×
324
          const geometry = parseGeometryFromArrow(data, encoding);
×
325
          return JSON.stringify(geometry);
×
326
        }
327
      } catch (error) {
328
        // ignore for now
329
      }
330
    } else if (data instanceof Uint8Array) {
×
331
      return uint8ArrayToHex(data);
×
332
    }
333
    return data;
×
334
  },
335
  [ALL_FIELD_TYPES.object]: (value: any) => {
336
    try {
5✔
337
      return JSON.stringify(value);
5✔
338
    } catch (e) {
339
      return String(value);
×
340
    }
341
  },
342
  [ALL_FIELD_TYPES.array]: d => JSON.stringify(d),
24✔
343
  [ALL_FIELD_TYPES.h3]: defaultFormatter
344
};
345

346
/**
347
 * Parse field value and type and return a string representation
348
 */
349
export const parseFieldValue = (value: any, type: string, field?: Field): string => {
15✔
350
  if (!notNullorUndefined(value)) {
665✔
351
    return '';
113✔
352
  }
353
  // BigInt values cannot be serialized with JSON.stringify() directly
354
  // We need to explicitly convert them to strings using .toString()
355
  // See: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/BigInt#use_within_json
356
  if (typeof value === 'bigint') {
552!
357
    return value.toString();
×
358
  }
359
  return FIELD_DISPLAY_FORMAT[type] ? FIELD_DISPLAY_FORMAT[type](value, field) : String(value);
552!
360
};
361

362
/**
363
 * Get the value format based on field and format options
364
 * Used in render tooltip value
365
 * @param format
366
 * @param field
367
 */
368
export function getFormatter(
369
  format?: string | Record<string, string> | null,
370
  field?: Field
371
): FieldFormatter {
372
  if (!format) {
473✔
373
    return defaultFormatter;
1✔
374
  }
375
  const tooltipFormat = Object.values(TOOLTIP_FORMATS).find(f => f[TOOLTIP_KEY] === format);
11,470✔
376

377
  if (tooltipFormat) {
472✔
378
    return applyDefaultFormat(tooltipFormat as TooltipFormat);
465✔
379
  } else if (typeof format === 'string' && field) {
7✔
380
    return applyCustomFormat(format, field);
6✔
381
  }
382

383
  return defaultFormatter;
1✔
384
}
385

386
export function getColumnFormatter(
387
  field: Pick<Field, 'type'> & Partial<Pick<Field, 'format' | 'displayFormat'>>
388
): FieldFormatter {
389
  const {format, displayFormat} = field;
1,566✔
390

391
  if (!format && !displayFormat) {
1,566✔
392
    return FIELD_DISPLAY_FORMAT[field.type];
621✔
393
  }
394
  const tooltipFormat = Object.values(TOOLTIP_FORMATS).find(f => f[TOOLTIP_KEY] === displayFormat);
27,540✔
395

396
  if (tooltipFormat) {
945✔
397
    return applyDefaultFormat(tooltipFormat);
81✔
398
  } else if (typeof displayFormat === 'string' && field) {
864!
399
    return applyCustomFormat(displayFormat, field);
×
400
  } else if (typeof displayFormat === 'object') {
864!
401
    return applyValueMap(displayFormat);
×
402
  }
403

404
  return defaultFormatter;
864✔
405
}
406

407
export function applyValueMap(format) {
408
  return v => format[v];
×
409
}
410

411
export function applyDefaultFormat(tooltipFormat: TooltipFormat): (v: any) => string {
412
  if (!tooltipFormat || !tooltipFormat.format) {
546!
413
    return defaultFormatter;
×
414
  }
415

416
  switch (tooltipFormat.type) {
546!
417
    case TOOLTIP_FORMAT_TYPES.DECIMAL:
418
      return d3Format(tooltipFormat.format);
83✔
419
    case TOOLTIP_FORMAT_TYPES.DATE:
420
    case TOOLTIP_FORMAT_TYPES.DATE_TIME:
421
      return datetimeFormatter(null)(tooltipFormat.format);
458✔
422
    case TOOLTIP_FORMAT_TYPES.PERCENTAGE:
423
      return v => `${d3Format(TOOLTIP_FORMATS.DECIMAL_DECIMAL_FIXED_2.format)(v)}%`;
1✔
424
    case TOOLTIP_FORMAT_TYPES.BOOLEAN:
425
      return getBooleanFormatter(tooltipFormat.format);
4✔
426
    default:
427
      return defaultFormatter;
×
428
  }
429
}
430

431
export function getBooleanFormatter(format: string): FieldFormatter {
432
  switch (format) {
4!
433
    case '01':
434
      return (v: boolean) => (v ? '1' : '0');
2✔
435
    case 'yn':
436
      return (v: boolean) => (v ? 'yes' : 'no');
2✔
437
    default:
438
      return defaultFormatter;
×
439
  }
440
}
441
// Allow user to specify custom tooltip format via config
442
export function applyCustomFormat(format, field: {type?: string}): FieldFormatter {
443
  switch (field.type) {
6!
444
    case ALL_FIELD_TYPES.real:
445
    case ALL_FIELD_TYPES.integer:
446
      return d3Format(format);
5✔
447
    case ALL_FIELD_TYPES.date:
448
    case ALL_FIELD_TYPES.timestamp:
449
      return datetimeFormatter(null)(format);
1✔
450
    default:
451
      return v => v;
×
452
  }
453
}
454

455
function formatLargeNumber(n) {
456
  // SI-prefix with 4 significant digits
457
  return d3Format('.4~s')(n);
24✔
458
}
459

460
export function formatNumber(n: number, type?: string): string {
461
  switch (type) {
217✔
462
    case ALL_FIELD_TYPES.integer:
463
      if (n < 0) {
137✔
464
        return `-${formatNumber(-n, 'integer')}`;
1✔
465
      }
466
      if (n < 1000) {
136✔
467
        return `${Math.round(n)}`;
36✔
468
      }
469
      if (n < 10 * 1000) {
100✔
470
        return d3Format(',')(Math.round(n));
77✔
471
      }
472
      return formatLargeNumber(n);
23✔
473
    case ALL_FIELD_TYPES.real:
474
      if (n < 0) {
77!
475
        return `-${formatNumber(-n, 'number')}`;
×
476
      }
477
      if (n < 1000) {
77✔
478
        return d3Format('.4~r')(n);
52✔
479
      }
480
      if (n < 10 * 1000) {
25✔
481
        return d3Format(',.2~f')(n);
24✔
482
      }
483
      return formatLargeNumber(n);
1✔
484

485
    default:
486
      return formatNumber(n, 'real');
3✔
487
  }
488
}
489

490
const transformation = {
15✔
491
  Y: Math.pow(10, 24),
492
  Z: Math.pow(10, 21),
493
  E: Math.pow(10, 18),
494
  P: Math.pow(10, 15),
495
  T: Math.pow(10, 12),
496
  G: Math.pow(10, 9),
497
  M: Math.pow(10, 6),
498
  k: Math.pow(10, 3),
499
  h: Math.pow(10, 2),
500
  da: Math.pow(10, 1),
501
  d: Math.pow(10, -1),
502
  c: Math.pow(10, -2),
503
  m: Math.pow(10, -3),
504
  μ: Math.pow(10, -6),
505
  n: Math.pow(10, -9),
506
  p: Math.pow(10, -12),
507
  f: Math.pow(10, -15),
508
  a: Math.pow(10, -18),
509
  z: Math.pow(10, -21),
510
  y: Math.pow(10, -24)
511
};
512

513
/**
514
 * Convert a formatted number from string back to number
515
 */
516
export function reverseFormatNumber(str: string): number {
517
  let returnValue: number | null = null;
110✔
518
  const strNum = str.trim().replace(/,/g, '');
110✔
519
  Object.entries(transformation).forEach(d => {
110✔
520
    if (strNum.includes(d[0])) {
2,200✔
521
      returnValue = parseFloat(strNum) * d[1];
21✔
522
      return true;
21✔
523
    }
524
    return false;
2,179✔
525
  });
526

527
  // if no transformer found, convert to nuber regardless
528
  return returnValue === null ? Number(strNum) : returnValue;
110✔
529
}
530

531
/**
532
 * Format epoch milliseconds with a format string
533
 * @type timezone
534
 */
535
export function datetimeFormatter(
536
  timezone?: string | null
537
): (format?: string) => (ts: number) => string {
538
  return timezone
480✔
539
    ? format => ts => moment.utc(ts).tz(timezone).format(format)
18✔
540
    : // return empty string instead of 'Invalid date' if ts is undefined/null
541
      format => ts => ts ? moment.utc(ts).format(format) : '';
493✔
542
}
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