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

keplergl / kepler.gl / 25315239817

04 May 2026 10:59AM UTC coverage: 58.936% (+0.005%) from 58.931%
25315239817

push

github

web-flow
fix: Fix PointLayer polygon filtering for GeoJSON column mode (#3410)

* fix: Fix PointLayer polygon filtering for GeoJSON column mode

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* fix highlighting for point layer in geojson mode

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

* improve

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

---------

Signed-off-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>
Co-authored-by: Ihor Dykhta <ihordykhta@Ihors-MacBook-Pro.local>

6952 of 14166 branches covered (49.08%)

Branch coverage included in aggregate %.

9 of 14 new or added lines in 2 files covered. (64.29%)

51 existing lines in 2 files now uncovered.

14302 of 21897 relevant lines covered (65.31%)

79.58 hits per line

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

77.72
/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, getCentroid} 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 {updateTimeFilterPlotType, updateRangeFilterPlotType} from './plot';
49
import {KeplerTableModel} from './types';
50

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

58
// TODO isolate types - depends on @kepler.gl/schemas
59
type VisState = any;
60

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

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

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

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

85
export const FILTER_UPDATER_PROPS = keyMirror({
17✔
86
  dataId: null,
87
  name: null,
88
  layerId: null
89
});
90

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

99
export const DEFAULT_FILTER_STRUCTURE = {
17✔
100
  dataId: [], // [string]
101
  id: null,
102
  enabled: true,
103

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

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

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

124
  // mode
125
  gpu: false
126
};
127

128
export const FILTER_ID_LENGTH = 4;
17✔
129

130
export const LAYER_FILTERS = [FILTER_TYPES.polygon];
17✔
131

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

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

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

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

180
  const isValidDataset = dataId.includes(dataset.id);
3✔
181

182
  if (!isValidDataset) {
3✔
183
    return failed;
1✔
184
  }
185

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

188
  if (!layer) {
2✔
189
    return failed;
1✔
190
  }
191

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

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

208
/**
209
 * Default validate filter function
210
 * @param datasets
211
 * @param datasetId
212
 * @param filter
213
 * @return - {filter, dataset}
214
 */
215
export function validateFilter<K extends KeplerTableModel<K, L>, L>(
216
  datasets: Record<string, K>,
217
  datasetId: string,
218
  filter: ParsedFilter
219
): {filter: Filter | null; dataset: K} {
220
  const dataset = datasets[datasetId];
46✔
221
  // match filter.dataId
222
  const failed = {dataset, filter: null};
46✔
223
  const filterDataId = toArray(filter.dataId);
46✔
224

225
  const filterDatasetIndex = filterDataId.indexOf(dataset.id);
46✔
226
  if (filterDatasetIndex < 0 || !toArray(filter.name)[filterDatasetIndex]) {
46!
227
    // the current filter is not mapped against the current dataset
228
    return failed;
×
229
  }
230

231
  const initializeFilter: Filter = {
46✔
232
    ...getDefaultFilter({dataId: filter.dataId}),
233
    ...filter,
234
    dataId: filterDataId,
235
    name: toArray(filter.name)
236
  };
237

238
  const fieldName = initializeFilter.name[filterDatasetIndex];
46✔
239
  const {filter: updatedFilter, dataset: updatedDataset} = applyFilterFieldName(
46✔
240
    initializeFilter,
241
    datasets,
242
    datasetId,
243
    fieldName,
244
    filterDatasetIndex,
245
    {mergeDomain: true}
246
  );
247

248
  if (!updatedFilter) {
46✔
249
    return failed;
1✔
250
  }
251

252
  // don't adjust value yet before all datasets are loaded
253
  updatedFilter.view = filter.view ?? updatedFilter.view;
45!
254

255
  if (updatedFilter.value === null) {
45!
256
    // cannot adjust saved value to filter
257
    return failed;
×
258
  }
259

260
  return {
45✔
261
    filter: validateFilterYAxis(updatedFilter, updatedDataset),
262
    dataset: updatedDataset
263
  };
264
}
265

266
/**
267
 * Validate saved filter config with new data
268
 *
269
 * @param datasets
270
 * @param datasetId
271
 * @param filter - filter to be validate
272
 * @param layers - layers
273
 * @return validated filter
274
 */
275
export function validateFilterWithData<K extends KeplerTableModel<K, L>, L>(
276
  datasets: Record<string, K>,
277
  datasetId: string,
278
  filter: ParsedFilter,
279
  layers: L[]
280
): {filter: Filter; dataset: K} {
281
  return filter.type && Object.prototype.hasOwnProperty.call(filterValidators, filter.type)
46!
282
    ? filterValidators[filter.type](datasets[datasetId], filter, layers)
283
    : validateFilter(datasets, datasetId, filter);
284
}
285

286
/**
287
 * Validate YAxis
288
 * @param filter
289
 * @param dataset
290
 * @return {*}
291
 */
292
function validateFilterYAxis(filter, dataset) {
293
  // TODO: validate yAxis against other datasets
294

295
  const {fields} = dataset;
45✔
296
  const {yAxis} = filter;
45✔
297
  // TODO: validate yAxis against other datasets
298
  if (yAxis) {
45!
299
    const matchedAxis = fields.find(({name, type}) => name === yAxis.name && type === yAxis.type);
×
300

301
    filter = matchedAxis
×
302
      ? {
303
          ...filter,
304
          yAxis: matchedAxis
305
        }
306
      : filter;
307
  }
308

309
  return filter;
45✔
310
}
311

312
/**
313
 * Get default filter prop based on field type
314
 *
315
 * @param field
316
 * @param fieldDomain
317
 * @returns default filter
318
 */
319
export function getFilterProps(
320
  field: Field,
321
  fieldDomain: FieldDomain
322
): Partial<Filter> & {fieldType: string} {
323
  const filterProps = {
76✔
324
    ...fieldDomain,
325
    fieldType: field.type,
326
    view: FILTER_VIEW_TYPES.side
327
  };
328

329
  switch (field.type) {
76!
330
    case ALL_FIELD_TYPES.real:
331
    case ALL_FIELD_TYPES.integer:
332
      return {
23✔
333
        ...filterProps,
334
        value: fieldDomain.domain,
335
        type: FILTER_TYPES.range,
336
        // @ts-expect-error
337
        typeOptions: [FILTER_TYPES.range],
338
        gpu: true
339
      };
340

341
    case ALL_FIELD_TYPES.boolean:
342
      // @ts-expect-error
343
      return {
2✔
344
        ...filterProps,
345
        type: FILTER_TYPES.select,
346
        value: true,
347
        gpu: false
348
      };
349

350
    case ALL_FIELD_TYPES.string:
351
    case ALL_FIELD_TYPES.h3:
352
    case ALL_FIELD_TYPES.date:
353
      // @ts-expect-error
354
      return {
15✔
355
        ...filterProps,
356
        type: FILTER_TYPES.multiSelect,
357
        value: [],
358
        gpu: false
359
      };
360

361
    case ALL_FIELD_TYPES.timestamp:
362
      // @ts-expect-error
363
      return {
36✔
364
        ...filterProps,
365
        type: FILTER_TYPES.timeRange,
366
        view: FILTER_VIEW_TYPES.enlarged,
367
        fixedDomain: true,
368
        value: filterProps.domain,
369
        gpu: true,
370
        plotType: {}
371
      };
372

373
    default:
374
      // @ts-expect-error
375
      return {};
×
376
  }
377
}
378

379
export const getPolygonFilterFunctor = (layer, filter, dataContainer) => {
17✔
380
  const getPosition = layer.getPositionAccessor(dataContainer);
10✔
381

382
  switch (layer.type) {
10!
383
    case LAYER_TYPES.point:
384
    case LAYER_TYPES.icon:
385
      if (layer.config?.columnMode === 'geojson' && layer.dataToFeature?.length) {
9✔
386
        return data => {
1✔
387
          const coordinates = layer.dataToFeature[data.index];
6✔
388
          if (!coordinates) return false;
6!
389
          if (Array.isArray(coordinates[0])) {
6✔
390
            return (coordinates as number[][]).some(
3✔
391
              coord =>
392
                coord.length >= 2 && coord.every(Number.isFinite) && isInPolygon(coord, filter.value)
6✔
393
            );
394
          }
395
          return (
396
            coordinates.length >= 2 &&
397
            coordinates.every(Number.isFinite) &&
3✔
398
            isInPolygon(coordinates, filter.value)
7✔
399
          );
400
        };
401
      }
402
      return data => {
403
        const pos = getPosition(data);
404
        return pos.every(Number.isFinite) && isInPolygon(pos, filter.value);
8✔
405
      };
28✔
406
    case LAYER_TYPES.arc:
28✔
407
    case LAYER_TYPES.line:
408
      return data => {
409
        const pos = getPosition(data);
410
        return (
×
UNCOV
411
          pos.every(Number.isFinite) &&
×
UNCOV
412
          [
×
413
            [pos[0], pos[1]],
×
414
            [pos[3], pos[4]]
415
          ].every(point => isInPolygon(point, filter.value))
416
        );
UNCOV
417
      };
×
418
    case LAYER_TYPES.hexagonId:
419
      if (layer.dataToFeature && layer.dataToFeature.centroids) {
420
        return data => {
421
          // null or getCentroid({id})
1!
422
          const centroid = layer.dataToFeature.centroids[data.index];
1✔
423
          return centroid && isInPolygon(centroid, filter.value);
424
        };
9✔
425
      }
9✔
426
      return data => {
427
        const id = getPosition(data);
428
        if (!h3IsValid(id)) {
×
429
          return false;
×
UNCOV
430
        }
×
431
        const pos = getCentroid({id});
×
432
        return pos.every(Number.isFinite) && isInPolygon(pos, filter.value);
UNCOV
433
      };
×
UNCOV
434
    case LAYER_TYPES.geojson:
×
435
      return data => {
436
        return layer.isInPolygon(data, data.index, filter.value);
UNCOV
437
      };
×
UNCOV
438
    case LAYER_TYPES.heatmap:
×
439
      if (layer.centroids?.length) {
440
        return data => {
441
          const centroid = layer.centroids[data.index];
×
442
          return centroid && isInPolygon(centroid, filter.value);
×
UNCOV
443
        };
×
UNCOV
444
      }
×
445
      return data => {
446
        const pos = getPosition(data);
447
        return pos.every(Number.isFinite) && isInPolygon(pos, filter.value);
×
UNCOV
448
      };
×
UNCOV
449
    default:
×
450
      return () => true;
451
  }
UNCOV
452
};
×
453

454
/**
455
 * Check if a GeoJSON feature filter can be applied to a layer
456
 */
457
export function canApplyFeatureFilter(feature: Feature | null): boolean {
458
  return Boolean(feature?.geometry && ['Polygon', 'MultiPolygon'].includes(feature.geometry.type));
459
}
460

2✔
461
/**
462
 * @param param An object that represents a row record.
463
 * @param param.index Index of the row in data container.
464
 * @returns Returns true to keep the element, or false otherwise.
465
 */
466
type filterFunction = (data: {index: number}) => boolean;
467

468
/**
469
 * @param field dataset Field
470
 * @param dataId Dataset id
471
 * @param filter Filter object
472
 * @param layers list of layers to filter upon
473
 * @param dataContainer Data container
474
 * @return filterFunction
475
 */
476
/* eslint-disable complexity */
477
export function getFilterFunction<L extends {config: {dataId: string | null}; id: string}>(
478
  field: Field | null,
479
  dataId: string,
480
  filter: Filter,
481
  layers: L[],
482
  dataContainer: DataContainerInterface
483
): filterFunction {
484
  // field could be null in polygon filter
485
  const valueAccessor = field ? field.valueAccessor : () => null;
486
  const defaultFunc = () => true;
487

82✔
488
  if (filter.enabled === false) {
82✔
489
    return defaultFunc;
490
  }
82✔
491

1✔
492
  switch (filter.type) {
493
    case FILTER_TYPES.range:
494
      return data => isInRange(valueAccessor(data), filter.value);
81!
495
    case FILTER_TYPES.multiSelect:
496
      return data => filter.value.includes(valueAccessor(data));
263✔
497
    case FILTER_TYPES.select:
498
      return data => valueAccessor(data) === filter.value;
414✔
499
    case FILTER_TYPES.timeRange: {
500
      if (!field) {
32✔
501
        return defaultFunc;
502
      }
17!
UNCOV
503
      const mappedValue = get(field, ['filterProps', 'mappedValue']);
×
504
      const accessor = Array.isArray(mappedValue)
505
        ? data => mappedValue[data.index]
17✔
506
        : data => timeToUnixMilli(valueAccessor(data), field.format);
17✔
507
      return data => isInRange(accessor(data), filter.value);
48✔
508
    }
9✔
509
    case FILTER_TYPES.polygon: {
57✔
510
      if (!layers || !layers.length || !filter.layerId) {
511
        return defaultFunc;
512
      }
9✔
513
      const layerFilterFunctions = filter.layerId
1✔
514
        .map(id => layers.find(l => l.id === id))
515
        .filter(l => l && l.config.dataId === dataId)
8✔
516
        .map(layer => getPolygonFilterFunctor(layer, filter, dataContainer));
24✔
517

13✔
518
      return data => layerFilterFunctions.every(filterFunc => filterFunc(data));
8✔
519
    }
520
    default:
35✔
521
      return defaultFunc;
522
  }
UNCOV
523
}
×
524

525
export function updateFilterDataId(dataId: string | string[]): FilterBase<LineChart> {
526
  return getDefaultFilter({dataId});
527
}
UNCOV
528

×
529
export function filterDataByFilterTypes(
530
  {
531
    dynamicDomainFilters,
532
    cpuFilters,
533
    filterFuncs
534
  }: {
535
    dynamicDomainFilters: Filter[] | null;
536
    cpuFilters: Filter[] | null;
537
    filterFuncs: {
538
      [key: string]: filterFunction;
539
    };
540
  },
541
  dataContainer: DataContainerInterface
542
): FilterResult {
543
  const filteredIndexForDomain: number[] = [];
544
  const filteredIndex: number[] = [];
545

55✔
546
  const filterContext = {index: -1, dataContainer};
55✔
547
  const filterFuncCaller = (filter: Filter) => filterFuncs[filter.id](filterContext);
548

55✔
549
  const numRows = dataContainer.numRows();
794✔
550
  for (let i = 0; i < numRows; ++i) {
551
    filterContext.index = i;
55✔
552

55✔
553
    const matchForDomain = dynamicDomainFilters && dynamicDomainFilters.every(filterFuncCaller);
543✔
554
    if (matchForDomain) {
555
      filteredIndexForDomain.push(filterContext.index);
543✔
556
    }
543✔
557

226✔
558
    const matchForRender = cpuFilters && cpuFilters.every(filterFuncCaller);
559
    if (matchForRender) {
560
      filteredIndex.push(filterContext.index);
543✔
561
    }
543✔
562
  }
132✔
563

564
  return {
565
    ...(dynamicDomainFilters ? {filteredIndexForDomain} : {}),
566
    ...(cpuFilters ? {filteredIndex} : {})
55✔
567
  };
55✔
568
}
55✔
569

570
/**
571
 * Get a record of filters based on domain type and gpu / cpu
572
 */
573
export function getFilterRecord(
574
  dataId: string,
575
  filters: Filter[],
576
  opt: FilterDatasetOpt = {}
577
): FilterRecord {
578
  const filterRecord: FilterRecord = {
×
579
    dynamicDomain: [],
580
    fixedDomain: [],
121✔
581
    cpu: [],
582
    gpu: []
583
  };
584

585
  filters.forEach(f => {
586
    if (isValidFilterValue(f.type, f.value) && toArray(f.dataId).includes(dataId)) {
587
      (f.fixedDomain || opt.ignoreDomain
121✔
588
        ? filterRecord.fixedDomain
149✔
589
        : filterRecord.dynamicDomain
145✔
590
      ).push(f);
591

592
      (f.gpu && !opt.cpuOnly ? filterRecord.gpu : filterRecord.cpu).push(f);
593
    }
594
  });
145✔
595

596
  return filterRecord;
597
}
598

121✔
599
/**
600
 * Compare filter records to get what has changed
601
 */
602
export function diffFilters(
603
  filterRecord: FilterRecord,
604
  oldFilterRecord: FilterRecord | Record<string, never> = {}
605
): FilterChanged {
606
  let filterChanged: Partial<FilterChanged> = {};
63✔
607

608
  (Object.entries(filterRecord) as Entries<FilterRecord>).forEach(([record, items]) => {
120✔
609
    items.forEach(filter => {
610
      const oldFilter: Filter | undefined = (oldFilterRecord[record] || []).find(
120✔
611
        (f: Filter) => f.id === filter.id
480✔
612
      );
288✔
613

137✔
614
      if (!oldFilter) {
615
        // added
616
        filterChanged = set([record, filter.id], 'added', filterChanged);
288✔
617
      } else {
618
        // check  what has changed
172✔
619
        ['name', 'value', 'dataId'].forEach(prop => {
620
          if (filter[prop] !== oldFilter[prop]) {
621
            filterChanged = set([record, filter.id], `${prop}_changed`, filterChanged);
116✔
622
          }
348✔
623
        });
86✔
624
      }
625
    });
626

627
    (oldFilterRecord[record] || []).forEach((oldFilter: Filter) => {
628
      // deleted
629
      if (!items.find(f => f.id === oldFilter.id)) {
480✔
630
        filterChanged = set([record, oldFilter.id], 'deleted', filterChanged);
631
      }
132✔
632
    });
16✔
633
  });
634

635
  return {...{dynamicDomain: null, fixedDomain: null, cpu: null, gpu: null}, ...filterChanged};
636
}
637

120✔
638
/**
639
 * Call by parsing filters from URL
640
 * Check if value of filter within filter domain, if not adjust it to match
641
 * filter domain
642
 *
643
 * @returns value - adjusted value to match filter or null to remove filter
644
 */
645
// eslint-disable-next-line complexity
646
export function adjustValueToFilterDomain(value: Filter['value'], {domain, type}) {
647
  if (!type) {
648
    return false;
649
  }
63!
UNCOV
650
  // if the current filter is a polygon it will not have any domain
×
651
  // all other filter types require domain
652
  if (type !== FILTER_TYPES.polygon && !domain) {
653
    return false;
654
  }
63!
UNCOV
655

×
656
  switch (type) {
657
    case FILTER_TYPES.range:
658
    case FILTER_TYPES.timeRange:
63!
659
      if (!Array.isArray(value) || value.length !== 2) {
660
        return domain.map(d => d);
661
      }
46✔
662

8✔
663
      return value.map((d, i) => (notNullorUndefined(d) && isInRange(d, domain) ? d : domain[i]));
664

665
    case FILTER_TYPES.multiSelect: {
84✔
666
      if (!Array.isArray(value)) {
667
        return [];
668
      }
12✔
669
      const filteredValue = value.filter(d => domain.includes(d));
1✔
670
      return filteredValue.length ? filteredValue : [];
671
    }
25✔
672
    case FILTER_TYPES.select:
11✔
673
      return domain.includes(value) ? value : true;
674
    case FILTER_TYPES.polygon:
675
      return value;
5✔
676

UNCOV
677
    default:
×
678
      return null;
679
  }
UNCOV
680
}
×
681

682
/**
683
 * Calculate numeric domain and suitable step
684
 */
685
export function getNumericFieldDomain(
686
  dataContainer: DataContainerInterface,
687
  valueAccessor: dataValueAccessor
688
): RangeFieldDomain {
689
  let domain: [number, number] = [0, 1];
690
  let step = 0.1;
691

28✔
692
  const mappedValue = dataContainer.mapIndex(valueAccessor);
28✔
693

694
  if (dataContainer.numRows() > 1) {
28✔
695
    domain = ScaleUtils.getLinearDomain(mappedValue);
696
    const diff = domain[1] - domain[0];
28!
697

28✔
698
    // in case equal domain, [96, 96], which will break quantize scale
28✔
699
    if (!diff) {
700
      domain[1] = domain[0] + 1;
701
    }
28!
UNCOV
702

×
703
    step = getNumericStepSize(diff) || step;
704
    domain[0] = formatNumberByStep(domain[0], step, 'floor');
705
    domain[1] = formatNumberByStep(domain[1], step, 'ceil');
28!
706
  }
28✔
707

28✔
708
  return {domain, step};
709
}
710

28✔
711
/**
712
 * Calculate step size for range and timerange filter
713
 */
714
export function getNumericStepSize(diff: number): number {
715
  diff = Math.abs(diff);
716

717
  if (diff > 100) {
29✔
718
    return 1;
719
  } else if (diff > 3) {
29✔
720
    return 0.01;
3✔
721
  } else if (diff > 1) {
26✔
722
    return 0.001;
23✔
723
  }
3✔
724
  // Try to get at least 1000 steps - and keep the step size below that of
1✔
725
  // the (diff > 1) case.
726
  const x = diff / 1000;
727
  // Find the exponent and truncate to 10 to the power of that exponent
728

2✔
729
  const exponentialForm = x.toExponential();
730
  const exponent = parseFloat(exponentialForm.split('e')[1]);
731

2✔
732
  // Getting ready for node 12
2✔
733
  // this is why we need decimal.js
734
  // Math.pow(10, -5) = 0.000009999999999999999
735
  // the above result shows in browser and node 10
736
  // node 12 behaves correctly
737
  return new Decimal(10).pow(exponent).toNumber();
738
}
739

2✔
740
/**
741
 * Calculate timestamp domain and suitable step
742
 */
743
export function getTimestampFieldDomain(
744
  dataContainer: DataContainerInterface,
745
  valueAccessor: dataValueAccessor
746
): TimeRangeFieldDomain {
747
  // to avoid converting string format time to epoch
748
  // every time we compare we store a value mapped to int in filter domain
749
  const mappedValue = dataContainer.mapIndex(valueAccessor);
750

751
  const domain = ScaleUtils.getLinearDomain(mappedValue);
46✔
752
  const defaultTimeFormat = getTimeWidgetTitleFormatter(domain);
753

46✔
754
  let step = 0.01;
46✔
755

756
  const diff = domain[1] - domain[0];
46✔
757
  // in case equal timestamp add 1 second padding to prevent break
758
  if (!diff) {
46✔
759
    domain[1] = domain[0] + 1000;
760
  }
46✔
761
  const entry = TimestampStepMap.find(f => f.max >= diff);
1✔
762
  if (entry) {
763
    step = entry.step;
301✔
764
  }
46!
765

46✔
766
  return {
767
    domain,
768
    step,
46✔
769
    mappedValue,
770
    defaultTimeFormat
771
  };
772
}
773

774
/**
775
 * round number based on step
776
 *
777
 * @param {Number} val
778
 * @param {Number} step
779
 * @param {string} bound
780
 * @returns {Number} rounded number
781
 */
782
export function formatNumberByStep(val: number, step: number, bound: 'floor' | 'ceil'): number {
783
  if (bound === 'floor') {
784
    return Math.floor(val * (1 / step)) / (1 / step);
785
  }
58✔
786

29✔
787
  return Math.ceil(val * (1 / step)) / (1 / step);
788
}
789

29✔
790
export function isInRange(val: any, domain: number[]): boolean {
791
  if (!Array.isArray(domain)) {
792
    return false;
793
  }
439!
UNCOV
794

×
795
  if (Array.isArray(val)) {
796
    return domain[0] <= val[0] && val[1] <= domain[1];
797
  }
439!
UNCOV
798

×
799
  return val >= domain[0] && val <= domain[1];
800
}
801

439✔
802
/**
803
 * Determines whether a point is within the provided polygon
804
 *
805
 * @param point as input search [lat, lng]
806
 * @param polygon Points must be within these (Multi)Polygon(s)
807
 * @return {boolean}
808
 */
809
export function isInPolygon(point: number[], polygon: any): boolean {
810
  return booleanWithin(turfPoint(point), polygon);
811
}
812
export function getTimeWidgetTitleFormatter(domain: [number, number]): string | null {
45✔
813
  if (!isValidTimeDomain(domain)) {
814
    return null;
815
  }
64✔
816

2✔
817
  const diff = domain[1] - domain[0];
818

819
  // Local aware formats
62✔
820
  // https://momentjs.com/docs/#/parsing/string-format
821
  return diff > durationYear ? 'L' : diff > durationDay ? 'L LT' : 'L LTS';
822
}
823

62!
824
/**
825
 * Sanity check on filters to prepare for save
826
 * @type {typeof import('./filter-utils').isFilterValidToSave}
827
 */
828
export function isFilterValidToSave(filter: any): boolean {
829
  return (
830
    filter?.type && Array.isArray(filter?.name) && (filter?.name.length || filter?.layerId.length)
831
  );
45✔
832
}
133!
833

834
/**
835
 * Sanity check on filters to prepare for save
836
 * @type {typeof import('./filter-utils').isValidFilterValue}
837
 */
838
/* eslint-disable complexity */
839
export function isValidFilterValue(type: string | null, value: any): boolean {
840
  if (!type) {
841
    return false;
842
  }
162✔
843
  switch (type) {
1✔
844
    case FILTER_TYPES.select:
845
      return value === true || value === false;
161!
846

847
    case FILTER_TYPES.range:
4✔
848
    case FILTER_TYPES.timeRange:
849
      return Array.isArray(value) && value.every(v => v !== null && !isNaN(v));
850

851
    case FILTER_TYPES.multiSelect:
217✔
852
      return Array.isArray(value) && Boolean(value.length);
853

854
    case FILTER_TYPES.input:
33✔
855
      return Boolean(value.length);
856

UNCOV
857
    case FILTER_TYPES.polygon: {
×
858
      const coordinates = get(value, ['geometry', 'coordinates']);
859
      return Boolean(value && value.id && coordinates);
860
    }
13✔
861
    default:
13✔
862
      return true;
863
  }
UNCOV
864
}
×
865

866
export function getColumnFilterProps<K extends KeplerTableModel<K, L>, L>(
867
  filter: Filter,
868
  dataset: K
869
): {lineChart: LineChart; yAxs: Field} | Record<string, any> {
870
  if (filter.plotType?.type === PLOT_TYPES.histogram || !filter.yAxis) {
871
    // histogram should be calculated when create filter
872
    return {};
×
873
  }
UNCOV
874

×
875
  const {mappedValue = []} = filter;
876
  const {yAxis} = filter;
877
  const fieldIdx = dataset.getColumnFieldIdx(yAxis.name);
×
878
  if (fieldIdx < 0) {
×
UNCOV
879
    // Console.warn(`yAxis ${yAxis.name} does not exist in dataset`);
×
880
    return {lineChart: {}, yAxis};
×
881
  }
UNCOV
882

×
883
  // return lineChart
884
  const series = dataset.dataContainer
885
    .map(
886
      (row, rowIndex) => ({
×
887
        x: mappedValue[rowIndex],
UNCOV
888
        y: row.valueAt(fieldIdx)
×
889
      }),
890
      true
891
    )
892
    .filter(({x, y}) => Number.isFinite(x) && Number.isFinite(y))
893
    .sort((a, b) => ascending(a.x, b.x));
UNCOV
894

×
895
  const yDomain = extent(series, d => d.y) as [number, number];
×
896
  const xDomain = [series[0].x, series[series.length - 1].x] as [number, number];
UNCOV
897

×
898
  return {lineChart: {series, yDomain, xDomain}, yAxis};
×
899
}
UNCOV
900

×
901
export function updateFilterPlot<K extends KeplerTableModel<K, any>>(
902
  datasets: {[id: string]: K},
903
  filter: Filter,
904
  dataId: string | undefined = undefined
905
) {
906
  if (dataId) {
119✔
907
    filter = removeFilterPlot(filter, dataId);
908
  }
119!
UNCOV
909

×
910
  if (filter.type === FILTER_TYPES.timeRange) {
911
    return updateTimeFilterPlotType(filter as TimeRangeFilter, filter.plotType, datasets);
912
  } else if (filter.type === FILTER_TYPES.range) {
119✔
913
    return updateRangeFilterPlotType(filter as RangeFilter, filter.plotType, datasets);
52✔
914
  }
67✔
915
  return filter;
34✔
916
}
917

33✔
918
/**
919
 *
920
 * @param datasetIds list of dataset ids to be filtered
921
 * @param datasets all datasets
922
 * @param filters all filters to be applied to datasets
923
 * @return datasets - new updated datasets
924
 */
925
export function applyFiltersToDatasets<
926
  K extends KeplerTableModel<K, L>,
927
  L extends {config: {dataId: string | null}}
928
>(
929
  datasetIds: string[],
930
  datasets: {[id: string]: K},
931
  filters: Filter[],
932
  layers?: L[]
933
): {[id: string]: K} {
934
  const dataIds = toArray(datasetIds);
935
  return dataIds.reduce((acc, dataId) => {
936
    const layersToFilter = (layers || []).filter(l => l.config.dataId === dataId);
130✔
937
    const appliedFilters = filters.filter(d => shouldApplyFilter(d, dataId));
130✔
938
    const table = datasets[dataId];
281!
939

200✔
940
    return {
114✔
941
      ...acc,
942
      [dataId]: table.filterTable(appliedFilters, layersToFilter, {})
114✔
943
    };
944
  }, datasets);
945
}
946

947
/**
948
 * Applies a new field name value to filter and update both filter and dataset
949
 * @param filter - to be applied the new field name on
950
 * @param datasets - All datasets
951
 * @param datasetId - Id of the dataset the field belongs to
952
 * @param fieldName - field.name
953
 * @param filterDatasetIndex - field.name
954
 * @param option
955
 * @return - {filter, datasets}
956
 */
957
export function applyFilterFieldName<K extends KeplerTableModel<K, L>, L>(
958
  filter: Filter,
959
  datasets: Record<string, K>,
960
  datasetId: string,
961
  fieldName: string,
962
  filterDatasetIndex = 0,
963
  option?: {mergeDomain: boolean}
964
): {
1✔
965
  filter: Filter | null;
966
  dataset: K;
967
} {
968
  // using filterDatasetIndex we can filter only the specified dataset
969
  const mergeDomain =
970
    option && Object.prototype.hasOwnProperty.call(option, 'mergeDomain')
971
      ? option.mergeDomain
972
      : false;
80✔
973

974
  const dataset = datasets[datasetId];
975
  const fieldIndex = dataset?.getColumnFieldIdx(fieldName);
976
  // if no field with same name is found, move to the next datasets
80✔
977
  if (!dataset || fieldIndex === -1) {
80✔
978
    // throw new Error(`fieldIndex not found. Dataset must contain a property with name: ${fieldName}`);
979
    return {filter: null, dataset};
80✔
980
  }
981

1✔
982
  // TODO: validate field type
983
  const filterProps = dataset.getColumnFilterProps(fieldName);
984

985
  let newFilter = {
79✔
986
    ...filter,
987
    ...filterProps,
79✔
988
    name: Object.assign([...toArray(filter.name)], {[filterDatasetIndex]: fieldName}),
989
    fieldIdx: Object.assign([...toArray(filter.fieldIdx)], {
990
      [filterDatasetIndex]: fieldIndex
991
    }),
992
    // Make sure plotType is not overwritten by the default empty plotType
993
    ...(filter.plotType ? {plotType: filter.plotType} : {})
994
  };
995

79!
996
  if (mergeDomain) {
997
    const domainSteps: (Filter & {step?: number}) | null =
998
      mergeFilterDomain(newFilter, datasets) ?? ({} as Filter);
79✔
999
    if (domainSteps) {
1000
      const {domain, step} = domainSteps;
47!
1001
      newFilter.domain = domain;
47!
1002
      if (newFilter.step !== step) {
47✔
1003
        newFilter.step = step;
47✔
1004
      }
47!
UNCOV
1005
    }
×
1006
  }
1007

1008
  // TODO: if we don't set filter value in filterProps, we don't need to do this
1009
  if (filterDatasetIndex > 0) {
1010
    // don't reset the filter value if we are just adding a synced dataset
1011
    newFilter = {
79✔
1012
      ...newFilter,
1013
      value: filter.value
5✔
1014
    };
1015
  }
1016

1017
  return {
1018
    filter: newFilter,
1019
    dataset
79✔
1020
  };
1021
}
1022

1023
/**
1024
 * Merge the domains of a filter in case it applies to multiple datasets
1025
 */
1026
export function mergeFilterDomain(
1027
  filter: Filter,
1028
  datasets: Record<string, KeplerTableModel<any, any>>
1029
): (Filter & {step?: number}) | null {
1030
  let domainSteps: (Filter & {step?: number}) | null = null;
1031
  if (!filter?.dataId?.length) {
1032
    return filter;
49✔
1033
  }
49!
UNCOV
1034
  filter.dataId.forEach((filterDataId, idx) => {
×
1035
    const dataset = datasets[filterDataId];
1036
    const filterProps = dataset.getColumnFilterProps(filter.name[idx]);
49✔
1037
    domainSteps = mergeFilterDomainStep(domainSteps ?? ({} as Filter), filterProps);
56✔
1038
  });
56✔
1039
  return domainSteps;
56✔
1040
}
1041

49✔
1042
/**
1043
 * Merge one filter with other filter prop domain
1044
 */
1045
/* eslint-disable complexity */
1046
export function mergeFilterDomainStep(
1047
  filter: Filter | null,
1048
  filterProps?: Partial<Filter>
1049
): (Filter & {step?: number}) | null {
1050
  if (!filter) {
1051
    return null;
1052
  }
56!
UNCOV
1053

×
1054
  if (!filterProps) {
1055
    return filter;
1056
  }
56✔
1057

1✔
1058
  if ((filter.fieldType && filter.fieldType !== filterProps.fieldType) || !filterProps.domain) {
1059
    return filter;
1060
  }
55!
UNCOV
1061

×
1062
  const sortedDomain = !filter.domain
1063
    ? filterProps.domain
1064
    : [...(filter.domain || []), ...(filterProps.domain || [])].sort((a, b) => a - b);
55✔
1065

1066
  const newFilter = {
36!
1067
    ...filter,
1068
    ...filterProps,
55✔
1069
    // use min max as default domain
1070
    domain: [sortedDomain[0], sortedDomain[sortedDomain.length - 1]]
1071
  };
1072

1073
  switch (filterProps.fieldType) {
1074
    case ALL_FIELD_TYPES.string:
1075
    case ALL_FIELD_TYPES.h3:
55✔
1076
    case ALL_FIELD_TYPES.date:
1077
      return {
1078
        ...newFilter,
1079
        domain: unique(sortedDomain)
7✔
1080
      };
1081

1082
    case ALL_FIELD_TYPES.timestamp: {
1083
      const step =
1084
        (filter as TimeRangeFilter).step < (filterProps as TimeRangeFieldDomain).step
1085
          ? (filter as TimeRangeFilter).step
1086
          : (filterProps as TimeRangeFieldDomain).step;
34!
1087

1088
      return {
1089
        ...newFilter,
1090
        step
34✔
1091
      };
1092
    }
1093
    case ALL_FIELD_TYPES.real:
1094
    case ALL_FIELD_TYPES.integer:
1095
    default:
1096
      return newFilter;
1097
  }
1098
}
14✔
1099
/* eslint-enable complexity */
1100

1101
/**
1102
 * Generates polygon filter
1103
 */
1104
export const featureToFilterValue = (
1105
  feature: Feature,
1106
  filterId: string,
17✔
1107
  properties?: Record<string, any>
1108
): FeatureValue => ({
1109
  ...feature,
1110
  id: feature.id,
6✔
1111
  properties: {
1112
    ...feature.properties,
1113
    ...properties,
1114
    filterId
1115
  }
1116
});
1117

1118
export const getFilterIdInFeature = (f: FeatureValue): string => get(f, ['properties', 'filterId']);
1119

1120
/**
21✔
1121
 * Generates polygon filter
1122
 */
1123
export function generatePolygonFilter<
1124
  L extends {config: {dataId: string | null; label: string}; id: string}
1125
>(layers: L[], feature: Feature): PolygonFilter {
1126
  const dataId = layers.map(l => l.config.dataId).filter(notNullorUndefined);
1127
  const layerId = layers.map(l => l.id);
1128
  const name = layers.map(l => l.config.label);
5✔
1129
  const filter = getDefaultFilter({dataId});
5✔
1130
  return {
5✔
1131
    ...filter,
5✔
1132
    fixedDomain: true,
5✔
1133
    type: FILTER_TYPES.polygon,
1134
    name,
1135
    layerId,
1136
    value: featureToFilterValue(feature, filter.id, {isVisible: true})
1137
  };
1138
}
1139

1140
/**
1141
 * Run filter entirely on CPU
1142
 */
1143
interface StateType<K extends KeplerTableModel<K, L>, L> {
1144
  layers: L[];
1145
  filters: Filter[];
1146
  datasets: {[id: string]: K};
1147
}
1148

1149
export function filterDatasetCPU<T extends StateType<K, L>, K extends KeplerTableModel<K, L>, L>(
1150
  state: T,
1151
  dataId: string
1152
): T {
1153
  const datasetFilters = state.filters.filter(f => f.dataId.includes(dataId));
1154
  const dataset = state.datasets[dataId];
1155

12✔
1156
  if (!dataset) {
7✔
1157
    return state;
1158
  }
7!
UNCOV
1159

×
1160
  const cpuFilteredDataset = dataset.filterTableCPU(datasetFilters, state.layers);
1161

1162
  return set(['datasets', dataId], cpuFilteredDataset, state);
7✔
1163
}
1164

7✔
1165
/**
1166
 * Validate parsed filters with datasets and add filterProps to field
1167
 */
1168
type MinVisStateForFilter = Pick<VisState, 'layers' | 'datasets' | 'isMergingDatasets'>;
1169
export function validateFiltersUpdateDatasets<
1170
  S extends MinVisStateForFilter,
1171
  K extends KeplerTableModel<K, L>,
1172
  L extends {config: {dataId: string | null; label: string}; id: string}
1173
>(
1174
  state: S,
1175
  filtersToValidate: ParsedFilter[] = []
1176
): {
1177
  validated: Filter[];
×
1178
  failed: Filter[];
1179
  updatedDatasets: S['datasets'];
1180
} {
1181
  // TODO Better Typings here
1182
  const validated: any[] = [];
1183
  const failed: any[] = [];
1184
  const {datasets, layers} = state;
52✔
1185
  let updatedDatasets = datasets;
52✔
1186

52✔
1187
  // merge filters
52✔
1188
  filtersToValidate.forEach(filterToValidate => {
1189
    // we can only look for datasets define in the filter dataId
1190
    const datasetIds = toArray(filterToValidate.dataId);
52✔
1191

1192
    // we can merge a filter only if all datasets in filter.dataId are loaded
91✔
1193
    if (datasetIds.every(d => datasets[d] && !state.isMergingDatasets[d])) {
1194
      // all datasetIds in filter must be present the state datasets
1195
      const {validatedFilter, applyToDatasets, augmentedDatasets} = datasetIds.reduce<{
95✔
1196
        validatedFilter: Filter | null;
1197
        applyToDatasets: string[];
43✔
1198
        augmentedDatasets: {[datasetId: string]: any};
1199
      }>(
1200
        (acc, datasetId) => {
1201
          const dataset = updatedDatasets[datasetId];
1202
          const datasetLayers = layers.filter(l => l.config.dataId === dataset.id);
1203
          const toValidate = acc.validatedFilter || filterToValidate;
46✔
1204

78✔
1205
          const {filter: updatedFilter, dataset: updatedDataset} = validateFilterWithData(
46✔
1206
            {
1207
              ...updatedDatasets,
46✔
1208
              ...acc.augmentedDatasets,
1209
              [datasetId]: acc.augmentedDatasets[datasetId] || dataset
1210
            },
1211
            datasetId,
92✔
1212
            toValidate,
1213
            datasetLayers
1214
          );
1215

1216
          if (updatedFilter) {
1217
            // merge filter domain step
1218
            return {
46✔
1219
              validatedFilter: updatedFilter,
1220

45✔
1221
              applyToDatasets: [...acc.applyToDatasets, datasetId],
1222

1223
              augmentedDatasets: {
1224
                ...acc.augmentedDatasets,
1225
                [datasetId]: updatedDataset
1226
              }
1227
            };
1228
          }
1229

1230
          return acc;
1231
        },
1232
        {
1✔
1233
          validatedFilter: null,
1234
          applyToDatasets: [],
1235
          augmentedDatasets: {}
1236
        }
1237
      );
1238

1239
      if (validatedFilter && isEqual(datasetIds, applyToDatasets)) {
1240
        let domain = validatedFilter.domain;
1241
        if ((validatedFilter as TimeRangeFilter).syncedWithLayerTimeline) {
43✔
1242
          const animatableLayers = getAnimatableVisibleLayers(layers);
42✔
1243
          domain = mergeTimeDomains([
42✔
1244
            ...animatableLayers.map(l => l.config.animation.domain || [0, 0]),
1✔
1245
            validatedFilter.domain
1✔
UNCOV
1246
          ]);
×
1247
        }
1248

1249
        validatedFilter.value = adjustValueToFilterDomain(filterToValidate.value, {
1250
          ...validatedFilter,
1251
          domain
42✔
1252
        });
1253

1254
        validated.push(updateFilterPlot(datasets, validatedFilter));
1255
        updatedDatasets = {
1256
          ...updatedDatasets,
42✔
1257
          ...augmentedDatasets
42✔
1258
        };
1259
      } else {
1260
        failed.push(filterToValidate);
1261
      }
1262
    } else {
1✔
1263
      failed.push(filterToValidate);
1264
    }
1265
  });
48✔
1266

1267
  return {validated, failed, updatedDatasets};
1268
}
1269

52✔
1270
export function removeFilterPlot(filter: Filter, dataId: string) {
1271
  let nextFilter = filter;
1272

1273
  const rangeFilter = filter as RangeFilter;
35✔
1274
  if (rangeFilter.bins && rangeFilter.bins[dataId]) {
1275
    const {[dataId]: _delete, ...nextBins} = rangeFilter.bins;
35✔
1276
    nextFilter = {
35!
UNCOV
1277
      ...rangeFilter,
×
UNCOV
1278
      bins: nextBins
×
1279
    };
1280
  }
1281

1282
  const timeFilter = filter as TimeRangeFilter;
1283
  if (timeFilter.timeBins && timeFilter.timeBins[dataId]) {
1284
    const {[dataId]: __delete, ...nextTimeBins} = timeFilter.timeBins;
35✔
1285
    nextFilter = {
35✔
1286
      ...nextFilter,
2✔
1287
      timeBins: nextTimeBins
2✔
1288
    } as Filter;
1289
  }
1290

1291
  return nextFilter;
1292
}
1293

35✔
1294
export function isValidTimeDomain(domain) {
1295
  return Array.isArray(domain) && domain.every(Number.isFinite);
1296
}
1297

64✔
1298
export function getTimeWidgetHintFormatter(domain: [number, number]): string | undefined {
1299
  if (!isValidTimeDomain(domain)) {
1300
    return undefined;
UNCOV
1301
  }
×
UNCOV
1302

×
1303
  const diff = domain[1] - domain[0];
1304
  return diff > durationWeek
UNCOV
1305
    ? 'L'
×
UNCOV
1306
    : diff > durationDay
×
1307
    ? 'L LT'
1308
    : diff > durationHour
×
1309
    ? 'LT'
1310
    : 'LTS';
×
1311
}
1312

1313
export function isSideFilter(filter: Filter): boolean {
1314
  return filter.view === FILTER_VIEW_TYPES.side;
1315
}
1316

2✔
1317
export function mergeTimeDomains(domains: ([number, number] | null)[]): [number, number] {
1318
  return domains.reduce(
1319
    (acc: [number, number], domain) => [
1320
      Math.min(acc[0], domain?.[0] ?? Infinity),
24✔
1321
      Math.max(acc[1], domain?.[1] ?? -Infinity)
29✔
1322
    ],
29!
1323
    [Number(Infinity), -Infinity]
29!
1324
  ) as [number, number];
1325
}
1326

1327
/**
1328
 * @param {Layer} layer
1329
 */
1330
export function isLayerAnimatable(layer: any): boolean {
1331
  return layer.config.animation?.enabled && Array.isArray(layer.config.animation.domain);
1332
}
1333

413✔
1334
/**
1335
 * @param {Layer[]} layers
1336
 * @returns {Layer[]}
1337
 */
1338
export function getAnimatableVisibleLayers(layers: any[]): any[] {
1339
  return layers.filter(l => isLayerAnimatable(l) && l.config.isVisible);
1340
}
1341

408✔
1342
/**
1343
 * @param {Layer[]} layers
1344
 * @param {string} type
1345
 * @returns {Layer[]}
1346
 */
1347
export function getAnimatableVisibleLayersByType(layers: any[], type: string): any[] {
1348
  return getAnimatableVisibleLayers(layers).filter(l => l.type === type);
1349
}
UNCOV
1350

×
1351
/**
1352
 * @param {Layer[]} layers
1353
 * @returns {Layer[]}
1354
 */
1355
export function getIntervalBasedAnimationLayers(layers: any[]): any[] {
1356
  // @ts-ignore
1357
  return getAnimatableVisibleLayers(layers).filter(l => l.config.animation?.timeSteps);
1358
}
1359

6✔
1360
export function mergeFilterWithTimeline(
1361
  filter: TimeRangeFilter,
1362
  animationConfig: AnimationConfig
1363
): {filter: TimeRangeFilter; animationConfig: AnimationConfig} {
1364
  if (
1365
    filter?.type === FILTER_TYPES.timeRange &&
1366
    filter.syncedWithLayerTimeline &&
1!
1367
    animationConfig &&
4✔
1368
    Array.isArray(animationConfig.domain)
1369
  ) {
1370
    const domain = mergeTimeDomains([filter.domain, animationConfig.domain as [number, number]]);
1371
    return {
1372
      filter: {
1✔
1373
        ...filter,
1✔
1374
        domain
1375
      },
1376
      animationConfig: {
1377
        ...animationConfig,
1378
        domain
1379
      }
1380
    };
1381
  }
1382
  return {filter, animationConfig};
1383
}
UNCOV
1384

×
1385
export function scaleSourceDomainToDestination(
1386
  sourceDomain: [number, number],
1387
  destinationDomain: [number, number]
1388
): [number, number] {
1389
  // 0 -> 100: merged domains t1 - t0 === 100% filter may already have this info which is good
1390
  const sourceDomainSize = sourceDomain[1] - sourceDomain[0];
1391
  // 10 -> 20: animationConfig domain d1 - d0 === animationConfig size
1392
  const destinationDomainSize = destinationDomain[1] - destinationDomain[0];
1✔
1393
  // scale animationConfig size using domain size
1394
  const scaledSourceDomainSize = (sourceDomainSize / destinationDomainSize) * 100;
1✔
1395
  // scale d0 - t0 using domain size to find starting point
1396
  const offset = sourceDomain[0] - destinationDomain[0];
1✔
1397
  const scaledOffset = (offset / destinationDomainSize) * 100;
1398
  return [scaledOffset, scaledSourceDomainSize + scaledOffset];
1✔
1399
}
1✔
1400

1✔
1401
export function getFilterScaledTimeline(filter, animationConfig): [number, number] | [] {
1402
  if (!(filter.syncedWithLayerTimeline && animationConfig?.domain)) {
1403
    return [];
UNCOV
1404
  }
×
UNCOV
1405

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