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

keplergl / kepler.gl / 25884645943

14 May 2026 08:43PM UTC coverage: 57.684% (-1.0%) from 58.684%
25884645943

push

github

web-flow
feat: basic annotations (#3434)

* feat: basic annotations

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

* fixes and improvements

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

* fix annotations lag

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

* tests, lint, fixes

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

* formatting/prettier

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

* update icon from target to letters

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

* fix tests

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

* fixes

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

* fix dragging

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

* fixes

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

* fixes

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

* fixes

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

* follow up

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

* fixes; follow ups

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

---------

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

7158 of 14867 branches covered (48.15%)

Branch coverage included in aggregate %.

217 of 737 new or added lines in 25 files covered. (29.44%)

70 existing lines in 2 files now uncovered.

14556 of 22776 relevant lines covered (63.91%)

77.67 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 &&
6✔
393
                coord.every(Number.isFinite) &&
394
                isInPolygon(coord, filter.value)
395
            );
396
          }
397
          return (
3✔
398
            coordinates.length >= 2 &&
7✔
399
            coordinates.every(Number.isFinite) &&
400
            isInPolygon(coordinates, filter.value)
401
          );
402
        };
403
      }
404
      return data => {
8✔
405
        const pos = getPosition(data);
28✔
406
        return pos.every(Number.isFinite) && isInPolygon(pos, filter.value);
28✔
407
      };
408
    case LAYER_TYPES.arc:
409
    case LAYER_TYPES.line:
UNCOV
410
      return data => {
×
UNCOV
411
        const pos = getPosition(data);
×
412
        return (
×
413
          pos.every(Number.isFinite) &&
×
414
          [
415
            [pos[0], pos[1]],
416
            [pos[3], pos[4]]
UNCOV
417
          ].every(point => isInPolygon(point, filter.value))
×
418
        );
419
      };
420
    case LAYER_TYPES.hexagonId:
421
      if (layer.dataToFeature && layer.dataToFeature.centroids) {
1!
422
        return data => {
1✔
423
          // null or getCentroid({id})
424
          const centroid = layer.dataToFeature.centroids[data.index];
9✔
425
          return centroid && isInPolygon(centroid, filter.value);
9✔
426
        };
427
      }
UNCOV
428
      return data => {
×
UNCOV
429
        const id = getPosition(data);
×
430
        if (!h3IsValid(id)) {
×
431
          return false;
×
432
        }
433
        const pos = getCentroid({id});
×
UNCOV
434
        return pos.every(Number.isFinite) && isInPolygon(pos, filter.value);
×
435
      };
436
    case LAYER_TYPES.geojson:
UNCOV
437
      return data => {
×
UNCOV
438
        return layer.isInPolygon(data, data.index, filter.value);
×
439
      };
440
    case LAYER_TYPES.heatmap:
UNCOV
441
      if (layer.centroids?.length) {
×
UNCOV
442
        return data => {
×
443
          const centroid = layer.centroids[data.index];
×
444
          return centroid && isInPolygon(centroid, filter.value);
×
445
        };
446
      }
UNCOV
447
      return data => {
×
UNCOV
448
        const pos = getPosition(data);
×
449
        return pos.every(Number.isFinite) && isInPolygon(pos, filter.value);
×
450
      };
451
    default:
UNCOV
452
      return () => true;
×
453
  }
454
};
455

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

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

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

490
  if (filter.enabled === false) {
82✔
491
    return defaultFunc;
1✔
492
  }
493

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

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

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

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

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

551
  const numRows = dataContainer.numRows();
55✔
552
  for (let i = 0; i < numRows; ++i) {
55✔
553
    filterContext.index = i;
543✔
554

555
    const matchForDomain = dynamicDomainFilters && dynamicDomainFilters.every(filterFuncCaller);
543✔
556
    if (matchForDomain) {
543✔
557
      filteredIndexForDomain.push(filterContext.index);
226✔
558
    }
559

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

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

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

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

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

598
  return filterRecord;
121✔
599
}
600

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

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

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

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

637
  return {...{dynamicDomain: null, fixedDomain: null, cpu: null, gpu: null}, ...filterChanged};
120✔
638
}
639

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

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

665
      return value.map((d, i) => (notNullorUndefined(d) && isInRange(d, domain) ? d : domain[i]));
84✔
666

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

679
    default:
UNCOV
680
      return null;
×
681
  }
682
}
683

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

694
  const mappedValue = dataContainer.mapIndex(valueAccessor);
28✔
695

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

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

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

710
  return {domain, step};
28✔
711
}
712

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

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

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

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

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

753
  const domain = ScaleUtils.getLinearDomain(mappedValue);
46✔
754
  const defaultTimeFormat = getTimeWidgetTitleFormatter(domain);
46✔
755

756
  let step = 0.01;
46✔
757

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

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

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

789
  return Math.ceil(val * (1 / step)) / (1 / step);
32✔
790
}
791

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

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

801
  return val >= domain[0] && val <= domain[1];
439✔
802
}
803

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

819
  const diff = domain[1] - domain[0];
62✔
820

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

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

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

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

853
    case FILTER_TYPES.multiSelect:
854
      return Array.isArray(value) && Boolean(value.length);
33✔
855

856
    case FILTER_TYPES.input:
UNCOV
857
      return Boolean(value.length);
×
858

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

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

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

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

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

900
  return {lineChart: {series, yDomain, xDomain}, yAxis};
×
901
}
902

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

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

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

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

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

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

984
  // TODO: validate field type
985
  const filterProps = dataset.getColumnFilterProps(fieldName);
79✔
986

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

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

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

1019
  return {
79✔
1020
    filter: newFilter,
1021
    dataset
1022
  };
1023
}
1024

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

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

1056
  if (!filterProps) {
56✔
1057
    return filter;
1✔
1058
  }
1059

1060
  if ((filter.fieldType && filter.fieldType !== filterProps.fieldType) || !filterProps.domain) {
55!
UNCOV
1061
    return filter;
×
1062
  }
1063

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

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

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

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

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

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

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

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

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

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

1158
  if (!dataset) {
7!
UNCOV
1159
    return state;
×
1160
  }
1161

1162
  const cpuFilteredDataset = dataset.filterTableCPU(datasetFilters, state.layers);
7✔
1163

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

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

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

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

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

1218
          if (updatedFilter) {
46✔
1219
            // merge filter domain step
1220
            return {
45✔
1221
              validatedFilter: updatedFilter,
1222

1223
              applyToDatasets: [...acc.applyToDatasets, datasetId],
1224

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

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

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

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

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

1269
  return {validated, failed, updatedDatasets};
52✔
1270
}
1271

1272
export function removeFilterPlot(filter: Filter, dataId: string) {
1273
  let nextFilter = filter;
35✔
1274

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

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

1293
  return nextFilter;
35✔
1294
}
1295

1296
export function isValidTimeDomain(domain) {
1297
  return Array.isArray(domain) && domain.every(Number.isFinite);
64✔
1298
}
1299

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

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

1315
export function isSideFilter(filter: Filter): boolean {
1316
  return filter.view === FILTER_VIEW_TYPES.side;
2✔
1317
}
1318

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

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

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

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

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

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

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

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

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