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

Open-S2 / open-vector-tile / #27

06 Dec 2024 02:32PM UTC coverage: 98.708% (+1.3%) from 97.451%
#27

push

Mr Martian
fix coveralls

8783 of 8898 relevant lines covered (98.71%)

58.5 hits per line

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

97.53
/src/base/vectorFeature.ts
1
import { OColumnName } from '../open/columnCache';
200✔
2
import { encodeValue } from '../open/shape';
176✔
3
import { weave2D, weave3D, zigzag } from '../util';
204✔
4

5
import type { ColumnCacheWriter } from '../open/columnCache';
6
import type MapboxVectorFeature from '../mapbox/vectorFeature';
7
import type { VectorFeatures as S2JSONFeature } from 's2json-spec';
8
import type { Shape } from '../open/shape';
9
import type {
10
  BBOX,
11
  BBox,
12
  BBox3D,
13
  OProperties,
14
  Point,
15
  Point3D,
16
  VectorLine,
17
  VectorLine3D,
18
  VectorLines,
19
  VectorMultiPoly,
20
  VectorPoints,
21
  VectorPoints3D,
22
} from '../vectorTile.spec';
23

24
/**
25
 * Base Vector Feature
26
 * Common variables and methods shared by all vector features
27
 */
28
export class VectorFeatureBase<G, B = BBOX> {
×
29
  type = 0;
×
30
  /**
×
31
   * @param geometry - the geometry of the feature
×
32
   * @param properties - the properties of the feature
×
33
   * @param id - the id of the feature if there is one
×
34
   * @param bbox - the BBox of the feature
×
35
   */
×
36
  constructor(
×
37
    public geometry: G,
192✔
38
    public properties: OProperties = {},
252✔
39
    public id?: number,
104✔
40
    public bbox?: B,
156✔
41
  ) {}
8✔
42

43
  /** @returns - true if the feature has BBox */
44
  get hasBBox(): boolean {
34✔
45
    const bbox = this.bbox as BBOX | undefined;
108✔
46
    return bbox !== undefined && bbox.some((v) => v !== 0);
236✔
47
  }
48
}
4✔
49

50
//! Points & Points3D
51

52
/** Base Vector Points Feature */
53
export class VectorFeaturePointsBase<
260✔
54
  G = VectorPoints | VectorPoints3D,
55
  B = BBOX,
56
> extends VectorFeatureBase<G, B> {
90✔
57
  /**
58
   * Points do not have this feature, so return false
59
   * @returns false always
60
   */
61
  get hasOffsets(): boolean {
40✔
62
    return false;
82✔
63
  }
64

65
  /**
66
   * Points do not have this feature, so return false
67
   * @returns false always
68
   */
69
  get hasMValues(): boolean {
40✔
70
    const geometry = this.geometry as VectorPoints | VectorPoints3D;
140✔
71
    return geometry.some(({ m }) => m !== undefined);
214✔
72
  }
73

74
  /** @returns the geometry */
75
  loadGeometry(): G {
44✔
76
    return this.geometry;
106✔
77
  }
78

79
  /** @returns the M-Values */
80
  getMValues(): undefined | OProperties[] {
40✔
81
    if (!this.hasMValues) return undefined;
148✔
82
    const geometry = this.geometry as VectorPoints | VectorPoints3D;
140✔
83
    return geometry.map(({ m }) => m ?? {});
178✔
84
  }
85

86
  /**
87
   * @param cache - the column cache to store the geometry
88
   * @param mShape - the shape of the M-values to encode the values as
89
   * @returns the index in the points column where the geometry is stored
90
   */
91
  addGeometryToCache(cache: ColumnCacheWriter, mShape: Shape = {}): number {
128✔
92
    const { hasMValues } = this;
128✔
93
    const geometry = this.geometry as VectorPoints | VectorPoints3D;
140✔
94
    const is3D = this.type === 4;
132✔
95
    const columnName = is3D ? OColumnName.points3D : OColumnName.points;
282✔
96
    if (geometry.length === 1) {
126✔
97
      if (is3D) {
66✔
98
        const { x, y, z } = (geometry as VectorPoints3D)[0];
160✔
99
        return weave3D(zigzag(x), zigzag(y), zigzag(z));
230✔
100
      } else {
34✔
101
        const { x, y } = geometry[0];
148✔
102
        return weave2D(zigzag(x), zigzag(y));
196✔
103
      }
104
    }
6✔
105
    // othwerise store the collection of points
106
    const indices: number[] = [];
92✔
107
    indices.push(cache.addColumnData(columnName, geometry));
240✔
108
    // store the mvalues indexes if they exist
109
    if (hasMValues) {
82✔
110
      for (const { m } of geometry) {
146✔
111
        indices.push(encodeValue(m ?? {}, mShape, cache));
252✔
112
      }
18✔
113
    }
6✔
114
    return cache.addColumnData(OColumnName.indices, indices);
248✔
115
  }
116
}
4✔
117

118
/**
119
 * Base Vector Points Feature
120
 * Type 1
121
 * Extends from @see {@link VectorFeaturePointsBase}.
122
 * Store either a single point or a list of points
123
 */
124
export class BaseVectorPointsFeature extends VectorFeaturePointsBase<VectorPoints, BBox> {
284✔
125
  type = 1;
40✔
126
}
4✔
127
/**
128
 * Base Vector Points 3D Feature
129
 * Type 4
130
 * Extends from @see {@link VectorFeaturePointsBase}.
131
 * Store either a single point or a list of points
132
 */
133
export class BaseVectorPoints3DFeature extends VectorFeaturePointsBase<VectorPoints3D, BBox3D> {
292✔
134
  type = 4;
40✔
135
}
4✔
136

137
//! Lines & Lines3D
138

139
/**
140
 * Base Vector Lines Feature
141
 * Common variables and methods shared by all vector lines and/or polygons features
142
 */
143
export class BaseVectorLine<L = VectorLine | VectorLine3D> {
196✔
144
  /**
145
   * @param geometry - the geometry of the feature
146
   * @param offset - the offset of the feature
147
   */
148
  constructor(
26✔
149
    public geometry: L,
200✔
150
    public offset: number = 0,
192✔
151
  ) {}
8✔
152
}
4✔
153

154
/** Base Vector Lines Feature */
155
export class VectorFeatureLinesBase<
172✔
156
  G = VectorLine | VectorLine3D,
157
  B = BBOX,
158
> extends VectorFeatureBase<BaseVectorLine<G>[], B> {
90✔
159
  /** @returns - true if the feature has offsets */
160
  get hasOffsets(): boolean {
40✔
161
    const geometry = this.geometry as BaseVectorLine<G>[];
140✔
162
    return geometry.some(({ offset }) => offset > 0);
222✔
163
  }
164

165
  /**
166
   * @returns - true if the feature has M values
167
   */
168
  get hasMValues(): boolean {
40✔
169
    return this.geometry.some(({ geometry }) => {
194✔
170
      return (geometry as VectorLine | VectorLine3D).some(({ m }) => m !== undefined);
220✔
171
    });
18✔
172
  }
173

174
  /** @returns the flattened geometry */
175
  loadGeometry(): G[] {
44✔
176
    return this.geometry.map(({ geometry }) => geometry);
230✔
177
  }
178

179
  /** @returns the flattened M values */
180
  getMValues(): undefined | OProperties[] {
40✔
181
    if (!this.hasMValues) return undefined;
148✔
182
    return this.geometry.flatMap(({ geometry }) =>
198✔
183
      (geometry as VectorLine | VectorLine3D).map(({ m }) => m ?? {}),
122✔
184
    );
18✔
185
  }
186

187
  /**
188
   * @param cache - the column cache to store the geometry
189
   * @param mShape - the shape of the M-values to encode the values as
190
   * @returns the indexes in the points column where the geometry is stored
191
   */
192
  addGeometryToCache(cache: ColumnCacheWriter, mShape: Shape = {}): number {
128✔
193
    const { hasOffsets, hasMValues } = this;
176✔
194
    const geometry = this.geometry as BaseVectorLine<VectorLine | VectorLine3D>[];
140✔
195
    const columnName = this.type === 5 ? OColumnName.points3D : OColumnName.points;
326✔
196
    const indices: number[] = [];
92✔
197
    // store number of lines
198
    if (geometry.length !== 1) indices.push(geometry.length);
260✔
199
    for (const line of geometry) {
134✔
200
      // store offset for current line
201
      if (hasOffsets) indices.push(encodeOffset(line.offset));
272✔
202
      // store geometry data and track its index position
203
      indices.push(cache.addColumnData(columnName, line.geometry));
268✔
204
      // store the mvalues indexes if they exist
205
      if (hasMValues) {
90✔
206
        for (const { m } of line.geometry) {
174✔
207
          indices.push(encodeValue(m ?? {}, mShape, cache));
268✔
208
        }
26✔
209
      }
18✔
210
    }
6✔
211
    return cache.addColumnData(OColumnName.indices, indices);
248✔
212
  }
213
}
4✔
214

215
/**
216
 * Base Vector Lines Feature
217
 * Type 2
218
 * Extends from @see {@link VectorFeatureBase}.
219
 * Store either a single line or a list of lines.
220
 */
221
export class BaseVectorLinesFeature extends VectorFeatureLinesBase<VectorLine, BBox> {
276✔
222
  type = 2;
40✔
223
}
4✔
224
/**
225
 * Base Vector Lines 3D Feature
226
 * Type 5
227
 * Extends from @see {@link VectorFeatureBase}.
228
 * Store either a single 3D line or a list of 3D lines
229
 */
230
export class BaseVectorLines3DFeature extends VectorFeatureLinesBase<VectorLine3D, BBox3D> {
284✔
231
  type = 5;
40✔
232
}
4✔
233

234
//! Polys & Polys3D
235

236
/** Base Vector Polys Feature */
237
export class VectorFeaturePolysBase<
248✔
238
  G = VectorLine | VectorLine3D,
239
  B = BBOX,
240
> extends VectorFeatureBase<BaseVectorLine<G>[][], B> {
84✔
241
  tesselation: Point[];
54✔
242
  /**
243
   * @param geometry - the geometry of the feature
244
   * @param indices - the indices of the geometry
245
   * @param tesselation - the tesselation of the geometry
246
   * @param properties - the properties of the feature
247
   * @param id - the id of the feature
248
   * @param bbox - the bbox of the feature
249
   */
250
  constructor(
26✔
251
    public geometry: BaseVectorLine<G>[][],
200✔
252
    public indices: number[] = [],
204✔
253
    tesselation: number[] = [],
72✔
254
    properties: OProperties = {},
68✔
255
    id?: number,
16✔
256
    public bbox?: B,
136✔
257
  ) {
8✔
258
    super(geometry, properties, id, bbox);
168✔
259
    this.tesselation = this.#fixTesselation(tesselation);
240✔
260
  }
261

262
  /**
263
   * @param tesselation - the tesselation of the geometry but flattened
264
   * @returns - the tesselation of the geometry as a list of points
265
   */
266
  #fixTesselation(tesselation: number[]): Point[] {
94✔
267
    if (tesselation.length % 2 !== 0) {
150✔
268
      throw new Error('The input tesselation must have an even number of elements.');
176✔
269
    }
6✔
270
    return tesselation.reduce((acc, _, index, array) => {
226✔
271
      if (index % 2 === 0) {
110✔
272
        acc.push({ x: array[index], y: array[index + 1] });
256✔
273
      }
6✔
274
      return acc;
80✔
275
    }, [] as Point[]);
34✔
276
  }
277

278
  /**
279
   * @returns true if the feature has offsets
280
   */
281
  get hasOffsets(): boolean {
40✔
282
    return this.geometry.some((poly) => poly.some(({ offset }) => offset > 0));
322✔
283
  }
284

285
  /**
286
   * @returns - true if the feature has M values
287
   */
288
  get hasMValues(): boolean {
40✔
289
    return this.geometry.some((poly) =>
154✔
290
      poly.some(({ geometry }) => {
118✔
291
        return (geometry as VectorLine | VectorLine3D).some(({ m }) => m !== undefined);
220✔
292
      }),
2✔
293
    );
18✔
294
  }
295

296
  /**
297
   * @returns the flattened geometry
298
   */
299
  loadGeometry(): G[][] {
44✔
300
    return this.geometry.map((poly) => poly.map((line) => line.geometry));
294✔
301
  }
302

303
  /**
304
   * @returns the flattened M-values
305
   */
306
  getMValues(): undefined | OProperties[] {
40✔
307
    if (!this.hasMValues) return undefined;
148✔
308
    return this.geometry.flatMap((poly) => {
174✔
309
      return poly.flatMap(({ geometry }) => {
178✔
310
        return (geometry as VectorLine | VectorLine3D).map((point) => point.m ?? {});
220✔
311
      });
16✔
312
    });
18✔
313
  }
314

315
  /**
316
   * @param cache - the column cache to store the geometry
317
   * @param mShape - the shape of the M-values to encode the values as
318
   * @returns the indexes in the points column where the geometry is stored
319
   */
320
  addGeometryToCache(cache: ColumnCacheWriter, mShape: Shape = {}): number {
128✔
321
    const { hasOffsets, hasMValues } = this;
176✔
322
    const geometry = this.geometry as BaseVectorLine<G>[][];
140✔
323
    const columnName = this.type === 6 ? OColumnName.points3D : OColumnName.points;
326✔
324
    const indices: number[] = [];
92✔
325
    // store number of polygons
326
    if (this.geometry.length > 1) indices.push(geometry.length);
272✔
327
    for (const poly of geometry) {
134✔
328
      // store number of lines in the polygon
329
      indices.push(poly.length);
128✔
330
      // store each line
331
      for (const line of poly) {
126✔
332
        // store offset for current line
333
        if (hasOffsets) indices.push(encodeOffset(line.offset));
288✔
334
        // store geometry data and track its index position
335
        indices.push(cache.addColumnData(columnName, line.geometry));
276✔
336
        // store the mvalues indexes if they exist
337
        if (hasMValues) {
98✔
338
          for (const { m } of line.geometry as VectorLine | VectorLine3D) {
182✔
339
            indices.push(encodeValue(m ?? {}, mShape, cache));
284✔
340
          }
34✔
341
        }
26✔
342
      }
18✔
343
    }
6✔
344
    return cache.addColumnData(OColumnName.indices, indices);
248✔
345
  }
346
}
4✔
347

348
/**
349
 * Base Vector Polys Feature
350
 * Type 3
351
 * Extends from @see {@link VectorFeatureBase}.
352
 * Store either a single polygon or a list of polygons
353
 */
354
export class BaseVectorPolysFeature extends VectorFeaturePolysBase<VectorLine, BBox> {
276✔
355
  type = 3;
40✔
356
}
4✔
357

358
/**
359
 * Base Vector Polys 3D Feature
360
 * Type 6
361
 * Extends from @see {@link VectorFeatureBase}.
362
 * Store either a single 3D poly or a list of 3D polys
363
 */
364
export class BaseVectorPolys3DFeature extends VectorFeaturePolysBase<VectorLine3D, BBox3D> {
284✔
365
  type = 6;
40✔
366
}
4✔
367

368
/**
369
 * A type that encompasses all vector tile feature types
370
 */
371
export type BaseVectorFeature =
372
  | BaseVectorPointsFeature
373
  | BaseVectorLinesFeature
374
  | BaseVectorPolysFeature
375
  | BaseVectorPoints3DFeature
376
  | BaseVectorLines3DFeature
377
  | BaseVectorPolys3DFeature;
378

379
/**
380
 * @param feature - A mapbox vector feature that's been parsed from protobuf data
381
 * @returns - A base feature to help build a vector tile
382
 */
383
export function fromMapboxVectorFeature(feature: MapboxVectorFeature): BaseVectorFeature {
134✔
384
  const { id, properties, extent } = feature;
180✔
385
  const geometry = feature.loadGeometry();
168✔
386
  const indices = feature.readIndices();
160✔
387
  const tesselation: number[] = [];
100✔
388
  feature.addTesselation(tesselation, 1 / extent);
200✔
389
  switch (feature.type) {
130✔
390
    case 1:
12✔
391
      return new BaseVectorPointsFeature(geometry as VectorPoints, properties, id);
290✔
392
    case 2: {
20✔
393
      const geo = geometry as VectorLines;
108✔
394
      const baseLines: BaseVectorLine<VectorLine>[] = [];
108✔
395
      for (const line of geo) {
122✔
396
        baseLines.push(new BaseVectorLine(line));
216✔
397
      }
6✔
398
      return new BaseVectorLinesFeature(baseLines, properties, id);
270✔
399
    }
30✔
400
    case 3:
42✔
401
    case 4: {
20✔
402
      const geo = geometry as VectorMultiPoly;
108✔
403
      const baseMultPoly: BaseVectorLine[][] = [];
120✔
404
      for (const poly of geo) {
122✔
405
        const baseLines: BaseVectorLine[] = [];
116✔
406
        for (const line of poly) {
134✔
407
          baseLines.push(new BaseVectorLine(line));
232✔
408
        }
6✔
409
        baseMultPoly.push(baseLines);
168✔
410
      }
6✔
411
      return new BaseVectorPolysFeature(baseMultPoly, indices, tesselation, properties, id);
370✔
412
    }
28✔
413
    default:
414
      throw new Error(`Unknown feature type: ${feature.type}`);
136✔
415
  }
416
}
417

418
/**
419
 * Convert an S2JSON feature to a base feature
420
 * @param feature - An S2JSON feature
421
 * @param extent - the extent of the vector layer
422
 * @returns - A base feature to help build a vector tile
423
 */
424
export function fromS2JSONFeature(feature: S2JSONFeature, extent: number): BaseVectorFeature {
154✔
425
  const { geometry, properties, id } = feature;
188✔
426
  const { type, is3D, coordinates, bbox, offset } = geometry;
244✔
427

428
  if (type === 'Point') {
98✔
429
    if (is3D)
52✔
430
      return new BaseVectorPoints3DFeature(
166✔
431
        [transformPoint3D(coordinates, extent)],
164✔
432
        properties,
48✔
433
        id,
16✔
434
        bbox as BBox3D,
16✔
435
      );
42✔
436
    else
437
      return new BaseVectorPointsFeature(
160✔
438
        [transformPoint(coordinates, extent)],
156✔
439
        properties,
48✔
440
        id,
16✔
441
        bbox as BBox,
16✔
442
      );
10✔
443
  } else if (type === 'MultiPoint') {
140✔
444
    if (is3D)
52✔
445
      return new BaseVectorPoints3DFeature(
166✔
446
        coordinates.map((p) => transformPoint3D(p, extent)),
208✔
447
        properties,
48✔
448
        id,
16✔
449
        bbox as BBox3D,
16✔
450
      );
42✔
451
    else
452
      return new BaseVectorPointsFeature(
160✔
453
        coordinates.map((p) => transformPoint(p, extent)),
200✔
454
        properties,
48✔
455
        id,
16✔
456
        bbox as BBox,
16✔
457
      );
10✔
458
  } else if (type === 'LineString') {
140✔
459
    if (is3D)
52✔
460
      return new BaseVectorLines3DFeature(
162✔
461
        [
36✔
462
          new BaseVectorLine(
76✔
463
            coordinates.map((p) => transformPoint3D(p, extent)),
208✔
464
            offset,
24✔
465
          ),
28✔
466
        ],
12✔
467
        properties,
48✔
468
        id,
16✔
469
        bbox as BBox3D,
16✔
470
      );
42✔
471
    else
472
      return new BaseVectorLinesFeature(
156✔
473
        [
36✔
474
          new BaseVectorLine(
76✔
475
            coordinates.map((p) => transformPoint(p, extent)),
200✔
476
            offset,
24✔
477
          ),
28✔
478
        ],
12✔
479
        properties,
48✔
480
        id,
16✔
481
        bbox as BBox,
16✔
482
      );
10✔
483
  } else if (type === 'MultiLineString') {
160✔
484
    if (is3D)
52✔
485
      return new BaseVectorLines3DFeature(
162✔
486
        coordinates.map((line, i) => {
122✔
487
          return new BaseVectorLine(
132✔
488
            line.map((p) => transformPoint3D(p, extent)),
180✔
489
            offset?.[i],
44✔
490
          );
20✔
491
        }),
12✔
492
        properties,
48✔
493
        id,
16✔
494
        bbox as BBox3D,
16✔
495
      );
42✔
496
    else
497
      return new BaseVectorLinesFeature(
156✔
498
        coordinates.map((line, i) => {
122✔
499
          return new BaseVectorLine(
132✔
500
            line.map((p) => transformPoint(p, extent)),
172✔
501
            offset?.[i],
44✔
502
          );
20✔
503
        }),
12✔
504
        properties,
48✔
505
        id,
16✔
506
        bbox as BBox,
16✔
507
      );
10✔
508
  } else if (type === 'Polygon') {
128✔
509
    const { indices, tesselation } = geometry;
184✔
510
    if (is3D)
52✔
511
      return new BaseVectorPolys3DFeature(
162✔
512
        [
36✔
513
          coordinates.map((line, i) => {
122✔
514
            return new BaseVectorLine(
140✔
515
              line.map((p) => transformPoint3D(p, extent)),
180✔
516
              offset?.[i],
44✔
517
            );
24✔
518
          }),
28✔
519
        ],
12✔
520
        indices,
36✔
521
        tesselation,
52✔
522
        properties,
48✔
523
        id,
16✔
524
        bbox as BBox3D,
16✔
525
      );
42✔
526
    else
527
      return new BaseVectorPolysFeature(
156✔
528
        [
36✔
529
          coordinates.map((line, i) => {
122✔
530
            return new BaseVectorLine(
140✔
531
              line.map((p) => transformPoint(p, extent)),
172✔
532
              offset?.[i],
44✔
533
            );
24✔
534
          }),
28✔
535
        ],
12✔
536
        indices,
36✔
537
        tesselation,
52✔
538
        properties,
48✔
539
        id,
16✔
540
        bbox as BBox,
16✔
541
      );
10✔
542
  } else if (type === 'MultiPolygon') {
148✔
543
    const { indices, tesselation } = geometry;
184✔
544
    if (is3D)
52✔
545
      return new BaseVectorPolys3DFeature(
162✔
546
        coordinates.map((poly, i) => {
122✔
547
          return poly.map((line, j) => {
150✔
548
            return new BaseVectorLine(
140✔
549
              line.map((p) => transformPoint3D(p, extent)),
180✔
550
              offset?.[i]?.[j],
64✔
551
            );
24✔
552
          });
20✔
553
        }),
12✔
554
        indices,
36✔
555
        tesselation,
52✔
556
        properties,
48✔
557
        id,
16✔
558
        bbox as BBox3D,
16✔
559
      );
42✔
560
    else
561
      return new BaseVectorPolysFeature(
156✔
562
        coordinates.map((poly, i) => {
122✔
563
          return poly.map((line, j) => {
150✔
564
            return new BaseVectorLine(
140✔
565
              line.map((p) => transformPoint(p, extent)),
172✔
566
              offset?.[i]?.[j],
64✔
567
            );
24✔
568
          });
20✔
569
        }),
12✔
570
        indices,
36✔
571
        tesselation,
52✔
572
        properties,
48✔
573
        id,
16✔
574
        bbox as BBox,
16✔
575
      );
10✔
576
  } else {
18✔
577
    throw new Error(`Unknown geometry type: ${type}`);
116✔
578
  }
579
}
580

581
/**
582
 * Transform a point in place to an extent
583
 * @param p - the point
584
 * @param extent - the extent
585
 * @returns - the transformed point
586
 */
587
function transformPoint(p: Point, extent: number): Point {
100✔
588
  const { round } = Math;
100✔
589
  return { x: round(p.x * extent), y: round(p.y * extent) };
238✔
590
}
591

592
/**
593
 * Transform a 3D point in place to an extent
594
 * @param p - the 3D point
595
 * @param extent - the extent
596
 * @returns - the transformed 3D point
597
 */
598
function transformPoint3D(p: Point | Point3D, extent: number): Point3D {
104✔
599
  const { round } = Math;
100✔
600
  return {
52✔
601
    x: round(p.x * extent),
108✔
602
    y: round(p.y * extent),
108✔
603
    z: round(('z' in p ? p.z : 0) * extent),
152✔
604
  };
12✔
605
}
606

607
/**
608
 * Encode offset values into a signed integer to reduce byte cost without too much loss
609
 * @param offset - float or double value to be compressed
610
 * @returns - a signed integer that saves 3 decimal places
611
 */
612
export function encodeOffset(offset: number): number {
108✔
613
  return Math.floor(offset * 1_000);
140✔
614
}
615

616
/**
617
 * Decode offset from a signed integer into a float or double
618
 * @param offset - the signed integer to be decompressed
619
 * @returns - a float or double that restores 3 decimal places
620
 */
621
export function decodeOffset(offset: number): number {
108✔
622
  return offset / 1_000;
90✔
623
}
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