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

keplergl / kepler.gl / 12509145710

26 Dec 2024 10:43PM UTC coverage: 67.491%. Remained the same
12509145710

push

github

web-flow
[chore] ts refactoring (#2861)

- move several base layer types to layer.d.ts
- other ts changes

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

5841 of 10041 branches covered (58.17%)

Branch coverage included in aggregate %.

8 of 12 new or added lines in 9 files covered. (66.67%)

31 existing lines in 6 files now uncovered.

11978 of 16361 relevant lines covered (73.21%)

87.57 hits per line

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

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

4
import {bisectLeft, extent, histogram as d3Histogram, ticks} from 'd3-array';
5
import isEqual from 'lodash.isequal';
6
import {getFilterMappedValue, getInitialInterval, intervalToFunction} from './time';
7
import moment from 'moment';
8
import {
9
  Bin,
10
  TimeBins,
11
  Millisecond,
12
  TimeRangeFilter,
13
  RangeFilter,
14
  PlotType,
15
  Filter,
16
  LineChart,
17
  Field,
18
  ValueOf,
19
  LineDatum
20
} from '@kepler.gl/types';
21
import {notNullorUndefined} from '@kepler.gl/common-utils';
22
import {
23
  ANIMATION_WINDOW,
24
  BINS,
25
  durationDay,
26
  TIME_AGGREGATION,
27
  AGGREGATION_TYPES,
28
  PLOT_TYPES,
29
  AggregationTypes
30
} from '@kepler.gl/constants';
31

32
import {isNumber, roundValToStep} from './data-utils';
33
import {aggregate, AGGREGATION_NAME} from './aggregation';
34
import {capitalizeFirstLetter} from './strings';
35
import {getDefaultTimeFormat} from './format';
36
import {rgbToHex} from './color-utils';
37
import {DataContainerInterface} from '.';
38
import {KeplerTableModel} from './types';
39

40
// TODO kepler-table module isn't accessible from utils. Add compatible interface to types
41
type Datasets = any;
42

43
/**
44
 *
45
 * @param thresholds
46
 * @param values
47
 * @param indexes
48
 */
49
export function histogramFromThreshold(
50
  thresholds: number[],
51
  values: number[],
52
  valueAccessor?: (d: unknown) => number,
53
  filterEmptyBins = true
54✔
54
): Bin[] {
55
  const getBins = d3Histogram()
55✔
56
    .domain([thresholds[0], thresholds[thresholds.length - 1]])
57
    .thresholds(thresholds);
58

59
  if (valueAccessor) {
55✔
60
    getBins.value(valueAccessor);
42✔
61
  }
62

63
  // @ts-ignore
64
  const bins = getBins(values).map(bin => ({
3,445✔
65
    count: bin.length,
66
    indexes: bin,
67
    x0: bin.x0,
68
    x1: bin.x1
69
  }));
70

71
  // d3-histogram ignores threshold values outside the domain
72
  // The first bin.x0 is always equal to the minimum domain value, and the last bin.x1 is always equal to the maximum domain value.
73

74
  // bins[0].x0 = thresholds[0];
75
  // bins[bins.length - 1].x1 = thresholds[thresholds.length - 1];
76

77
  // @ts-ignore
78
  return filterEmptyBins ? bins.filter(b => b.count > 0) : bins;
3,420✔
79
}
80

81
/**
82
 *
83
 * @param values
84
 * @param numBins
85
 * @param valueAccessor
86
 */
87
export function histogramFromValues(
88
  values: (Millisecond | null | number)[],
89
  numBins: number,
90
  valueAccessor?: (d: number) => number
91
) {
92
  const getBins = d3Histogram().thresholds(numBins);
9✔
93

94
  if (valueAccessor) {
9✔
95
    getBins.value(valueAccessor);
8✔
96
  }
97

98
  // @ts-ignore d3-array types doesn't match
99
  return getBins(values)
9✔
100
    .map(bin => ({
179✔
101
      count: bin.length,
102
      indexes: bin,
103
      x0: bin.x0,
104
      x1: bin.x1
105
    }))
106
    .filter(b => {
107
      const {x0, x1} = b;
179✔
108
      return isNumber(x0) && isNumber(x1);
179✔
109
    });
110
}
111

112
/**
113
 *
114
 * @param domain
115
 * @param values
116
 * @param numBins
117
 * @param valueAccessor
118
 */
119
export function histogramFromDomain(
120
  domain: [number, number],
121
  values: (Millisecond | null | number)[],
122
  numBins: number,
123
  valueAccessor?: (d: unknown) => number
124
): Bin[] {
125
  const getBins = d3Histogram().thresholds(ticks(domain[0], domain[1], numBins)).domain(domain);
30✔
126
  if (valueAccessor) {
30✔
127
    getBins.value(valueAccessor);
23✔
128
  }
129

130
  // @ts-ignore d3-array types doesn't match
131
  return getBins(values).map(bin => ({
957✔
132
    count: bin.length,
133
    indexes: bin,
134
    x0: bin.x0,
135
    x1: bin.x1
136
  }));
137
}
138

139
/**
140
 * @param filter
141
 * @param datasets
142
 * @param interval
143
 */
144
export function getTimeBins(
145
  filter: TimeRangeFilter,
146
  datasets: Datasets,
147
  interval: PlotType['interval']
148
): TimeBins {
149
  let bins = filter.timeBins || {};
55✔
150

151
  filter.dataId.forEach((dataId, dataIdIdx) => {
55✔
152
    // reuse bins if filterData did not change
153
    if (bins[dataId] && bins[dataId][interval]) {
62✔
154
      return;
22✔
155
    }
156
    const dataset = datasets[dataId];
40✔
157

158
    // do not apply current filter
159
    const indexes = runGpuFilterForPlot(dataset, filter);
40✔
160

161
    bins = {
40✔
162
      ...bins,
163
      [dataId]: {
164
        ...bins[dataId],
165
        [interval]: binByTime(indexes, dataset, interval, filter)
166
      }
167
    };
168
  });
169

170
  return bins;
55✔
171
}
172

173
export function binByTime(indexes, dataset, interval, filter) {
174
  // gpuFilters need to be apply to filteredIndex
175
  const mappedValue = getFilterMappedValue(dataset, filter);
40✔
176
  if (!mappedValue) {
40!
177
    return null;
×
178
  }
179
  const intervalBins = getBinThresholds(interval, filter.domain);
40✔
180
  const valueAccessor = idx => mappedValue[idx];
646✔
181
  const bins = histogramFromThreshold(intervalBins, indexes, valueAccessor);
40✔
182

183
  return bins;
40✔
184
}
185

186
export function getBinThresholds(interval: string, domain: number[]): number[] {
187
  const timeInterval = intervalToFunction(interval);
53✔
188
  const [t0, t1] = domain;
53✔
189
  const floor = timeInterval.floor(t0).getTime();
53✔
190
  const ceiling = timeInterval.ceil(t1).getTime();
53✔
191

192
  if (!timeInterval) {
53!
193
    // if time interval is not defined
194
    // this should not happen
195
    return [t0, t0 + durationDay];
×
196
  }
197
  const binThresholds = timeInterval.range(floor, ceiling + 1).map(t => moment.utc(t).valueOf());
3,519✔
198
  const lastStep = binThresholds[binThresholds.length - 1];
53✔
199
  if (lastStep === t1) {
53✔
200
    // when last step equal to domain max, add one more step
201
    binThresholds.push(moment.utc(timeInterval.offset(lastStep)).valueOf());
22✔
202
  }
203

204
  return binThresholds;
53✔
205
}
206

207
/**
208
 * Run GPU filter on current filter result to generate indexes for ploting chart
209
 * Skip ruuning for the same field
210
 * @param dataset
211
 * @param filter
212
 */
213
export function runGpuFilterForPlot<K extends KeplerTableModel<K, L>, L>(
214
  dataset: K,
215
  filter?: Filter
216
): number[] {
217
  const skipIndexes = getSkipIndexes(dataset, filter);
63✔
218

219
  const {
220
    gpuFilter: {filterValueUpdateTriggers, filterRange, filterValueAccessor},
221
    filteredIndex
222
  } = dataset;
63✔
223
  const getFilterValue = filterValueAccessor(dataset.dataContainer)();
63✔
224

225
  const allChannels = Object.keys(filterValueUpdateTriggers)
63✔
226
    .map((_, i) => i)
252✔
227
    .filter(i => Object.values(filterValueUpdateTriggers)[i]);
252✔
228
  const skipAll = !allChannels.filter(i => !skipIndexes.includes(i)).length;
63✔
229
  if (skipAll) {
63✔
230
    return filteredIndex;
61✔
231
  }
232

233
  const filterData = getFilterDataFunc(
2✔
234
    filterRange,
235
    getFilterValue,
236
    dataset.dataContainer,
237
    skipIndexes
238
  );
239

240
  return filteredIndex.filter(filterData);
2✔
241
}
242

243
function getSkipIndexes(dataset, filter) {
244
  // array of gpu filter names
245
  if (!filter) {
63!
246
    return [];
×
247
  }
248
  const gpuFilters = Object.values(dataset.gpuFilter.filterValueUpdateTriggers) as ({
63✔
249
    name: string;
250
  } | null)[];
251
  const valueIndex = filter.dataId.findIndex(id => id === dataset.id);
68✔
252
  const filterColumn = filter.name[valueIndex];
63✔
253

254
  return gpuFilters.reduce((accu, item, idx) => {
63✔
255
    if (item && filterColumn === item.name) {
252✔
256
      accu.push(idx);
29✔
257
    }
258
    return accu;
252✔
259
  }, [] as number[]);
260
}
261

262
export function getFilterDataFunc(
263
  filterRange,
264
  getFilterValue,
265
  dataContainer: DataContainerInterface,
266
  skips
267
) {
268
  return index =>
2✔
269
    getFilterValue({index}).every(
20✔
270
      (val, i) => skips.includes(i) || (val >= filterRange[i][0] && val <= filterRange[i][1])
32✔
271
    );
272
}
273

274
export function validBin(b) {
275
  return b.x0 !== undefined && b.x1 !== undefined;
×
276
}
277

278
/**
279
 * Use in slider, given a number and an array of numbers, return the nears number from the array.
280
 * Takes a value, timesteps and return the actual step.
281
 * @param value
282
 * @param marks
283
 */
284
export function snapToMarks(value: number, marks: number[]): number {
285
  // always use bin x0
286
  if (!marks.length) {
9!
287
    // @ts-expect-error looking at the usage null return value isn't expected and requires extra handling in a lot of places
288
    return null;
×
289
  }
290
  const i = bisectLeft(marks, value);
9✔
291
  if (i === 0) {
9✔
292
    return marks[i];
2✔
293
  } else if (i === marks.length) {
7✔
294
    return marks[i - 1];
2✔
295
  }
296
  const idx = marks[i] - value < value - marks[i - 1] ? i : i - 1;
5✔
297
  return marks[idx];
5✔
298
}
299

300
export function normalizeValue(val, minValue, step, marks) {
301
  if (marks && marks.length) {
×
302
    return snapToMarks(val, marks);
×
303
  }
304

305
  return roundValToStep(minValue, step, val);
×
306
}
307

308
export function isPercentField(field) {
309
  return field.metadata && field.metadata.numerator && field.metadata.denominator;
44!
310
}
311

312
export function updateAggregationByField(field: Field, aggregation: ValueOf<AggregationTypes>) {
313
  // shouldn't apply sum to percent fiele type
314
  // default aggregation is average
315
  return field && isPercentField(field)
×
316
    ? AGGREGATION_TYPES.average
317
    : aggregation || AGGREGATION_TYPES.average;
×
318
}
319

320
const getAgregationType = (field, aggregation) => {
12✔
321
  if (isPercentField(field)) {
22!
322
    return 'mean_of_percent';
×
323
  }
324
  return aggregation;
22✔
325
};
326

327
const getAggregationAccessor = (field, dataContainer: DataContainerInterface, fields) => {
12✔
328
  if (isPercentField(field)) {
22!
329
    const numeratorIdx = fields.findIndex(f => f.name === field.metadata.numerator);
×
330
    const denominatorIdx = fields.findIndex(f => f.name === field.metadata.denominator);
×
331

332
    return {
×
333
      getNumerator: i => dataContainer.valueAt(i, numeratorIdx),
×
334
      getDenominator: i => dataContainer.valueAt(i, denominatorIdx)
×
335
    };
336
  }
337

338
  return i => field.valueAccessor({index: i});
24✔
339
};
340

341
export const getValueAggrFunc = (
12✔
342
  field: Field | string | null,
343
  aggregation: string,
344
  dataset: KeplerTableModel<any, any>
345
): ((bin: Bin) => number) => {
346
  const {dataContainer, fields} = dataset;
1✔
347

348
  // The passed-in field might not have all the fields set (e.g. valueAccessor)
349
  const datasetField = fields.find(
1✔
350
    f => field && (f.name === field || f.name === (field as Field).name)
7✔
351
  );
352

353
  return datasetField && aggregation
1!
354
    ? bin =>
355
        aggregate(
22✔
356
          bin.indexes,
357
          getAgregationType(datasetField, aggregation),
358
          // @ts-expect-error can return {getNumerator, getDenominator}
359
          getAggregationAccessor(datasetField, dataContainer, fields)
360
        )
361
    : bin => bin.count;
×
362
};
363

364
export const getAggregationOptiosnBasedOnField = field => {
12✔
365
  if (isPercentField(field)) {
×
366
    // don't show sum
367
    return TIME_AGGREGATION.filter(({id}) => id !== AGGREGATION_TYPES.sum);
×
368
  }
369
  return TIME_AGGREGATION;
×
370
};
371

372
function getDelta(
373
  bins: LineDatum[],
374
  y: number,
375
  interval: PlotType['interval']
376
): Partial<LineDatum> & {delta: 'last'; pct: number | null} {
377
  // if (WOW[interval]) return getWow(bins, y, interval);
378
  const lastBin = bins[bins.length - 1];
22✔
379

380
  return {
22✔
381
    delta: 'last',
382
    pct: lastBin ? getPctChange(y, lastBin.y) : null
22✔
383
  };
384
}
385

386
export function getPctChange(y: unknown, y0: unknown): number | null {
387
  if (Number.isFinite(y) && Number.isFinite(y0) && y0 !== 0) {
21✔
388
    return ((y as number) - (y0 as number)) / (y0 as number);
12✔
389
  }
390
  return null;
9✔
391
}
392

393
/**
394
 *
395
 * @param datasets
396
 * @param filter
397
 */
398
export function getLineChart(datasets: Datasets, filter: Filter): LineChart {
399
  const {dataId, yAxis, plotType, lineChart} = filter;
1✔
400
  const {aggregation, interval} = plotType;
1✔
401
  const seriesDataId = dataId[0];
1✔
402
  const bins = (filter as TimeRangeFilter).timeBins?.[seriesDataId]?.[interval];
1✔
403

404
  if (
1!
405
    lineChart &&
1!
406
    lineChart.aggregation === aggregation &&
407
    lineChart.interval === interval &&
408
    lineChart.yAxis === yAxis?.name &&
409
    // we need to make sure we validate bins because of cross filter data changes
410
    isEqual(bins, lineChart?.bins)
411
  ) {
412
    // don't update lineChart if plotType hasn't change
413
    return lineChart;
×
414
  }
415

416
  const dataset = datasets[seriesDataId];
1✔
417
  const getYValue = getValueAggrFunc(yAxis, aggregation, dataset);
1✔
418

419
  const init: LineDatum[] = [];
1✔
420
  const series = (bins || []).reduce((accu, bin, i) => {
1!
421
    const y = getYValue(bin);
22✔
422
    const delta = getDelta(accu, y, interval);
22✔
423
    accu.push({
22✔
424
      x: bin.x0,
425
      y,
426
      ...delta
427
    });
428
    return accu;
22✔
429
  }, init);
430

431
  const yDomain = extent<{y: any}>(series, d => d.y);
22✔
432
  const xDomain = bins ? [bins[0].x0, bins[bins.length - 1].x1] : [];
1!
433

434
  // treat missing data as another series
435
  const split = splitSeries(series);
1✔
436
  const aggrName = AGGREGATION_NAME[aggregation];
1✔
437

438
  return {
1✔
439
    // @ts-ignore
440
    yDomain,
441
    // @ts-ignore
442
    xDomain,
443
    interval,
444
    aggregation,
445
    // @ts-ignore
446
    series: split,
447
    title: `${aggrName}${' of '}${yAxis ? yAxis.name : 'Count'}`,
1!
448
    fieldType: yAxis ? yAxis.type : 'integer',
1!
449
    yAxis: yAxis ? yAxis.name : null,
1!
450
    allTime: {
451
      title: `All Time Average`,
452
      value: aggregate(series, AGGREGATION_TYPES.average, d => d.y)
22✔
453
    },
454
    // @ts-expect-error bins is Bins[], not a Bins map. Refactor to use correct types.
455
    bins
456
  };
457
}
458

459
// split into multiple series when see missing data
460
export function splitSeries(series) {
461
  const lines: any[] = [];
1✔
462
  let temp: any[] = [];
1✔
463
  for (let i = 0; i < series.length; i++) {
1✔
464
    const d = series[i];
22✔
465
    if (!notNullorUndefined(d.y) && temp.length) {
22!
466
      // ends temp
467
      lines.push(temp);
×
468
      temp = [];
×
469
    } else if (notNullorUndefined(d.y)) {
22!
470
      temp.push(d);
22✔
471
    }
472

473
    if (i === series.length - 1 && temp.length) {
22✔
474
      lines.push(temp);
1✔
475
    }
476
  }
477

478
  const markers = lines.length > 1 ? series.filter(d => notNullorUndefined(d.y)) : [];
1!
479

480
  return {lines, markers};
1✔
481
}
482

483
type MinVisStateForAnimationWindow = {
484
  datasets: Datasets;
485
};
486

487
export function adjustValueToAnimationWindow<S extends MinVisStateForAnimationWindow>(
488
  state: S,
489
  filter: TimeRangeFilter
490
) {
491
  const {
492
    plotType,
493
    value: [value0, value1],
494
    animationWindow
495
  } = filter;
1✔
496

497
  const interval = plotType.interval || getInitialInterval(filter, state.datasets);
1!
498
  const bins = getTimeBins(filter, state.datasets, interval);
1✔
499
  const datasetBins = bins && Object.keys(bins).length && Object.values(bins)[0][interval];
1✔
500
  const thresholds = (datasetBins || []).map(b => b.x0);
1!
501

502
  let val0 = value0;
1✔
503
  let val1 = value1;
1✔
504
  let idx;
505
  if (animationWindow === ANIMATION_WINDOW.interval) {
1!
506
    val0 = snapToMarks(value1, thresholds);
1✔
507
    idx = thresholds.indexOf(val0);
1✔
508
    val1 = idx > -1 ? datasetBins[idx].x1 : NaN;
1!
509
  } else {
510
    // fit current value to window
511
    val0 = snapToMarks(value0, thresholds);
×
512
    val1 = snapToMarks(value1, thresholds);
×
513

514
    if (val0 === val1) {
×
515
      idx = thresholds.indexOf(val0);
×
516
      if (idx === thresholds.length - 1) {
×
517
        val0 = thresholds[idx - 1];
×
518
      } else {
519
        val1 = thresholds[idx + 1];
×
520
      }
521
    }
522
  }
523

524
  const updatedFilter = {
1✔
525
    ...filter,
526
    plotType: {
527
      ...filter.plotType,
528
      interval
529
    },
530
    timeBins: bins,
531
    value: [val0, val1]
532
  };
533

534
  return updatedFilter;
1✔
535
}
536

537
/**
538
 * Create or update colors for a filter plot
539
 * @param filter
540
 * @param datasets
541
 * @param oldColorsByDataId
542
 */
543
function getFilterPlotColorsByDataId(filter, datasets, oldColorsByDataId) {
544
  let colorsByDataId = oldColorsByDataId || {};
6✔
545
  for (const dataId of filter.dataId) {
6✔
546
    if (!colorsByDataId[dataId] && datasets[dataId]) {
12✔
547
      colorsByDataId = {
4✔
548
        ...colorsByDataId,
549
        [dataId]: rgbToHex(datasets[dataId].color)
550
      };
551
    }
552
  }
553
  return colorsByDataId;
6✔
554
}
555

556
/**
557
 *
558
 * @param filter
559
 * @param plotType
560
 * @param datasets
561
 * @param dataId
562
 */
563
export function updateTimeFilterPlotType(
564
  filter: TimeRangeFilter,
565
  plotType: TimeRangeFilter['plotType'],
566
  datasets: Datasets,
567
  dataId?: string
568
): TimeRangeFilter {
569
  let nextFilter = filter;
53✔
570
  let nextPlotType = plotType;
53✔
571
  if (typeof nextPlotType !== 'object' || !nextPlotType.aggregation || !nextPlotType.interval) {
53✔
572
    nextPlotType = getDefaultPlotType(filter, datasets);
23✔
573
  }
574

575
  if (filter.dataId.length > 1) {
53✔
576
    nextPlotType = {
6✔
577
      ...nextPlotType,
578
      colorsByDataId: getFilterPlotColorsByDataId(filter, datasets, nextPlotType.colorsByDataId)
579
    };
580
  }
581
  nextFilter = {
53✔
582
    ...nextFilter,
583
    plotType: nextPlotType
584
  };
585

586
  const bins = getTimeBins(nextFilter, datasets, nextPlotType.interval);
53✔
587

588
  nextFilter = {
53✔
589
    ...nextFilter,
590
    timeBins: bins
591
  };
592

593
  if (plotType.type === PLOT_TYPES.histogram) {
53✔
594
    // Histogram is calculated and memoized in the chart itself
595
  } else if (plotType.type === PLOT_TYPES.lineChart) {
1!
596
    // we should be able to move this into its own component so react will do the shallow comparison for us.
597
    nextFilter = {
1✔
598
      ...nextFilter,
599
      lineChart: getLineChart(datasets, nextFilter)
600
    };
601
  }
602

603
  return nextFilter;
53✔
604
}
605

606
export function getRangeFilterBins(filter, datasets, numBins) {
607
  const {domain} = filter;
34✔
608
  if (!filter.dataId) return null;
34!
609

610
  return filter.dataId.reduce((acc, dataId, datasetIdx) => {
34✔
611
    if (filter.bins?.[dataId]) {
34✔
612
      // don't recalculate bins
613
      acc[dataId] = filter.bins[dataId];
11✔
614
      return acc;
11✔
615
    }
616
    const fieldName = filter.name[datasetIdx];
23✔
617
    if (dataId && fieldName) {
23!
618
      const dataset = datasets[dataId];
23✔
619
      const field = dataset?.getColumnField(fieldName);
23✔
620
      if (dataset && field) {
23!
621
        const indexes = runGpuFilterForPlot(dataset, filter);
23✔
622
        const valueAccessor = index => field.valueAccessor({index});
210✔
623
        acc[dataId] = histogramFromDomain(domain, indexes, numBins, valueAccessor);
23✔
624
      }
625
    }
626
    return acc;
23✔
627
  }, {});
628
}
629

630
export function updateRangeFilterPlotType(
631
  filter: RangeFilter,
632
  plotType: RangeFilter['plotType'],
633
  datasets: Datasets,
634
  dataId?: string
635
): RangeFilter {
636
  const nextFilter = {
34✔
637
    ...filter,
638
    plotType
639
  };
640

641
  // if (dataId) {
642
  //   // clear bins
643
  //   nextFilter = {
644
  //     ...nextFilter,
645
  //     bins: {
646
  //       ...nextFilter.bins,
647
  //       [dataId]: null
648
  //     }
649
  //   };
650
  // }
651

652
  return {
34✔
653
    ...filter,
654
    plotType,
655
    bins: getRangeFilterBins(nextFilter, datasets, BINS)
656
  };
657
}
658

659
export function getChartTitle(yAxis: Field, plotType: PlotType): string {
UNCOV
660
  const yAxisName = yAxis?.displayName;
×
661
  const {aggregation} = plotType;
×
662

663
  if (yAxisName) {
×
664
    return capitalizeFirstLetter(`${aggregation} ${yAxisName} over Time`);
×
665
  }
666

667
  return `Count of Rows over Time`;
×
668
}
669

670
export function getDefaultPlotType(filter, datasets) {
671
  const interval = getInitialInterval(filter, datasets);
23✔
672
  const defaultTimeFormat = getDefaultTimeFormat(interval);
23✔
673
  return {
23✔
674
    interval,
675
    defaultTimeFormat,
676
    type: PLOT_TYPES.histogram,
677
    aggregation: AGGREGATION_TYPES.sum
678
  };
679
}
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