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

keplergl / kepler.gl / 25191011901

30 Apr 2026 09:48PM UTC coverage: 59.174% (-0.2%) from 59.41%
25191011901

push

github

web-flow
feat: add COLUMN_MODE_GEOJSON to heatmap layer (#3397)

* feat: add COLUMN_MODE_GEOJSON to heatmap layer

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

* enable polygon filter for heatmap layer

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

* follow up

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>

6916 of 14046 branches covered (49.24%)

Branch coverage included in aggregate %.

32 of 119 new or added lines in 3 files covered. (26.89%)

1 existing line in 1 file now uncovered.

14273 of 21762 relevant lines covered (65.59%)

80.05 hits per line

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

77.16
/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);
8✔
381

382
  switch (layer.type) {
8!
383
    case LAYER_TYPES.point:
384
    case LAYER_TYPES.icon:
385
      return data => {
7✔
386
        const pos = getPosition(data);
26✔
387
        return pos.every(Number.isFinite) && isInPolygon(pos, filter.value);
26✔
388
      };
389
    case LAYER_TYPES.arc:
390
    case LAYER_TYPES.line:
391
      return data => {
×
392
        const pos = getPosition(data);
×
393
        return (
×
394
          pos.every(Number.isFinite) &&
×
395
          [
396
            [pos[0], pos[1]],
397
            [pos[3], pos[4]]
398
          ].every(point => isInPolygon(point, filter.value))
×
399
        );
400
      };
401
    case LAYER_TYPES.hexagonId:
402
      if (layer.dataToFeature && layer.dataToFeature.centroids) {
1!
403
        return data => {
1✔
404
          // null or getCentroid({id})
405
          const centroid = layer.dataToFeature.centroids[data.index];
9✔
406
          return centroid && isInPolygon(centroid, filter.value);
9✔
407
        };
408
      }
409
      return data => {
×
410
        const id = getPosition(data);
×
411
        if (!h3IsValid(id)) {
×
412
          return false;
×
413
        }
414
        const pos = getCentroid({id});
×
415
        return pos.every(Number.isFinite) && isInPolygon(pos, filter.value);
×
416
      };
417
    case LAYER_TYPES.geojson:
418
      return data => {
×
419
        return layer.isInPolygon(data, data.index, filter.value);
×
420
      };
421
    case LAYER_TYPES.heatmap:
NEW
422
      if (layer.centroids?.length) {
×
NEW
423
        return data => {
×
NEW
424
          const centroid = layer.centroids[data.index];
×
NEW
425
          return centroid && isInPolygon(centroid, filter.value);
×
426
        };
427
      }
NEW
428
      return data => {
×
NEW
429
        const pos = getPosition(data);
×
NEW
430
        return pos.every(Number.isFinite) && isInPolygon(pos, filter.value);
×
431
      };
432
    default:
433
      return () => true;
×
434
  }
435
};
436

437
/**
438
 * Check if a GeoJSON feature filter can be applied to a layer
439
 */
440
export function canApplyFeatureFilter(feature: Feature | null): boolean {
441
  return Boolean(feature?.geometry && ['Polygon', 'MultiPolygon'].includes(feature.geometry.type));
2✔
442
}
443

444
/**
445
 * @param param An object that represents a row record.
446
 * @param param.index Index of the row in data container.
447
 * @returns Returns true to keep the element, or false otherwise.
448
 */
449
type filterFunction = (data: {index: number}) => boolean;
450

451
/**
452
 * @param field dataset Field
453
 * @param dataId Dataset id
454
 * @param filter Filter object
455
 * @param layers list of layers to filter upon
456
 * @param dataContainer Data container
457
 * @return filterFunction
458
 */
459
/* eslint-disable complexity */
460
export function getFilterFunction<L extends {config: {dataId: string | null}; id: string}>(
461
  field: Field | null,
462
  dataId: string,
463
  filter: Filter,
464
  layers: L[],
465
  dataContainer: DataContainerInterface
466
): filterFunction {
467
  // field could be null in polygon filter
468
  const valueAccessor = field ? field.valueAccessor : () => null;
82✔
469
  const defaultFunc = () => true;
82✔
470

471
  if (filter.enabled === false) {
82✔
472
    return defaultFunc;
1✔
473
  }
474

475
  switch (filter.type) {
81!
476
    case FILTER_TYPES.range:
477
      return data => isInRange(valueAccessor(data), filter.value);
263✔
478
    case FILTER_TYPES.multiSelect:
479
      return data => filter.value.includes(valueAccessor(data));
414✔
480
    case FILTER_TYPES.select:
481
      return data => valueAccessor(data) === filter.value;
32✔
482
    case FILTER_TYPES.timeRange: {
483
      if (!field) {
17!
484
        return defaultFunc;
×
485
      }
486
      const mappedValue = get(field, ['filterProps', 'mappedValue']);
17✔
487
      const accessor = Array.isArray(mappedValue)
17✔
488
        ? data => mappedValue[data.index]
48✔
489
        : data => timeToUnixMilli(valueAccessor(data), field.format);
9✔
490
      return data => isInRange(accessor(data), filter.value);
57✔
491
    }
492
    case FILTER_TYPES.polygon: {
493
      if (!layers || !layers.length || !filter.layerId) {
9✔
494
        return defaultFunc;
1✔
495
      }
496
      const layerFilterFunctions = filter.layerId
8✔
497
        .map(id => layers.find(l => l.id === id))
24✔
498
        .filter(l => l && l.config.dataId === dataId)
13✔
499
        .map(layer => getPolygonFilterFunctor(layer, filter, dataContainer));
8✔
500

501
      return data => layerFilterFunctions.every(filterFunc => filterFunc(data));
35✔
502
    }
503
    default:
504
      return defaultFunc;
×
505
  }
506
}
507

508
export function updateFilterDataId(dataId: string | string[]): FilterBase<LineChart> {
509
  return getDefaultFilter({dataId});
×
510
}
511

512
export function filterDataByFilterTypes(
513
  {
514
    dynamicDomainFilters,
515
    cpuFilters,
516
    filterFuncs
517
  }: {
518
    dynamicDomainFilters: Filter[] | null;
519
    cpuFilters: Filter[] | null;
520
    filterFuncs: {
521
      [key: string]: filterFunction;
522
    };
523
  },
524
  dataContainer: DataContainerInterface
525
): FilterResult {
526
  const filteredIndexForDomain: number[] = [];
55✔
527
  const filteredIndex: number[] = [];
55✔
528

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

532
  const numRows = dataContainer.numRows();
55✔
533
  for (let i = 0; i < numRows; ++i) {
55✔
534
    filterContext.index = i;
543✔
535

536
    const matchForDomain = dynamicDomainFilters && dynamicDomainFilters.every(filterFuncCaller);
543✔
537
    if (matchForDomain) {
543✔
538
      filteredIndexForDomain.push(filterContext.index);
226✔
539
    }
540

541
    const matchForRender = cpuFilters && cpuFilters.every(filterFuncCaller);
543✔
542
    if (matchForRender) {
543✔
543
      filteredIndex.push(filterContext.index);
132✔
544
    }
545
  }
546

547
  return {
55✔
548
    ...(dynamicDomainFilters ? {filteredIndexForDomain} : {}),
55✔
549
    ...(cpuFilters ? {filteredIndex} : {})
55✔
550
  };
551
}
552

553
/**
554
 * Get a record of filters based on domain type and gpu / cpu
555
 */
556
export function getFilterRecord(
557
  dataId: string,
558
  filters: Filter[],
559
  opt: FilterDatasetOpt = {}
×
560
): FilterRecord {
561
  const filterRecord: FilterRecord = {
121✔
562
    dynamicDomain: [],
563
    fixedDomain: [],
564
    cpu: [],
565
    gpu: []
566
  };
567

568
  filters.forEach(f => {
121✔
569
    if (isValidFilterValue(f.type, f.value) && toArray(f.dataId).includes(dataId)) {
149✔
570
      (f.fixedDomain || opt.ignoreDomain
145✔
571
        ? filterRecord.fixedDomain
572
        : filterRecord.dynamicDomain
573
      ).push(f);
574

575
      (f.gpu && !opt.cpuOnly ? filterRecord.gpu : filterRecord.cpu).push(f);
145✔
576
    }
577
  });
578

579
  return filterRecord;
121✔
580
}
581

582
/**
583
 * Compare filter records to get what has changed
584
 */
585
export function diffFilters(
586
  filterRecord: FilterRecord,
587
  oldFilterRecord: FilterRecord | Record<string, never> = {}
63✔
588
): FilterChanged {
589
  let filterChanged: Partial<FilterChanged> = {};
120✔
590

591
  (Object.entries(filterRecord) as Entries<FilterRecord>).forEach(([record, items]) => {
120✔
592
    items.forEach(filter => {
480✔
593
      const oldFilter: Filter | undefined = (oldFilterRecord[record] || []).find(
288✔
594
        (f: Filter) => f.id === filter.id
137✔
595
      );
596

597
      if (!oldFilter) {
288✔
598
        // added
599
        filterChanged = set([record, filter.id], 'added', filterChanged);
172✔
600
      } else {
601
        // check  what has changed
602
        ['name', 'value', 'dataId'].forEach(prop => {
116✔
603
          if (filter[prop] !== oldFilter[prop]) {
348✔
604
            filterChanged = set([record, filter.id], `${prop}_changed`, filterChanged);
86✔
605
          }
606
        });
607
      }
608
    });
609

610
    (oldFilterRecord[record] || []).forEach((oldFilter: Filter) => {
480✔
611
      // deleted
612
      if (!items.find(f => f.id === oldFilter.id)) {
132✔
613
        filterChanged = set([record, oldFilter.id], 'deleted', filterChanged);
16✔
614
      }
615
    });
616
  });
617

618
  return {...{dynamicDomain: null, fixedDomain: null, cpu: null, gpu: null}, ...filterChanged};
120✔
619
}
620

621
/**
622
 * Call by parsing filters from URL
623
 * Check if value of filter within filter domain, if not adjust it to match
624
 * filter domain
625
 *
626
 * @returns value - adjusted value to match filter or null to remove filter
627
 */
628
// eslint-disable-next-line complexity
629
export function adjustValueToFilterDomain(value: Filter['value'], {domain, type}) {
630
  if (!type) {
63!
631
    return false;
×
632
  }
633
  // if the current filter is a polygon it will not have any domain
634
  // all other filter types require domain
635
  if (type !== FILTER_TYPES.polygon && !domain) {
63!
636
    return false;
×
637
  }
638

639
  switch (type) {
63!
640
    case FILTER_TYPES.range:
641
    case FILTER_TYPES.timeRange:
642
      if (!Array.isArray(value) || value.length !== 2) {
46✔
643
        return domain.map(d => d);
8✔
644
      }
645

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

648
    case FILTER_TYPES.multiSelect: {
649
      if (!Array.isArray(value)) {
12✔
650
        return [];
1✔
651
      }
652
      const filteredValue = value.filter(d => domain.includes(d));
25✔
653
      return filteredValue.length ? filteredValue : [];
11✔
654
    }
655
    case FILTER_TYPES.select:
656
      return domain.includes(value) ? value : true;
5✔
657
    case FILTER_TYPES.polygon:
658
      return value;
×
659

660
    default:
661
      return null;
×
662
  }
663
}
664

665
/**
666
 * Calculate numeric domain and suitable step
667
 */
668
export function getNumericFieldDomain(
669
  dataContainer: DataContainerInterface,
670
  valueAccessor: dataValueAccessor
671
): RangeFieldDomain {
672
  let domain: [number, number] = [0, 1];
28✔
673
  let step = 0.1;
28✔
674

675
  const mappedValue = dataContainer.mapIndex(valueAccessor);
28✔
676

677
  if (dataContainer.numRows() > 1) {
28!
678
    domain = ScaleUtils.getLinearDomain(mappedValue);
28✔
679
    const diff = domain[1] - domain[0];
28✔
680

681
    // in case equal domain, [96, 96], which will break quantize scale
682
    if (!diff) {
28!
683
      domain[1] = domain[0] + 1;
×
684
    }
685

686
    step = getNumericStepSize(diff) || step;
28!
687
    domain[0] = formatNumberByStep(domain[0], step, 'floor');
28✔
688
    domain[1] = formatNumberByStep(domain[1], step, 'ceil');
28✔
689
  }
690

691
  return {domain, step};
28✔
692
}
693

694
/**
695
 * Calculate step size for range and timerange filter
696
 */
697
export function getNumericStepSize(diff: number): number {
698
  diff = Math.abs(diff);
29✔
699

700
  if (diff > 100) {
29✔
701
    return 1;
3✔
702
  } else if (diff > 3) {
26✔
703
    return 0.01;
23✔
704
  } else if (diff > 1) {
3✔
705
    return 0.001;
1✔
706
  }
707
  // Try to get at least 1000 steps - and keep the step size below that of
708
  // the (diff > 1) case.
709
  const x = diff / 1000;
2✔
710
  // Find the exponent and truncate to 10 to the power of that exponent
711

712
  const exponentialForm = x.toExponential();
2✔
713
  const exponent = parseFloat(exponentialForm.split('e')[1]);
2✔
714

715
  // Getting ready for node 12
716
  // this is why we need decimal.js
717
  // Math.pow(10, -5) = 0.000009999999999999999
718
  // the above result shows in browser and node 10
719
  // node 12 behaves correctly
720
  return new Decimal(10).pow(exponent).toNumber();
2✔
721
}
722

723
/**
724
 * Calculate timestamp domain and suitable step
725
 */
726
export function getTimestampFieldDomain(
727
  dataContainer: DataContainerInterface,
728
  valueAccessor: dataValueAccessor
729
): TimeRangeFieldDomain {
730
  // to avoid converting string format time to epoch
731
  // every time we compare we store a value mapped to int in filter domain
732
  const mappedValue = dataContainer.mapIndex(valueAccessor);
46✔
733

734
  const domain = ScaleUtils.getLinearDomain(mappedValue);
46✔
735
  const defaultTimeFormat = getTimeWidgetTitleFormatter(domain);
46✔
736

737
  let step = 0.01;
46✔
738

739
  const diff = domain[1] - domain[0];
46✔
740
  // in case equal timestamp add 1 second padding to prevent break
741
  if (!diff) {
46✔
742
    domain[1] = domain[0] + 1000;
1✔
743
  }
744
  const entry = TimestampStepMap.find(f => f.max >= diff);
301✔
745
  if (entry) {
46!
746
    step = entry.step;
46✔
747
  }
748

749
  return {
46✔
750
    domain,
751
    step,
752
    mappedValue,
753
    defaultTimeFormat
754
  };
755
}
756

757
/**
758
 * round number based on step
759
 *
760
 * @param {Number} val
761
 * @param {Number} step
762
 * @param {string} bound
763
 * @returns {Number} rounded number
764
 */
765
export function formatNumberByStep(val: number, step: number, bound: 'floor' | 'ceil'): number {
766
  if (bound === 'floor') {
58✔
767
    return Math.floor(val * (1 / step)) / (1 / step);
29✔
768
  }
769

770
  return Math.ceil(val * (1 / step)) / (1 / step);
29✔
771
}
772

773
export function isInRange(val: any, domain: number[]): boolean {
774
  if (!Array.isArray(domain)) {
439!
775
    return false;
×
776
  }
777

778
  if (Array.isArray(val)) {
439!
779
    return domain[0] <= val[0] && val[1] <= domain[1];
×
780
  }
781

782
  return val >= domain[0] && val <= domain[1];
439✔
783
}
784

785
/**
786
 * Determines whether a point is within the provided polygon
787
 *
788
 * @param point as input search [lat, lng]
789
 * @param polygon Points must be within these (Multi)Polygon(s)
790
 * @return {boolean}
791
 */
792
export function isInPolygon(point: number[], polygon: any): boolean {
793
  return booleanWithin(turfPoint(point), polygon);
36✔
794
}
795
export function getTimeWidgetTitleFormatter(domain: [number, number]): string | null {
796
  if (!isValidTimeDomain(domain)) {
64✔
797
    return null;
2✔
798
  }
799

800
  const diff = domain[1] - domain[0];
62✔
801

802
  // Local aware formats
803
  // https://momentjs.com/docs/#/parsing/string-format
804
  return diff > durationYear ? 'L' : diff > durationDay ? 'L LT' : 'L LTS';
62!
805
}
806

807
/**
808
 * Sanity check on filters to prepare for save
809
 * @type {typeof import('./filter-utils').isFilterValidToSave}
810
 */
811
export function isFilterValidToSave(filter: any): boolean {
812
  return (
45✔
813
    filter?.type && Array.isArray(filter?.name) && (filter?.name.length || filter?.layerId.length)
133!
814
  );
815
}
816

817
/**
818
 * Sanity check on filters to prepare for save
819
 * @type {typeof import('./filter-utils').isValidFilterValue}
820
 */
821
/* eslint-disable complexity */
822
export function isValidFilterValue(type: string | null, value: any): boolean {
823
  if (!type) {
162✔
824
    return false;
1✔
825
  }
826
  switch (type) {
161!
827
    case FILTER_TYPES.select:
828
      return value === true || value === false;
4✔
829

830
    case FILTER_TYPES.range:
831
    case FILTER_TYPES.timeRange:
832
      return Array.isArray(value) && value.every(v => v !== null && !isNaN(v));
217✔
833

834
    case FILTER_TYPES.multiSelect:
835
      return Array.isArray(value) && Boolean(value.length);
33✔
836

837
    case FILTER_TYPES.input:
838
      return Boolean(value.length);
×
839

840
    case FILTER_TYPES.polygon: {
841
      const coordinates = get(value, ['geometry', 'coordinates']);
13✔
842
      return Boolean(value && value.id && coordinates);
13✔
843
    }
844
    default:
845
      return true;
×
846
  }
847
}
848

849
export function getColumnFilterProps<K extends KeplerTableModel<K, L>, L>(
850
  filter: Filter,
851
  dataset: K
852
): {lineChart: LineChart; yAxs: Field} | Record<string, any> {
853
  if (filter.plotType?.type === PLOT_TYPES.histogram || !filter.yAxis) {
×
854
    // histogram should be calculated when create filter
855
    return {};
×
856
  }
857

858
  const {mappedValue = []} = filter;
×
859
  const {yAxis} = filter;
×
860
  const fieldIdx = dataset.getColumnFieldIdx(yAxis.name);
×
861
  if (fieldIdx < 0) {
×
862
    // Console.warn(`yAxis ${yAxis.name} does not exist in dataset`);
863
    return {lineChart: {}, yAxis};
×
864
  }
865

866
  // return lineChart
867
  const series = dataset.dataContainer
×
868
    .map(
869
      (row, rowIndex) => ({
×
870
        x: mappedValue[rowIndex],
871
        y: row.valueAt(fieldIdx)
872
      }),
873
      true
874
    )
875
    .filter(({x, y}) => Number.isFinite(x) && Number.isFinite(y))
×
876
    .sort((a, b) => ascending(a.x, b.x));
×
877

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

881
  return {lineChart: {series, yDomain, xDomain}, yAxis};
×
882
}
883

884
export function updateFilterPlot<K extends KeplerTableModel<K, any>>(
885
  datasets: {[id: string]: K},
886
  filter: Filter,
887
  dataId: string | undefined = undefined
119✔
888
) {
889
  if (dataId) {
119!
890
    filter = removeFilterPlot(filter, dataId);
×
891
  }
892

893
  if (filter.type === FILTER_TYPES.timeRange) {
119✔
894
    return updateTimeFilterPlotType(filter as TimeRangeFilter, filter.plotType, datasets);
52✔
895
  } else if (filter.type === FILTER_TYPES.range) {
67✔
896
    return updateRangeFilterPlotType(filter as RangeFilter, filter.plotType, datasets);
34✔
897
  }
898
  return filter;
33✔
899
}
900

901
/**
902
 *
903
 * @param datasetIds list of dataset ids to be filtered
904
 * @param datasets all datasets
905
 * @param filters all filters to be applied to datasets
906
 * @return datasets - new updated datasets
907
 */
908
export function applyFiltersToDatasets<
909
  K extends KeplerTableModel<K, L>,
910
  L extends {config: {dataId: string | null}}
911
>(
912
  datasetIds: string[],
913
  datasets: {[id: string]: K},
914
  filters: Filter[],
915
  layers?: L[]
916
): {[id: string]: K} {
917
  const dataIds = toArray(datasetIds);
130✔
918
  return dataIds.reduce((acc, dataId) => {
130✔
919
    const layersToFilter = (layers || []).filter(l => l.config.dataId === dataId);
281!
920
    const appliedFilters = filters.filter(d => shouldApplyFilter(d, dataId));
200✔
921
    const table = datasets[dataId];
114✔
922

923
    return {
114✔
924
      ...acc,
925
      [dataId]: table.filterTable(appliedFilters, layersToFilter, {})
926
    };
927
  }, datasets);
928
}
929

930
/**
931
 * Applies a new field name value to filter and update both filter and dataset
932
 * @param filter - to be applied the new field name on
933
 * @param datasets - All datasets
934
 * @param datasetId - Id of the dataset the field belongs to
935
 * @param fieldName - field.name
936
 * @param filterDatasetIndex - field.name
937
 * @param option
938
 * @return - {filter, datasets}
939
 */
940
export function applyFilterFieldName<K extends KeplerTableModel<K, L>, L>(
941
  filter: Filter,
942
  datasets: Record<string, K>,
943
  datasetId: string,
944
  fieldName: string,
945
  filterDatasetIndex = 0,
1✔
946
  option?: {mergeDomain: boolean}
947
): {
948
  filter: Filter | null;
949
  dataset: K;
950
} {
951
  // using filterDatasetIndex we can filter only the specified dataset
952
  const mergeDomain =
953
    option && Object.prototype.hasOwnProperty.call(option, 'mergeDomain')
80✔
954
      ? option.mergeDomain
955
      : false;
956

957
  const dataset = datasets[datasetId];
80✔
958
  const fieldIndex = dataset?.getColumnFieldIdx(fieldName);
80✔
959
  // if no field with same name is found, move to the next datasets
960
  if (!dataset || fieldIndex === -1) {
80✔
961
    // throw new Error(`fieldIndex not found. Dataset must contain a property with name: ${fieldName}`);
962
    return {filter: null, dataset};
1✔
963
  }
964

965
  // TODO: validate field type
966
  const filterProps = dataset.getColumnFilterProps(fieldName);
79✔
967

968
  let newFilter = {
79✔
969
    ...filter,
970
    ...filterProps,
971
    name: Object.assign([...toArray(filter.name)], {[filterDatasetIndex]: fieldName}),
972
    fieldIdx: Object.assign([...toArray(filter.fieldIdx)], {
973
      [filterDatasetIndex]: fieldIndex
974
    }),
975
    // Make sure plotType is not overwritten by the default empty plotType
976
    ...(filter.plotType ? {plotType: filter.plotType} : {})
79!
977
  };
978

979
  if (mergeDomain) {
79✔
980
    const domainSteps: (Filter & {step?: number}) | null =
981
      mergeFilterDomain(newFilter, datasets) ?? ({} as Filter);
47!
982
    if (domainSteps) {
47!
983
      const {domain, step} = domainSteps;
47✔
984
      newFilter.domain = domain;
47✔
985
      if (newFilter.step !== step) {
47!
986
        newFilter.step = step;
×
987
      }
988
    }
989
  }
990

991
  // TODO: if we don't set filter value in filterProps, we don't need to do this
992
  if (filterDatasetIndex > 0) {
79✔
993
    // don't reset the filter value if we are just adding a synced dataset
994
    newFilter = {
5✔
995
      ...newFilter,
996
      value: filter.value
997
    };
998
  }
999

1000
  return {
79✔
1001
    filter: newFilter,
1002
    dataset
1003
  };
1004
}
1005

1006
/**
1007
 * Merge the domains of a filter in case it applies to multiple datasets
1008
 */
1009
export function mergeFilterDomain(
1010
  filter: Filter,
1011
  datasets: Record<string, KeplerTableModel<any, any>>
1012
): (Filter & {step?: number}) | null {
1013
  let domainSteps: (Filter & {step?: number}) | null = null;
49✔
1014
  if (!filter?.dataId?.length) {
49!
1015
    return filter;
×
1016
  }
1017
  filter.dataId.forEach((filterDataId, idx) => {
49✔
1018
    const dataset = datasets[filterDataId];
56✔
1019
    const filterProps = dataset.getColumnFilterProps(filter.name[idx]);
56✔
1020
    domainSteps = mergeFilterDomainStep(domainSteps ?? ({} as Filter), filterProps);
56✔
1021
  });
1022
  return domainSteps;
49✔
1023
}
1024

1025
/**
1026
 * Merge one filter with other filter prop domain
1027
 */
1028
/* eslint-disable complexity */
1029
export function mergeFilterDomainStep(
1030
  filter: Filter | null,
1031
  filterProps?: Partial<Filter>
1032
): (Filter & {step?: number}) | null {
1033
  if (!filter) {
56!
1034
    return null;
×
1035
  }
1036

1037
  if (!filterProps) {
56✔
1038
    return filter;
1✔
1039
  }
1040

1041
  if ((filter.fieldType && filter.fieldType !== filterProps.fieldType) || !filterProps.domain) {
55!
1042
    return filter;
×
1043
  }
1044

1045
  const sortedDomain = !filter.domain
55✔
1046
    ? filterProps.domain
1047
    : [...(filter.domain || []), ...(filterProps.domain || [])].sort((a, b) => a - b);
36!
1048

1049
  const newFilter = {
55✔
1050
    ...filter,
1051
    ...filterProps,
1052
    // use min max as default domain
1053
    domain: [sortedDomain[0], sortedDomain[sortedDomain.length - 1]]
1054
  };
1055

1056
  switch (filterProps.fieldType) {
55✔
1057
    case ALL_FIELD_TYPES.string:
1058
    case ALL_FIELD_TYPES.h3:
1059
    case ALL_FIELD_TYPES.date:
1060
      return {
7✔
1061
        ...newFilter,
1062
        domain: unique(sortedDomain)
1063
      };
1064

1065
    case ALL_FIELD_TYPES.timestamp: {
1066
      const step =
1067
        (filter as TimeRangeFilter).step < (filterProps as TimeRangeFieldDomain).step
34!
1068
          ? (filter as TimeRangeFilter).step
1069
          : (filterProps as TimeRangeFieldDomain).step;
1070

1071
      return {
34✔
1072
        ...newFilter,
1073
        step
1074
      };
1075
    }
1076
    case ALL_FIELD_TYPES.real:
1077
    case ALL_FIELD_TYPES.integer:
1078
    default:
1079
      return newFilter;
14✔
1080
  }
1081
}
1082
/* eslint-enable complexity */
1083

1084
/**
1085
 * Generates polygon filter
1086
 */
1087
export const featureToFilterValue = (
17✔
1088
  feature: Feature,
1089
  filterId: string,
1090
  properties?: Record<string, any>
1091
): FeatureValue => ({
6✔
1092
  ...feature,
1093
  id: feature.id,
1094
  properties: {
1095
    ...feature.properties,
1096
    ...properties,
1097
    filterId
1098
  }
1099
});
1100

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

1103
/**
1104
 * Generates polygon filter
1105
 */
1106
export function generatePolygonFilter<
1107
  L extends {config: {dataId: string | null; label: string}; id: string}
1108
>(layers: L[], feature: Feature): PolygonFilter {
1109
  const dataId = layers.map(l => l.config.dataId).filter(notNullorUndefined);
5✔
1110
  const layerId = layers.map(l => l.id);
5✔
1111
  const name = layers.map(l => l.config.label);
5✔
1112
  const filter = getDefaultFilter({dataId});
5✔
1113
  return {
5✔
1114
    ...filter,
1115
    fixedDomain: true,
1116
    type: FILTER_TYPES.polygon,
1117
    name,
1118
    layerId,
1119
    value: featureToFilterValue(feature, filter.id, {isVisible: true})
1120
  };
1121
}
1122

1123
/**
1124
 * Run filter entirely on CPU
1125
 */
1126
interface StateType<K extends KeplerTableModel<K, L>, L> {
1127
  layers: L[];
1128
  filters: Filter[];
1129
  datasets: {[id: string]: K};
1130
}
1131

1132
export function filterDatasetCPU<T extends StateType<K, L>, K extends KeplerTableModel<K, L>, L>(
1133
  state: T,
1134
  dataId: string
1135
): T {
1136
  const datasetFilters = state.filters.filter(f => f.dataId.includes(dataId));
12✔
1137
  const dataset = state.datasets[dataId];
7✔
1138

1139
  if (!dataset) {
7!
1140
    return state;
×
1141
  }
1142

1143
  const cpuFilteredDataset = dataset.filterTableCPU(datasetFilters, state.layers);
7✔
1144

1145
  return set(['datasets', dataId], cpuFilteredDataset, state);
7✔
1146
}
1147

1148
/**
1149
 * Validate parsed filters with datasets and add filterProps to field
1150
 */
1151
type MinVisStateForFilter = Pick<VisState, 'layers' | 'datasets' | 'isMergingDatasets'>;
1152
export function validateFiltersUpdateDatasets<
1153
  S extends MinVisStateForFilter,
1154
  K extends KeplerTableModel<K, L>,
1155
  L extends {config: {dataId: string | null; label: string}; id: string}
1156
>(
1157
  state: S,
1158
  filtersToValidate: ParsedFilter[] = []
×
1159
): {
1160
  validated: Filter[];
1161
  failed: Filter[];
1162
  updatedDatasets: S['datasets'];
1163
} {
1164
  // TODO Better Typings here
1165
  const validated: any[] = [];
52✔
1166
  const failed: any[] = [];
52✔
1167
  const {datasets, layers} = state;
52✔
1168
  let updatedDatasets = datasets;
52✔
1169

1170
  // merge filters
1171
  filtersToValidate.forEach(filterToValidate => {
52✔
1172
    // we can only look for datasets define in the filter dataId
1173
    const datasetIds = toArray(filterToValidate.dataId);
91✔
1174

1175
    // we can merge a filter only if all datasets in filter.dataId are loaded
1176
    if (datasetIds.every(d => datasets[d] && !state.isMergingDatasets[d])) {
95✔
1177
      // all datasetIds in filter must be present the state datasets
1178
      const {validatedFilter, applyToDatasets, augmentedDatasets} = datasetIds.reduce<{
43✔
1179
        validatedFilter: Filter | null;
1180
        applyToDatasets: string[];
1181
        augmentedDatasets: {[datasetId: string]: any};
1182
      }>(
1183
        (acc, datasetId) => {
1184
          const dataset = updatedDatasets[datasetId];
46✔
1185
          const datasetLayers = layers.filter(l => l.config.dataId === dataset.id);
78✔
1186
          const toValidate = acc.validatedFilter || filterToValidate;
46✔
1187

1188
          const {filter: updatedFilter, dataset: updatedDataset} = validateFilterWithData(
46✔
1189
            {
1190
              ...updatedDatasets,
1191
              ...acc.augmentedDatasets,
1192
              [datasetId]: acc.augmentedDatasets[datasetId] || dataset
92✔
1193
            },
1194
            datasetId,
1195
            toValidate,
1196
            datasetLayers
1197
          );
1198

1199
          if (updatedFilter) {
46✔
1200
            // merge filter domain step
1201
            return {
45✔
1202
              validatedFilter: updatedFilter,
1203

1204
              applyToDatasets: [...acc.applyToDatasets, datasetId],
1205

1206
              augmentedDatasets: {
1207
                ...acc.augmentedDatasets,
1208
                [datasetId]: updatedDataset
1209
              }
1210
            };
1211
          }
1212

1213
          return acc;
1✔
1214
        },
1215
        {
1216
          validatedFilter: null,
1217
          applyToDatasets: [],
1218
          augmentedDatasets: {}
1219
        }
1220
      );
1221

1222
      if (validatedFilter && isEqual(datasetIds, applyToDatasets)) {
43✔
1223
        let domain = validatedFilter.domain;
42✔
1224
        if ((validatedFilter as TimeRangeFilter).syncedWithLayerTimeline) {
42✔
1225
          const animatableLayers = getAnimatableVisibleLayers(layers);
1✔
1226
          domain = mergeTimeDomains([
1✔
1227
            ...animatableLayers.map(l => l.config.animation.domain || [0, 0]),
×
1228
            validatedFilter.domain
1229
          ]);
1230
        }
1231

1232
        validatedFilter.value = adjustValueToFilterDomain(filterToValidate.value, {
42✔
1233
          ...validatedFilter,
1234
          domain
1235
        });
1236

1237
        validated.push(updateFilterPlot(datasets, validatedFilter));
42✔
1238
        updatedDatasets = {
42✔
1239
          ...updatedDatasets,
1240
          ...augmentedDatasets
1241
        };
1242
      } else {
1243
        failed.push(filterToValidate);
1✔
1244
      }
1245
    } else {
1246
      failed.push(filterToValidate);
48✔
1247
    }
1248
  });
1249

1250
  return {validated, failed, updatedDatasets};
52✔
1251
}
1252

1253
export function removeFilterPlot(filter: Filter, dataId: string) {
1254
  let nextFilter = filter;
35✔
1255

1256
  const rangeFilter = filter as RangeFilter;
35✔
1257
  if (rangeFilter.bins && rangeFilter.bins[dataId]) {
35!
1258
    const {[dataId]: _delete, ...nextBins} = rangeFilter.bins;
×
1259
    nextFilter = {
×
1260
      ...rangeFilter,
1261
      bins: nextBins
1262
    };
1263
  }
1264

1265
  const timeFilter = filter as TimeRangeFilter;
35✔
1266
  if (timeFilter.timeBins && timeFilter.timeBins[dataId]) {
35✔
1267
    const {[dataId]: __delete, ...nextTimeBins} = timeFilter.timeBins;
2✔
1268
    nextFilter = {
2✔
1269
      ...nextFilter,
1270
      timeBins: nextTimeBins
1271
    } as Filter;
1272
  }
1273

1274
  return nextFilter;
35✔
1275
}
1276

1277
export function isValidTimeDomain(domain) {
1278
  return Array.isArray(domain) && domain.every(Number.isFinite);
64✔
1279
}
1280

1281
export function getTimeWidgetHintFormatter(domain: [number, number]): string | undefined {
1282
  if (!isValidTimeDomain(domain)) {
×
1283
    return undefined;
×
1284
  }
1285

1286
  const diff = domain[1] - domain[0];
×
1287
  return diff > durationWeek
×
1288
    ? 'L'
1289
    : diff > durationDay
×
1290
    ? 'L LT'
1291
    : diff > durationHour
×
1292
    ? 'LT'
1293
    : 'LTS';
1294
}
1295

1296
export function isSideFilter(filter: Filter): boolean {
1297
  return filter.view === FILTER_VIEW_TYPES.side;
2✔
1298
}
1299

1300
export function mergeTimeDomains(domains: ([number, number] | null)[]): [number, number] {
1301
  return domains.reduce(
24✔
1302
    (acc: [number, number], domain) => [
29✔
1303
      Math.min(acc[0], domain?.[0] ?? Infinity),
29!
1304
      Math.max(acc[1], domain?.[1] ?? -Infinity)
29!
1305
    ],
1306
    [Number(Infinity), -Infinity]
1307
  ) as [number, number];
1308
}
1309

1310
/**
1311
 * @param {Layer} layer
1312
 */
1313
export function isLayerAnimatable(layer: any): boolean {
1314
  return layer.config.animation?.enabled && Array.isArray(layer.config.animation.domain);
413✔
1315
}
1316

1317
/**
1318
 * @param {Layer[]} layers
1319
 * @returns {Layer[]}
1320
 */
1321
export function getAnimatableVisibleLayers(layers: any[]): any[] {
1322
  return layers.filter(l => isLayerAnimatable(l) && l.config.isVisible);
408✔
1323
}
1324

1325
/**
1326
 * @param {Layer[]} layers
1327
 * @param {string} type
1328
 * @returns {Layer[]}
1329
 */
1330
export function getAnimatableVisibleLayersByType(layers: any[], type: string): any[] {
1331
  return getAnimatableVisibleLayers(layers).filter(l => l.type === type);
×
1332
}
1333

1334
/**
1335
 * @param {Layer[]} layers
1336
 * @returns {Layer[]}
1337
 */
1338
export function getIntervalBasedAnimationLayers(layers: any[]): any[] {
1339
  // @ts-ignore
1340
  return getAnimatableVisibleLayers(layers).filter(l => l.config.animation?.timeSteps);
6✔
1341
}
1342

1343
export function mergeFilterWithTimeline(
1344
  filter: TimeRangeFilter,
1345
  animationConfig: AnimationConfig
1346
): {filter: TimeRangeFilter; animationConfig: AnimationConfig} {
1347
  if (
1!
1348
    filter?.type === FILTER_TYPES.timeRange &&
4✔
1349
    filter.syncedWithLayerTimeline &&
1350
    animationConfig &&
1351
    Array.isArray(animationConfig.domain)
1352
  ) {
1353
    const domain = mergeTimeDomains([filter.domain, animationConfig.domain as [number, number]]);
1✔
1354
    return {
1✔
1355
      filter: {
1356
        ...filter,
1357
        domain
1358
      },
1359
      animationConfig: {
1360
        ...animationConfig,
1361
        domain
1362
      }
1363
    };
1364
  }
1365
  return {filter, animationConfig};
×
1366
}
1367

1368
export function scaleSourceDomainToDestination(
1369
  sourceDomain: [number, number],
1370
  destinationDomain: [number, number]
1371
): [number, number] {
1372
  // 0 -> 100: merged domains t1 - t0 === 100% filter may already have this info which is good
1373
  const sourceDomainSize = sourceDomain[1] - sourceDomain[0];
1✔
1374
  // 10 -> 20: animationConfig domain d1 - d0 === animationConfig size
1375
  const destinationDomainSize = destinationDomain[1] - destinationDomain[0];
1✔
1376
  // scale animationConfig size using domain size
1377
  const scaledSourceDomainSize = (sourceDomainSize / destinationDomainSize) * 100;
1✔
1378
  // scale d0 - t0 using domain size to find starting point
1379
  const offset = sourceDomain[0] - destinationDomain[0];
1✔
1380
  const scaledOffset = (offset / destinationDomainSize) * 100;
1✔
1381
  return [scaledOffset, scaledSourceDomainSize + scaledOffset];
1✔
1382
}
1383

1384
export function getFilterScaledTimeline(filter, animationConfig): [number, number] | [] {
1385
  if (!(filter.syncedWithLayerTimeline && animationConfig?.domain)) {
×
1386
    return [];
×
1387
  }
1388

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