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

keplergl / kepler.gl / 12031095165

26 Nov 2024 12:57PM UTC coverage: 69.321% (+22.9%) from 46.466%
12031095165

push

github

web-flow
[feat] create new dataset action (#2778)

* [feat] create new dataset action

- createNewDataEntry now returns a react-palm task to create or update a dataset asynchronously.
- updateVisDataUpdater now returns tasks to create or update a dataset asynchronously, and once done triggers createNewDatasetSuccess action.
- refactoring of demo-app App and Container to functional components

Signed-off-by: Ihor Dykhta <dikhta.igor@gmail.com>
Co-authored-by: Shan He <heshan0131@gmail.com>

5436 of 9079 branches covered (59.87%)

Branch coverage included in aggregate %.

91 of 111 new or added lines in 13 files covered. (81.98%)

8 existing lines in 3 files now uncovered.

11368 of 15162 relevant lines covered (74.98%)

95.15 hits per line

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

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

4
import keyMirror from 'keymirror';
5
import get from 'lodash.get';
6
import isEqual from 'lodash.isequal';
7
import {ascending, extent} from 'd3-array';
8

9
import booleanWithin from '@turf/boolean-within';
10
import {point as turfPoint} from '@turf/helpers';
11
import {Decimal} from 'decimal.js';
12
import {
13
  ALL_FIELD_TYPES,
14
  FILTER_TYPES,
15
  ANIMATION_WINDOW,
16
  PLOT_TYPES,
17
  LAYER_TYPES,
18
  FILTER_VIEW_TYPES
19
} from '@kepler.gl/constants';
20
import {VisState} from '@kepler.gl/schemas';
21
import * as ScaleUtils from './data-scale-utils';
22
import {h3IsValid} from 'h3-js';
23

24
import {
25
  Entries,
26
  Field,
27
  ParsedFilter,
28
  Filter,
29
  FilterBase,
30
  PolygonFilter,
31
  FieldDomain,
32
  TimeRangeFieldDomain,
33
  Feature,
34
  FeatureValue,
35
  LineChart,
36
  RangeFilter,
37
  TimeRangeFilter,
38
  RangeFieldDomain,
39
  FilterDatasetOpt,
40
  FilterRecord,
41
  AnimationConfig
42
} from '@kepler.gl/types';
43

44
import {generateHashId, toArray, notNullorUndefined} from '@kepler.gl/common-utils';
45
import {DataContainerInterface} from './data-container-interface';
46
import {set} from './utils';
47
import {timeToUnixMilli, unique} from './data-utils';
48
import {getCentroid} from './h3-utils';
49
import {updateTimeFilterPlotType, updateRangeFilterPlotType} from './plot';
50
import {KeplerTableModel} from './types';
51

52
export const durationSecond = 1000;
11✔
53
export const durationMinute = durationSecond * 60;
11✔
54
export const durationHour = durationMinute * 60;
11✔
55
export const durationDay = durationHour * 24;
11✔
56
export const durationWeek = durationDay * 7;
11✔
57
export const durationYear = durationDay * 365;
11✔
58

59
export type FilterResult = {
60
  filteredIndexForDomain?: number[];
61
  filteredIndex?: number[];
62
};
63

64
export type FilterChanged = {
65
  // eslint-disable-next-line no-unused-vars
66
  [key in keyof FilterRecord]: {
67
    [key: string]: 'added' | 'deleted' | 'name_changed' | 'value_changed' | 'dataId_changed';
68
  } | null;
69
};
70

71
export type dataValueAccessor = (data: {index: number}) => number | null;
72

73
export const TimestampStepMap = [
11✔
74
  {max: 1, step: 0.05},
75
  {max: 10, step: 0.1},
76
  {max: 100, step: 1},
77
  {max: 500, step: 5},
78
  {max: 1000, step: 10},
79
  {max: 5000, step: 50},
80
  {max: Number.POSITIVE_INFINITY, step: 1000}
81
];
82

83
export const FILTER_UPDATER_PROPS = keyMirror({
11✔
84
  dataId: null,
85
  name: null,
86
  layerId: null
87
});
88

89
export const FILTER_COMPONENTS = {
11✔
90
  [FILTER_TYPES.select]: 'SingleSelectFilter',
91
  [FILTER_TYPES.multiSelect]: 'MultiSelectFilter',
92
  [FILTER_TYPES.timeRange]: 'TimeRangeFilter',
93
  [FILTER_TYPES.range]: 'RangeFilter',
94
  [FILTER_TYPES.polygon]: 'PolygonFilter'
95
};
96

97
export const DEFAULT_FILTER_STRUCTURE = {
11✔
98
  dataId: [], // [string]
99
  id: null,
100
  enabled: true,
101

102
  // time range filter specific
103
  fixedDomain: false,
104
  view: FILTER_VIEW_TYPES.side,
105
  isAnimating: false,
106
  animationWindow: ANIMATION_WINDOW.free,
107
  speed: 1,
108

109
  // field specific
110
  name: [], // string
111
  type: null,
112
  fieldIdx: [], // [integer]
113
  domain: null,
114
  value: null,
115

116
  // plot
117
  plotType: {
118
    type: PLOT_TYPES.histogram
119
  },
120
  yAxis: null,
121

122
  // mode
123
  gpu: false
124
};
125

126
export const FILTER_ID_LENGTH = 4;
11✔
127

128
export const LAYER_FILTERS = [FILTER_TYPES.polygon];
11✔
129

130
/**
131
 * Generates a filter with a dataset id as dataId
132
 */
133
export function getDefaultFilter({
3✔
134
  dataId,
135
  id
136
}: {
137
  dataId?: string | null | string[];
138
  id?: string;
139
} = {}): FilterBase<LineChart> {
140
  return {
98✔
141
    ...DEFAULT_FILTER_STRUCTURE,
142
    // store it as dataId and it could be one or many
143
    dataId: dataId ? toArray(dataId) : [],
98✔
144
    id: id || generateHashId(FILTER_ID_LENGTH)
191✔
145
  };
146
}
147

148
/**
149
 * Check if a filter is valid based on the given dataId
150
 * @param  filter to validate
151
 * @param  datasetId id to validate filter against
152
 * @return true if a filter is valid, false otherwise
153
 */
154
export function shouldApplyFilter(filter: Filter, datasetId: string): boolean {
155
  const dataIds = toArray(filter.dataId);
234✔
156
  return dataIds.includes(datasetId) && filter.value !== null;
234✔
157
}
158

159
/**
160
 * Validates and modifies polygon filter structure
161
 * @param dataset
162
 * @param filter
163
 * @param layers
164
 * @return - {filter, dataset}
165
 */
166
export function validatePolygonFilter<K extends KeplerTableModel<K, L>, L extends {id: string}>(
167
  dataset: K,
168
  filter: PolygonFilter,
169
  layers: L[]
170
): {filter: PolygonFilter | null; dataset: K} {
171
  const failed = {dataset, filter: null};
5✔
172
  const {value, layerId, type, dataId} = filter;
5✔
173

174
  if (!layerId || !isValidFilterValue(type, value)) {
5✔
175
    return failed;
2✔
176
  }
177

178
  const isValidDataset = dataId.includes(dataset.id);
3✔
179

180
  if (!isValidDataset) {
3✔
181
    return failed;
1✔
182
  }
183

184
  const layer = layers.find(l => layerId.includes(l.id));
2✔
185

186
  if (!layer) {
2✔
187
    return failed;
1✔
188
  }
189

190
  return {
1✔
191
    filter: {
192
      ...filter,
193
      fieldIdx: []
194
    },
195
    dataset
196
  };
197
}
198

199
/**
200
 * Custom filter validators
201
 */
202
const filterValidators = {
11✔
203
  [FILTER_TYPES.polygon]: validatePolygonFilter
204
};
205

206
/**
207
 * Default validate filter function
208
 * @param dataset
209
 * @param filter
210
 * @return - {filter, dataset}
211
 */
212
export function validateFilter<K extends KeplerTableModel<K, L>, L>(
213
  dataset: K,
214
  filter: ParsedFilter
215
): {filter: Filter | null; dataset: K} {
216
  // match filter.dataId
217
  const failed = {dataset, filter: null};
43✔
218
  const filterDataId = toArray(filter.dataId);
43✔
219

220
  const filterDatasetIndex = filterDataId.indexOf(dataset.id);
43✔
221
  if (filterDatasetIndex < 0 || !toArray(filter.name)[filterDatasetIndex]) {
43!
222
    // the current filter is not mapped against the current dataset
UNCOV
223
    return failed;
×
224
  }
225

226
  const initializeFilter: Filter = {
43✔
227
    ...getDefaultFilter({dataId: filter.dataId}),
228
    ...filter,
229
    dataId: filterDataId,
230
    name: toArray(filter.name)
231
  };
232

233
  const fieldName = initializeFilter.name[filterDatasetIndex];
43✔
234
  const {filter: updatedFilter, dataset: updatedDataset} = applyFilterFieldName(
43✔
235
    initializeFilter,
236
    dataset,
237
    fieldName,
238
    filterDatasetIndex,
239
    {mergeDomain: true}
240
  );
241

242
  if (!updatedFilter) {
43!
243
    return failed;
×
244
  }
245

246
  // don't adjust value yet before all datasets are loaded
247
  updatedFilter.view = filter.view ?? updatedFilter.view;
43!
248

249
  if (updatedFilter.value === null) {
43!
250
    // cannot adjust saved value to filter
251
    return failed;
×
252
  }
253

254
  return {
43✔
255
    filter: validateFilterYAxis(updatedFilter, updatedDataset),
256
    dataset: updatedDataset
257
  };
258
}
259

260
/**
261
 * Validate saved filter config with new data
262
 *
263
 * @param dataset
264
 * @param filter - filter to be validate
265
 * @param layers - layers
266
 * @return validated filter
267
 */
268
export function validateFilterWithData<K extends KeplerTableModel<K, L>, L>(
269
  dataset: K,
270
  filter: ParsedFilter,
271
  layers: L[]
272
): {filter: Filter; dataset: K} {
273
  return filter.type && Object.prototype.hasOwnProperty.call(filterValidators, filter.type)
43!
274
    ? filterValidators[filter.type](dataset, filter, layers)
275
    : validateFilter(dataset, filter);
276
}
277

278
/**
279
 * Validate YAxis
280
 * @param filter
281
 * @param dataset
282
 * @return {*}
283
 */
284
function validateFilterYAxis(filter, dataset) {
285
  // TODO: validate yAxis against other datasets
286

287
  const {fields} = dataset;
43✔
288
  const {yAxis} = filter;
43✔
289
  // TODO: validate yAxis against other datasets
290
  if (yAxis) {
43!
291
    const matchedAxis = fields.find(({name, type}) => name === yAxis.name && type === yAxis.type);
×
292

293
    filter = matchedAxis
×
294
      ? {
295
          ...filter,
296
          yAxis: matchedAxis
297
        }
298
      : filter;
299
  }
300

301
  return filter;
43✔
302
}
303

304
/**
305
 * Get default filter prop based on field type
306
 *
307
 * @param field
308
 * @param fieldDomain
309
 * @returns default filter
310
 */
311
export function getFilterProps(
312
  field: Field,
313
  fieldDomain: FieldDomain
314
): Partial<Filter> & {fieldType: string} {
315
  const filterProps = {
85✔
316
    ...fieldDomain,
317
    fieldType: field.type,
318
    view: FILTER_VIEW_TYPES.side
319
  };
320

321
  switch (field.type) {
85!
322
    case ALL_FIELD_TYPES.real:
323
    case ALL_FIELD_TYPES.integer:
324
      return {
24✔
325
        ...filterProps,
326
        value: fieldDomain.domain,
327
        type: FILTER_TYPES.range,
328
        // @ts-expect-error
329
        typeOptions: [FILTER_TYPES.range],
330
        gpu: true
331
      };
332

333
    case ALL_FIELD_TYPES.boolean:
334
      // @ts-expect-error
335
      return {
2✔
336
        ...filterProps,
337
        type: FILTER_TYPES.select,
338
        value: true,
339
        gpu: false
340
      };
341

342
    case ALL_FIELD_TYPES.string:
343
    case ALL_FIELD_TYPES.date:
344
      // @ts-expect-error
345
      return {
18✔
346
        ...filterProps,
347
        type: FILTER_TYPES.multiSelect,
348
        value: [],
349
        gpu: false
350
      };
351

352
    case ALL_FIELD_TYPES.timestamp:
353
      // @ts-expect-error
354
      return {
41✔
355
        ...filterProps,
356
        type: FILTER_TYPES.timeRange,
357
        view: FILTER_VIEW_TYPES.enlarged,
358
        fixedDomain: true,
359
        value: filterProps.domain,
360
        gpu: true,
361
        plotType: {}
362
      };
363

364
    default:
365
      // @ts-expect-error
366
      return {};
×
367
  }
368
}
369

370
export const getPolygonFilterFunctor = (layer, filter, dataContainer) => {
11✔
371
  const getPosition = layer.getPositionAccessor(dataContainer);
8✔
372

373
  switch (layer.type) {
8!
374
    case LAYER_TYPES.point:
375
    case LAYER_TYPES.icon:
376
      return data => {
7✔
377
        const pos = getPosition(data);
26✔
378
        return pos.every(Number.isFinite) && isInPolygon(pos, filter.value);
26✔
379
      };
380
    case LAYER_TYPES.arc:
381
    case LAYER_TYPES.line:
382
      return data => {
×
383
        const pos = getPosition(data);
×
384
        return (
×
385
          pos.every(Number.isFinite) &&
×
386
          [
387
            [pos[0], pos[1]],
388
            [pos[3], pos[4]]
389
          ].every(point => isInPolygon(point, filter.value))
×
390
        );
391
      };
392
    case LAYER_TYPES.hexagonId:
393
      if (layer.dataToFeature && layer.dataToFeature.centroids) {
1!
394
        return data => {
1✔
395
          // null or getCentroid({id})
396
          const centroid = layer.dataToFeature.centroids[data.index];
9✔
397
          return centroid && isInPolygon(centroid, filter.value);
9✔
398
        };
399
      }
400
      return data => {
×
401
        const id = getPosition(data);
×
402
        if (!h3IsValid(id)) {
×
403
          return false;
×
404
        }
405
        const pos = getCentroid({id});
×
406
        return pos.every(Number.isFinite) && isInPolygon(pos, filter.value);
×
407
      };
408
    case LAYER_TYPES.geojson:
409
      return data => {
×
410
        return layer.isInPolygon(data, data.index, filter.value);
×
411
      };
412
    default:
413
      return () => true;
×
414
  }
415
};
416

417
/**
418
 * Check if a GeoJSON feature filter can be applied to a layer
419
 */
420
export function canApplyFeatureFilter(feature: Feature | null): boolean {
421
  return Boolean(feature?.geometry && ['Polygon', 'MultiPolygon'].includes(feature.geometry.type));
2✔
422
}
423

424
/**
425
 * @param param An object that represents a row record.
426
 * @param param.index Index of the row in data container.
427
 * @returns Returns true to keep the element, or false otherwise.
428
 */
429
type filterFunction = (data: {index: number}) => boolean;
430

431
/**
432
 * @param field dataset Field
433
 * @param dataId Dataset id
434
 * @param filter Filter object
435
 * @param layers list of layers to filter upon
436
 * @param dataContainer Data container
437
 * @return filterFunction
438
 */
439
/* eslint-disable complexity */
440
export function getFilterFunction<L extends {config: {dataId: string | null}; id: string}>(
441
  field: Field | null,
442
  dataId: string,
443
  filter: Filter,
444
  layers: L[],
445
  dataContainer: DataContainerInterface
446
): filterFunction {
447
  // field could be null in polygon filter
448
  const valueAccessor = field ? field.valueAccessor : () => null;
90✔
449
  const defaultFunc = () => true;
90✔
450

451
  if (filter.enabled === false) {
90✔
452
    return defaultFunc;
1✔
453
  }
454

455
  switch (filter.type) {
89!
456
    case FILTER_TYPES.range:
457
      return data => isInRange(valueAccessor(data), filter.value);
265✔
458
    case FILTER_TYPES.multiSelect:
459
      return data => filter.value.includes(valueAccessor(data));
492✔
460
    case FILTER_TYPES.select:
461
      return data => valueAccessor(data) === filter.value;
32✔
462
    case FILTER_TYPES.timeRange: {
463
      if (!field) {
18!
464
        return defaultFunc;
×
465
      }
466
      const mappedValue = get(field, ['filterProps', 'mappedValue']);
18✔
467
      const accessor = Array.isArray(mappedValue)
18✔
468
        ? data => mappedValue[data.index]
48✔
469
        : data => timeToUnixMilli(valueAccessor(data), field.format);
9✔
470
      return data => isInRange(accessor(data), filter.value);
57✔
471
    }
472
    case FILTER_TYPES.polygon: {
473
      if (!layers || !layers.length || !filter.layerId) {
9✔
474
        return defaultFunc;
1✔
475
      }
476
      const layerFilterFunctions = filter.layerId
8✔
477
        .map(id => layers.find(l => l.id === id))
24✔
478
        .filter(l => l && l.config.dataId === dataId)
13✔
479
        .map(layer => getPolygonFilterFunctor(layer, filter, dataContainer));
8✔
480

481
      return data => layerFilterFunctions.every(filterFunc => filterFunc(data));
35✔
482
    }
483
    default:
484
      return defaultFunc;
×
485
  }
486
}
487

488
export function updateFilterDataId(dataId: string | string[]): FilterBase<LineChart> {
489
  return getDefaultFilter({dataId});
×
490
}
491

492
export function filterDataByFilterTypes(
493
  {
494
    dynamicDomainFilters,
495
    cpuFilters,
496
    filterFuncs
497
  }: {
498
    dynamicDomainFilters: Filter[] | null;
499
    cpuFilters: Filter[] | null;
500
    filterFuncs: {
501
      [key: string]: filterFunction;
502
    };
503
  },
504
  dataContainer: DataContainerInterface
505
): FilterResult {
506
  const filteredIndexForDomain: number[] = [];
60✔
507
  const filteredIndex: number[] = [];
60✔
508

509
  const filterContext = {index: -1, dataContainer};
60✔
510
  const filterFuncCaller = (filter: Filter) => filterFuncs[filter.id](filterContext);
874✔
511

512
  const numRows = dataContainer.numRows();
60✔
513
  for (let i = 0; i < numRows; ++i) {
60✔
514
    filterContext.index = i;
587✔
515

516
    const matchForDomain = dynamicDomainFilters && dynamicDomainFilters.every(filterFuncCaller);
587✔
517
    if (matchForDomain) {
587✔
518
      filteredIndexForDomain.push(filterContext.index);
240✔
519
    }
520

521
    const matchForRender = cpuFilters && cpuFilters.every(filterFuncCaller);
587✔
522
    if (matchForRender) {
587✔
523
      filteredIndex.push(filterContext.index);
144✔
524
    }
525
  }
526

527
  return {
60✔
528
    ...(dynamicDomainFilters ? {filteredIndexForDomain} : {}),
60✔
529
    ...(cpuFilters ? {filteredIndex} : {})
60✔
530
  };
531
}
532

533
/**
534
 * Get a record of filters based on domain type and gpu / cpu
535
 */
536
export function getFilterRecord(
537
  dataId: string,
538
  filters: Filter[],
539
  opt: FilterDatasetOpt = {}
×
540
): FilterRecord {
541
  const filterRecord: FilterRecord = {
135✔
542
    dynamicDomain: [],
543
    fixedDomain: [],
544
    cpu: [],
545
    gpu: []
546
  };
547

548
  filters.forEach(f => {
135✔
549
    if (isValidFilterValue(f.type, f.value) && toArray(f.dataId).includes(dataId)) {
171✔
550
      (f.fixedDomain || opt.ignoreDomain
166✔
551
        ? filterRecord.fixedDomain
552
        : filterRecord.dynamicDomain
553
      ).push(f);
554

555
      (f.gpu && !opt.cpuOnly ? filterRecord.gpu : filterRecord.cpu).push(f);
166✔
556
    }
557
  });
558

559
  return filterRecord;
135✔
560
}
561

562
/**
563
 * Compare filter records to get what has changed
564
 */
565
export function diffFilters(
566
  filterRecord: FilterRecord,
567
  oldFilterRecord: FilterRecord | Record<string, never> = {}
69✔
568
): FilterChanged {
569
  let filterChanged: Partial<FilterChanged> = {};
134✔
570

571
  (Object.entries(filterRecord) as Entries<FilterRecord>).forEach(([record, items]) => {
134✔
572
    items.forEach(filter => {
536✔
573
      const oldFilter: Filter | undefined = (oldFilterRecord[record] || []).find(
330✔
574
        (f: Filter) => f.id === filter.id
167✔
575
      );
576

577
      if (!oldFilter) {
330✔
578
        // added
579
        filterChanged = set([record, filter.id], 'added', filterChanged);
192✔
580
      } else {
581
        // check  what has changed
582
        ['name', 'value', 'dataId'].forEach(prop => {
138✔
583
          if (filter[prop] !== oldFilter[prop]) {
414✔
584
            filterChanged = set([record, filter.id], `${prop}_changed`, filterChanged);
92✔
585
          }
586
        });
587
      }
588
    });
589

590
    (oldFilterRecord[record] || []).forEach((oldFilter: Filter) => {
536✔
591
      // deleted
592
      if (!items.find(f => f.id === oldFilter.id)) {
159✔
593
        filterChanged = set([record, oldFilter.id], 'deleted', filterChanged);
18✔
594
      }
595
    });
596
  });
597

598
  return {...{dynamicDomain: null, fixedDomain: null, cpu: null, gpu: null}, ...filterChanged};
134✔
599
}
600

601
/**
602
 * Call by parsing filters from URL
603
 * Check if value of filter within filter domain, if not adjust it to match
604
 * filter domain
605
 *
606
 * @returns value - adjusted value to match filter or null to remove filter
607
 */
608
// eslint-disable-next-line complexity
609
export function adjustValueToFilterDomain(value: Filter['value'], {domain, type}) {
610
  if (!type) {
63!
611
    return false;
×
612
  }
613
  // if the current filter is a polygon it will not have any domain
614
  // all other filter types require domain
615
  if (type !== FILTER_TYPES.polygon && !domain) {
63!
616
    return false;
×
617
  }
618

619
  switch (type) {
63!
620
    case FILTER_TYPES.range:
621
    case FILTER_TYPES.timeRange:
622
      if (!Array.isArray(value) || value.length !== 2) {
46✔
623
        return domain.map(d => d);
10✔
624
      }
625

626
      return value.map((d, i) => (notNullorUndefined(d) && isInRange(d, domain) ? d : domain[i]));
82✔
627

628
    case FILTER_TYPES.multiSelect: {
629
      if (!Array.isArray(value)) {
12✔
630
        return [];
1✔
631
      }
632
      const filteredValue = value.filter(d => domain.includes(d));
25✔
633
      return filteredValue.length ? filteredValue : [];
11✔
634
    }
635
    case FILTER_TYPES.select:
636
      return domain.includes(value) ? value : true;
5✔
637
    case FILTER_TYPES.polygon:
UNCOV
638
      return value;
×
639

640
    default:
641
      return null;
×
642
  }
643
}
644

645
/**
646
 * Calculate numeric domain and suitable step
647
 */
648
export function getNumericFieldDomain(
649
  dataContainer: DataContainerInterface,
650
  valueAccessor: dataValueAccessor
651
): RangeFieldDomain {
652
  let domain: [number, number] = [0, 1];
29✔
653
  let step = 0.1;
29✔
654

655
  const mappedValue = dataContainer.mapIndex(valueAccessor);
29✔
656

657
  if (dataContainer.numRows() > 1) {
29!
658
    domain = ScaleUtils.getLinearDomain(mappedValue);
29✔
659
    const diff = domain[1] - domain[0];
29✔
660

661
    // in case equal domain, [96, 96], which will break quantize scale
662
    if (!diff) {
29!
663
      domain[1] = domain[0] + 1;
×
664
    }
665

666
    step = getNumericStepSize(diff) || step;
29!
667
    domain[0] = formatNumberByStep(domain[0], step, 'floor');
29✔
668
    domain[1] = formatNumberByStep(domain[1], step, 'ceil');
29✔
669
  }
670

671
  return {domain, step};
29✔
672
}
673

674
/**
675
 * Calculate step size for range and timerange filter
676
 */
677
export function getNumericStepSize(diff: number): number {
678
  diff = Math.abs(diff);
29✔
679

680
  if (diff > 100) {
29✔
681
    return 1;
3✔
682
  } else if (diff > 3) {
26✔
683
    return 0.01;
23✔
684
  } else if (diff > 1) {
3✔
685
    return 0.001;
1✔
686
  }
687
  // Try to get at least 1000 steps - and keep the step size below that of
688
  // the (diff > 1) case.
689
  const x = diff / 1000;
2✔
690
  // Find the exponent and truncate to 10 to the power of that exponent
691

692
  const exponentialForm = x.toExponential();
2✔
693
  const exponent = parseFloat(exponentialForm.split('e')[1]);
2✔
694

695
  // Getting ready for node 12
696
  // this is why we need decimal.js
697
  // Math.pow(10, -5) = 0.000009999999999999999
698
  // the above result shows in browser and node 10
699
  // node 12 behaves correctly
700
  return new Decimal(10).pow(exponent).toNumber();
2✔
701
}
702

703
/**
704
 * Calculate timestamp domain and suitable step
705
 */
706
export function getTimestampFieldDomain(
707
  dataContainer: DataContainerInterface,
708
  valueAccessor: dataValueAccessor
709
): TimeRangeFieldDomain {
710
  // to avoid converting string format time to epoch
711
  // every time we compare we store a value mapped to int in filter domain
712
  const mappedValue = dataContainer.mapIndex(valueAccessor);
51✔
713

714
  const domain = ScaleUtils.getLinearDomain(mappedValue);
51✔
715
  const defaultTimeFormat = getTimeWidgetTitleFormatter(domain);
51✔
716

717
  let step = 0.01;
51✔
718

719
  const diff = domain[1] - domain[0];
51✔
720
  // in case equal timestamp add 1 second padding to prevent break
721
  if (!diff) {
51✔
722
    domain[1] = domain[0] + 1000;
1✔
723
  }
724
  const entry = TimestampStepMap.find(f => f.max >= diff);
335✔
725
  if (entry) {
51!
726
    step = entry.step;
51✔
727
  }
728

729
  return {
51✔
730
    domain,
731
    step,
732
    mappedValue,
733
    defaultTimeFormat
734
  };
735
}
736

737
/**
738
 * round number based on step
739
 *
740
 * @param {Number} val
741
 * @param {Number} step
742
 * @param {string} bound
743
 * @returns {Number} rounded number
744
 */
745
export function formatNumberByStep(val: number, step: number, bound: 'floor' | 'ceil'): number {
746
  if (bound === 'floor') {
58✔
747
    return Math.floor(val * (1 / step)) / (1 / step);
29✔
748
  }
749

750
  return Math.ceil(val * (1 / step)) / (1 / step);
29✔
751
}
752

753
export function isInRange(val: any, domain: number[]): boolean {
754
  if (!Array.isArray(domain)) {
439!
755
    return false;
×
756
  }
757

758
  return val >= domain[0] && val <= domain[1];
439✔
759
}
760

761
/**
762
 * Determines whether a point is within the provided polygon
763
 *
764
 * @param point as input search [lat, lng]
765
 * @param polygon Points must be within these (Multi)Polygon(s)
766
 * @return {boolean}
767
 */
768
export function isInPolygon(point: number[], polygon: any): boolean {
769
  return booleanWithin(turfPoint(point), polygon);
36✔
770
}
771
export function getTimeWidgetTitleFormatter(domain: [number, number]): string | null {
772
  if (!isValidTimeDomain(domain)) {
69!
773
    return null;
×
774
  }
775

776
  const diff = domain[1] - domain[0];
69✔
777

778
  // Local aware formats
779
  // https://momentjs.com/docs/#/parsing/string-format
780
  return diff > durationYear ? 'L' : diff > durationDay ? 'L LT' : 'L LTS';
69!
781
}
782

783
/**
784
 * Sanity check on filters to prepare for save
785
 * @type {typeof import('./filter-utils').isFilterValidToSave}
786
 */
787
export function isFilterValidToSave(filter: any): boolean {
788
  return (
41✔
789
    filter?.type && Array.isArray(filter?.name) && (filter?.name.length || filter?.layerId.length)
121!
790
  );
791
}
792

793
/**
794
 * Sanity check on filters to prepare for save
795
 * @type {typeof import('./filter-utils').isValidFilterValue}
796
 */
797
/* eslint-disable complexity */
798
export function isValidFilterValue(type: string | null, value: any): boolean {
799
  if (!type) {
184✔
800
    return false;
1✔
801
  }
802
  switch (type) {
183!
803
    case FILTER_TYPES.select:
804
      return value === true || value === false;
4✔
805

806
    case FILTER_TYPES.range:
807
    case FILTER_TYPES.timeRange:
808
      return Array.isArray(value) && value.every(v => v !== null && !isNaN(v));
245✔
809

810
    case FILTER_TYPES.multiSelect:
811
      return Array.isArray(value) && Boolean(value.length);
41✔
812

813
    case FILTER_TYPES.input:
814
      return Boolean(value.length);
×
815

816
    case FILTER_TYPES.polygon: {
817
      const coordinates = get(value, ['geometry', 'coordinates']);
13✔
818
      return Boolean(value && value.id && coordinates);
13✔
819
    }
820
    default:
821
      return true;
×
822
  }
823
}
824

825
export function getColumnFilterProps<K extends KeplerTableModel<K, L>, L>(
826
  filter: Filter,
827
  dataset: K
828
): {lineChart: LineChart; yAxs: Field} | Record<string, any> {
829
  if (filter.plotType?.type === PLOT_TYPES.histogram || !filter.yAxis) {
×
830
    // histogram should be calculated when create filter
831
    return {};
×
832
  }
833

834
  const {mappedValue = []} = filter;
×
835
  const {yAxis} = filter;
×
836
  const fieldIdx = dataset.getColumnFieldIdx(yAxis.name);
×
837
  if (fieldIdx < 0) {
×
838
    // Console.warn(`yAxis ${yAxis.name} does not exist in dataset`);
839
    return {lineChart: {}, yAxis};
×
840
  }
841

842
  // return lineChart
843
  const series = dataset.dataContainer
×
844
    .map(
845
      (row, rowIndex) => ({
×
846
        x: mappedValue[rowIndex],
847
        y: row.valueAt(fieldIdx)
848
      }),
849
      true
850
    )
851
    .filter(({x, y}) => Number.isFinite(x) && Number.isFinite(y))
×
852
    .sort((a, b) => ascending(a.x, b.x));
×
853

854
  const yDomain = extent(series, d => d.y) as [number, number];
×
855
  const xDomain = [series[0].x, series[series.length - 1].x] as [number, number];
×
856

857
  return {lineChart: {series, yDomain, xDomain}, yAxis};
×
858
}
859

860
export function updateFilterPlot<K extends KeplerTableModel<K, any>>(
861
  datasets: {[id: string]: K},
862
  filter: Filter,
863
  dataId: string | undefined = undefined
134✔
864
) {
865
  if (dataId) {
134!
866
    filter = removeFilterPlot(filter, dataId);
×
867
  }
868

869
  if (filter.type === FILTER_TYPES.timeRange) {
134✔
870
    return updateTimeFilterPlotType(filter as TimeRangeFilter, filter.plotType, datasets);
61✔
871
  } else if (filter.type === FILTER_TYPES.range) {
73✔
872
    return updateRangeFilterPlotType(filter as RangeFilter, filter.plotType, datasets);
36✔
873
  }
874
  return filter;
37✔
875
}
876

877
/**
878
 *
879
 * @param datasetIds list of dataset ids to be filtered
880
 * @param datasets all datasets
881
 * @param filters all filters to be applied to datasets
882
 * @return datasets - new updated datasets
883
 */
884
export function applyFiltersToDatasets<
885
  K extends KeplerTableModel<K, L>,
886
  L extends {config: {dataId: string | null}}
887
>(
888
  datasetIds: string[],
889
  datasets: {[id: string]: K},
890
  filters: Filter[],
891
  layers?: L[]
892
): {[id: string]: K} {
893
  const dataIds = toArray(datasetIds);
143✔
894
  return dataIds.reduce((acc, dataId) => {
143✔
895
    const layersToFilter = (layers || []).filter(l => l.config.dataId === dataId);
307!
896
    const appliedFilters = filters.filter(d => shouldApplyFilter(d, dataId));
234✔
897
    const table = datasets[dataId];
128✔
898

899
    return {
128✔
900
      ...acc,
901
      [dataId]: table.filterTable(appliedFilters, layersToFilter, {})
902
    };
903
  }, datasets);
904
}
905

906
/**
907
 * Applies a new field name value to filter and update both filter and dataset
908
 * @param filter - to be applied the new field name on
909
 * @param dataset - dataset the field belongs to
910
 * @param fieldName - field.name
911
 * @param filterDatasetIndex - field.name
912
 * @param option
913
 * @return - {filter, datasets}
914
 */
915
export function applyFilterFieldName<K extends KeplerTableModel<K, L>, L>(
916
  filter: Filter,
917
  dataset: K,
918
  fieldName: string,
919
  filterDatasetIndex = 0,
1✔
920
  option?: {mergeDomain: boolean}
921
): {
922
  filter: Filter | null;
923
  dataset: K;
924
} {
925
  // using filterDatasetIndex we can filter only the specified dataset
926
  const mergeDomain =
927
    option && Object.prototype.hasOwnProperty.call(option, 'mergeDomain')
86✔
928
      ? option.mergeDomain
929
      : false;
930

931
  const fieldIndex = dataset.getColumnFieldIdx(fieldName);
86✔
932
  // if no field with same name is found, move to the next datasets
933
  if (fieldIndex === -1) {
86!
934
    // throw new Error(`fieldIndex not found. Dataset must contain a property with name: ${fieldName}`);
935
    return {filter: null, dataset};
×
936
  }
937

938
  // TODO: validate field type
939
  const filterProps = dataset.getColumnFilterProps(fieldName);
86✔
940

941
  let newFilter = {
86✔
942
    ...(mergeDomain ? mergeFilterDomainStep(filter, filterProps) : {...filter, ...filterProps}),
86✔
943
    name: Object.assign([...toArray(filter.name)], {[filterDatasetIndex]: fieldName}),
944
    fieldIdx: Object.assign([...toArray(filter.fieldIdx)], {
945
      [filterDatasetIndex]: fieldIndex
946
    }),
947
    // Make sure plotType is not overwritten by the default empty plotType
948
    ...(filter.plotType ? {plotType: filter.plotType} : {})
86!
949
  };
950

951
  // TODO: if we don't set filter value in filterProps, we don't need to do this
952
  if (filterDatasetIndex > 0) {
86✔
953
    // don't reset the filter value if we are just adding a synced dataset
954
    newFilter = {
4✔
955
      ...newFilter,
956
      value: filter.value
957
    };
958
  }
959

960
  return {
86✔
961
    filter: newFilter,
962
    dataset
963
  };
964
}
965

966
/**
967
 * Merge one filter with other filter prop domain
968
 */
969
/* eslint-disable complexity */
970
export function mergeFilterDomainStep(
971
  filter: Filter,
972
  filterProps?: Partial<Filter>
973
): (Filter & {step?: number}) | null {
974
  if (!filter) {
48!
975
    return null;
×
976
  }
977

978
  if (!filterProps) {
48!
979
    return filter;
×
980
  }
981

982
  if ((filter.fieldType && filter.fieldType !== filterProps.fieldType) || !filterProps.domain) {
48!
983
    return filter;
×
984
  }
985

986
  const sortedDomain = !filter.domain
48✔
987
    ? filterProps.domain
988
    : [...(filter.domain || []), ...(filterProps.domain || [])].sort((a, b) => a - b);
24!
989

990
  const newFilter = {
48✔
991
    ...filter,
992
    ...filterProps,
993
    // use min max as default domain
994
    domain: [sortedDomain[0], sortedDomain[sortedDomain.length - 1]]
995
  };
996

997
  switch (filterProps.fieldType) {
48✔
998
    case ALL_FIELD_TYPES.string:
999
    case ALL_FIELD_TYPES.date:
1000
      return {
7✔
1001
        ...newFilter,
1002
        domain: unique(sortedDomain)
1003
      };
1004

1005
    case ALL_FIELD_TYPES.timestamp: {
1006
      const step =
1007
        (filter as TimeRangeFilter).step < (filterProps as TimeRangeFieldDomain).step
27!
1008
          ? (filter as TimeRangeFilter).step
1009
          : (filterProps as TimeRangeFieldDomain).step;
1010

1011
      return {
27✔
1012
        ...newFilter,
1013
        step
1014
      };
1015
    }
1016
    case ALL_FIELD_TYPES.real:
1017
    case ALL_FIELD_TYPES.integer:
1018
    default:
1019
      return newFilter;
14✔
1020
  }
1021
}
1022
/* eslint-enable complexity */
1023

1024
/**
1025
 * Generates polygon filter
1026
 */
1027
export const featureToFilterValue = (
11✔
1028
  feature: Feature,
1029
  filterId: string,
1030
  properties?: Record<string, any>
1031
): FeatureValue => ({
6✔
1032
  ...feature,
1033
  id: feature.id,
1034
  properties: {
1035
    ...feature.properties,
1036
    ...properties,
1037
    filterId
1038
  }
1039
});
1040

1041
export const getFilterIdInFeature = (f: FeatureValue): string => get(f, ['properties', 'filterId']);
21✔
1042

1043
/**
1044
 * Generates polygon filter
1045
 */
1046
export function generatePolygonFilter<
1047
  L extends {config: {dataId: string | null; label: string}; id: string}
1048
>(layers: L[], feature: Feature): PolygonFilter {
1049
  const dataId = layers.map(l => l.config.dataId).filter(notNullorUndefined);
5✔
1050
  const layerId = layers.map(l => l.id);
5✔
1051
  const name = layers.map(l => l.config.label);
5✔
1052
  const filter = getDefaultFilter({dataId});
5✔
1053
  return {
5✔
1054
    ...filter,
1055
    fixedDomain: true,
1056
    type: FILTER_TYPES.polygon,
1057
    name,
1058
    layerId,
1059
    value: featureToFilterValue(feature, filter.id, {isVisible: true})
1060
  };
1061
}
1062

1063
/**
1064
 * Run filter entirely on CPU
1065
 */
1066
interface StateType<K extends KeplerTableModel<K, L>, L> {
1067
  layers: L[];
1068
  filters: Filter[];
1069
  datasets: {[id: string]: K};
1070
}
1071

1072
export function filterDatasetCPU<T extends StateType<K, L>, K extends KeplerTableModel<K, L>, L>(
1073
  state: T,
1074
  dataId: string
1075
): T {
1076
  const datasetFilters = state.filters.filter(f => f.dataId.includes(dataId));
12✔
1077
  const dataset = state.datasets[dataId];
7✔
1078

1079
  if (!dataset) {
7!
1080
    return state;
×
1081
  }
1082

1083
  const cpuFilteredDataset = dataset.filterTableCPU(datasetFilters, state.layers);
7✔
1084

1085
  return set(['datasets', dataId], cpuFilteredDataset, state);
7✔
1086
}
1087

1088
/**
1089
 * Validate parsed filters with datasets and add filterProps to field
1090
 */
1091
type MinVisStateForFilter = Pick<VisState, 'layers' | 'datasets' | 'isMergingDatasets'>;
1092
export function validateFiltersUpdateDatasets<
1093
  S extends MinVisStateForFilter,
1094
  K extends KeplerTableModel<K, L>,
1095
  L extends {config: {dataId: string | null; label: string}; id: string}
1096
>(
1097
  state: S,
1098
  filtersToValidate: ParsedFilter[] = []
×
1099
): {
1100
  validated: Filter[];
1101
  failed: Filter[];
1102
  updatedDatasets: S['datasets'];
1103
} {
1104
  // TODO Better Typings here
1105
  const validated: any[] = [];
52✔
1106
  const failed: any[] = [];
52✔
1107
  const {datasets, layers} = state;
52✔
1108
  let updatedDatasets = datasets;
52✔
1109

1110
  // merge filters
1111
  filtersToValidate.forEach(filterToValidate => {
52✔
1112
    // we can only look for datasets define in the filter dataId
1113
    const datasetIds = toArray(filterToValidate.dataId);
91✔
1114

1115
    // we can merge a filter only if all datasets in filter.dataId are loaded
1116
    if (datasetIds.every(d => datasets[d] && !state.isMergingDatasets[d])) {
93✔
1117
      // all datasetIds in filter must be present the state datasets
1118
      const {validatedFilter, applyToDatasets, augmentedDatasets} = datasetIds.reduce<{
42✔
1119
        validatedFilter: Filter | null;
1120
        applyToDatasets: string[];
1121
        augmentedDatasets: {[datasetId: string]: any};
1122
      }>(
1123
        (acc, datasetId) => {
1124
          const dataset = updatedDatasets[datasetId];
43✔
1125
          const datasetLayers = layers.filter(l => l.config.dataId === dataset.id);
76✔
1126
          const toValidate = acc.validatedFilter || filterToValidate;
43✔
1127

1128
          const {filter: updatedFilter, dataset: updatedDataset} = validateFilterWithData(
43✔
1129
            acc.augmentedDatasets[datasetId] || dataset,
86✔
1130
            toValidate,
1131
            datasetLayers
1132
          );
1133

1134
          if (updatedFilter) {
43!
1135
            // merge filter domain step
1136
            return {
43✔
1137
              validatedFilter: updatedFilter,
1138

1139
              applyToDatasets: [...acc.applyToDatasets, datasetId],
1140

1141
              augmentedDatasets: {
1142
                ...acc.augmentedDatasets,
1143
                [datasetId]: updatedDataset
1144
              }
1145
            };
1146
          }
1147

UNCOV
1148
          return acc;
×
1149
        },
1150
        {
1151
          validatedFilter: null,
1152
          applyToDatasets: [],
1153
          augmentedDatasets: {}
1154
        }
1155
      );
1156

1157
      if (validatedFilter && isEqual(datasetIds, applyToDatasets)) {
42!
1158
        let domain = validatedFilter.domain;
42✔
1159
        if ((validatedFilter as TimeRangeFilter).syncedWithLayerTimeline) {
42✔
1160
          const animatableLayers = getAnimatableVisibleLayers(layers);
1✔
1161
          domain = mergeTimeDomains([
1✔
1162
            ...animatableLayers.map(l => l.config.animation.domain || [0, 0]),
×
1163
            validatedFilter.domain
1164
          ]);
1165
        }
1166

1167
        validatedFilter.value = adjustValueToFilterDomain(filterToValidate.value, {
42✔
1168
          ...validatedFilter,
1169
          domain
1170
        });
1171

1172
        validated.push(updateFilterPlot(datasets, validatedFilter));
42✔
1173
        updatedDatasets = {
42✔
1174
          ...updatedDatasets,
1175
          ...augmentedDatasets
1176
        };
1177
      } else {
UNCOV
1178
        failed.push(filterToValidate);
×
1179
      }
1180
    } else {
1181
      failed.push(filterToValidate);
49✔
1182
    }
1183
  });
1184

1185
  return {validated, failed, updatedDatasets};
52✔
1186
}
1187

1188
export function removeFilterPlot(filter: Filter, dataId: string) {
1189
  let nextFilter = filter;
44✔
1190

1191
  const rangeFilter = filter as RangeFilter;
44✔
1192
  if (rangeFilter.bins && rangeFilter.bins[dataId]) {
44!
1193
    const {[dataId]: _delete, ...nextBins} = rangeFilter.bins;
×
1194
    nextFilter = {
×
1195
      ...rangeFilter,
1196
      bins: nextBins
1197
    };
1198
  }
1199

1200
  const timeFilter = filter as TimeRangeFilter;
44✔
1201
  if (timeFilter.timeBins && timeFilter.timeBins[dataId]) {
44✔
1202
    const {[dataId]: __delete, ...nextTimeBins} = timeFilter.timeBins;
2✔
1203
    nextFilter = {
2✔
1204
      ...nextFilter,
1205
      timeBins: nextTimeBins
1206
    } as Filter;
1207
  }
1208

1209
  return nextFilter;
44✔
1210
}
1211

1212
export function isValidTimeDomain(domain) {
1213
  return Array.isArray(domain) && domain.every(Number.isFinite);
69✔
1214
}
1215

1216
export function getTimeWidgetHintFormatter(domain: [number, number]): string | undefined {
1217
  if (!isValidTimeDomain(domain)) {
×
1218
    return undefined;
×
1219
  }
1220

1221
  const diff = domain[1] - domain[0];
×
1222
  return diff > durationWeek
×
1223
    ? 'L'
1224
    : diff > durationDay
×
1225
    ? 'L LT'
1226
    : diff > durationHour
×
1227
    ? 'LT'
1228
    : 'LTS';
1229
}
1230

1231
export function isSideFilter(filter: Filter): boolean {
1232
  return filter.view === FILTER_VIEW_TYPES.side;
2✔
1233
}
1234

1235
export function mergeTimeDomains(domains: ([number, number] | null)[]): [number, number] {
1236
  return domains.reduce(
24✔
1237
    (acc: [number, number], domain) => [
29✔
1238
      Math.min(acc[0], domain?.[0] ?? Infinity),
29!
1239
      Math.max(acc[1], domain?.[1] ?? -Infinity)
29!
1240
    ],
1241
    [Number(Infinity), -Infinity]
1242
  ) as [number, number];
1243
}
1244

1245
/**
1246
 * @param {Layer} layer
1247
 */
1248
export function isLayerAnimatable(layer: any): boolean {
1249
  return layer.config.animation?.enabled && Array.isArray(layer.config.animation.domain);
408✔
1250
}
1251

1252
/**
1253
 * @param {Layer[]} layers
1254
 * @returns {Layer[]}
1255
 */
1256
export function getAnimatableVisibleLayers(layers: any[]): any[] {
1257
  return layers.filter(l => isLayerAnimatable(l) && l.config.isVisible);
403✔
1258
}
1259

1260
/**
1261
 * @param {Layer[]} layers
1262
 * @param {string} type
1263
 * @returns {Layer[]}
1264
 */
1265
export function getAnimatableVisibleLayersByType(layers: any[], type: string): any[] {
1266
  return getAnimatableVisibleLayers(layers).filter(l => l.type === type);
×
1267
}
1268

1269
/**
1270
 * @param {Layer[]} layers
1271
 * @returns {Layer[]}
1272
 */
1273
export function getIntervalBasedAnimationLayers(layers: any[]): any[] {
1274
  // @ts-ignore
1275
  return getAnimatableVisibleLayers(layers).filter(l => l.config.animation?.timeSteps);
6✔
1276
}
1277

1278
export function mergeFilterWithTimeline(
1279
  filter: TimeRangeFilter,
1280
  animationConfig: AnimationConfig
1281
): {filter: TimeRangeFilter; animationConfig: AnimationConfig} {
1282
  if (
1!
1283
    filter?.type === FILTER_TYPES.timeRange &&
4✔
1284
    filter.syncedWithLayerTimeline &&
1285
    animationConfig &&
1286
    Array.isArray(animationConfig.domain)
1287
  ) {
1288
    const domain = mergeTimeDomains([filter.domain, animationConfig.domain as [number, number]]);
1✔
1289
    return {
1✔
1290
      filter: {
1291
        ...filter,
1292
        domain
1293
      },
1294
      animationConfig: {
1295
        ...animationConfig,
1296
        domain
1297
      }
1298
    };
1299
  }
1300
  return {filter, animationConfig};
×
1301
}
1302

1303
export function scaleSourceDomainToDestination(
1304
  sourceDomain: [number, number],
1305
  destinationDomain: [number, number]
1306
): [number, number] {
1307
  // 0 -> 100: merged domains t1 - t0 === 100% filter may already have this info which is good
1308
  const sourceDomainSize = sourceDomain[1] - sourceDomain[0];
1✔
1309
  // 10 -> 20: animationConfig domain d1 - d0 === animationConfig size
1310
  const destinationDomainSize = destinationDomain[1] - destinationDomain[0];
1✔
1311
  // scale animationConfig size using domain size
1312
  const scaledSourceDomainSize = (sourceDomainSize / destinationDomainSize) * 100;
1✔
1313
  // scale d0 - t0 using domain size to find starting point
1314
  const offset = sourceDomain[0] - destinationDomain[0];
1✔
1315
  const scaledOffset = (offset / destinationDomainSize) * 100;
1✔
1316
  return [scaledOffset, scaledSourceDomainSize + scaledOffset];
1✔
1317
}
1318

1319
export function getFilterScaledTimeline(filter, animationConfig): [number, number] | [] {
1320
  if (!(filter.syncedWithLayerTimeline && animationConfig?.domain)) {
×
1321
    return [];
×
1322
  }
1323

1324
  return scaleSourceDomainToDestination(animationConfig.domain, filter.domain);
×
1325
}
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