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

iTowns / itowns / 4075703087

pending completion
4075703087

Pull #1996

github

GitHub
Merge eab53e51a into 7f86d26e1
Pull Request #1996: Elevation measure - tests

3165 of 4874 branches covered (64.94%)

Branch coverage included in aggregate %.

7 of 7 new or added lines in 1 file covered. (100.0%)

6975 of 8631 relevant lines covered (80.81%)

1508.61 hits per line

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

65.18
/src/Core/View.js
1
import * as THREE from 'three';
1✔
2
import Camera from 'Renderer/Camera';
1✔
3
import MainLoop, { MAIN_LOOP_EVENTS, RENDERING_PAUSED } from 'Core/MainLoop';
1✔
4
import Capabilities from 'Core/System/Capabilities';
1✔
5
import { COLOR_LAYERS_ORDER_CHANGED } from 'Renderer/ColorLayersOrdering';
1✔
6
import c3DEngine from 'Renderer/c3DEngine';
1✔
7
import RenderMode from 'Renderer/RenderMode';
1✔
8
import CRS from 'Core/Geographic/Crs';
1✔
9
import Coordinates from 'Core/Geographic/Coordinates';
1✔
10
import FeaturesUtils from 'Utils/FeaturesUtils';
1✔
11
import { getMaxColorSamplerUnitsCount } from 'Renderer/LayeredMaterial';
1✔
12
import Scheduler from 'Core/Scheduler/Scheduler';
1✔
13
import Picking from 'Core/Picking';
1✔
14
import LabelLayer from 'Layer/LabelLayer';
1✔
15
import ObjectRemovalHelper from 'Process/ObjectRemovalHelper';
1✔
16

17
export const VIEW_EVENTS = {
1✔
18
    /**
19
     * Fires when all the layers of the view are considered initialized.
20
     * Initialized in this context means: all layers are ready to be
21
     * displayed (no pending network access, no visual improvement to be
22
     * expected, ...).
23
     * If you add new layers, the event will be fired again when all
24
     * layers are ready.
25
     * @event View#layers-initialized
26
     * @property type {string} layers-initialized
27
     */
28
    LAYERS_INITIALIZED: 'layers-initialized',
29
    LAYER_REMOVED: 'layer-removed',
30
    LAYER_ADDED: 'layer-added',
31
    INITIALIZED: 'initialized',
32
    COLOR_LAYERS_ORDER_CHANGED,
33
    CAMERA_MOVED: 'camera-moved',
34
};
35

36
/**
37
 * Fired on current view's domElement when double right-clicking it. Copies all properties of the second right-click
38
 * MouseEvent (such as cursor position).
39
 * @event View#dblclick-right
40
 * @property {string} type  dblclick-right
41
 */
42

43
function _preprocessLayer(view, layer, parentLayer) {
44
    const source = layer.source;
71✔
45
    if (parentLayer && !layer.extent) {
71✔
46
        layer.extent = parentLayer.extent;
31✔
47
        if (source && !source.extent) {
31✔
48
            source.extent = parentLayer.extent;
28✔
49
        }
50
    }
51

52
    if (layer.isGeometryLayer) {
71✔
53
        // Find crs projection layer, this is projection destination
54
        layer.crs = view.referenceCrs;
63✔
55
    } else if (!layer.crs) {
8✔
56
        if (parentLayer && parentLayer.tileMatrixSets && parentLayer.tileMatrixSets.includes(CRS.formatToTms(source.crs))) {
3!
57
            layer.crs = source.crs;
3✔
58
        } else {
59
            layer.crs = parentLayer && parentLayer.extent.crs;
×
60
        }
61
    }
62

63
    if (layer.isLabelLayer) {
71✔
64
        view.mainLoop.gfxEngine.label2dRenderer.registerLayer(layer);
2✔
65
    } else if (layer.labelEnabled || layer.addLabelLayer) {
69✔
66
        if (layer.labelEnabled) {
2!
67
            // eslint-disable-next-line no-console
68
            console.info('layer.labelEnabled is deprecated use addLabelLayer, instead of');
×
69
        }
70
        // Because the features are shared between layer and labelLayer.
71
        layer.buildExtent = true;
2✔
72
        const labelLayer = new LabelLayer(`${layer.id}-label`, {
2✔
73
            source,
74
            style: layer.style,
75
            zoom: layer.zoom,
76
            crs: source.crs,
77
            visible: layer.visible,
78
            margin: 15,
79
        });
80

81
        layer.addEventListener('visible-property-changed', () => {
2✔
82
            labelLayer.visible = layer.visible;
×
83
        });
84

85
        const removeLabelLayer = (e) => {
2✔
86
            if (e.layerId === layer.id) {
×
87
                view.removeLayer(labelLayer.id);
×
88
            }
89
            view.removeEventListener(VIEW_EVENTS.LAYER_REMOVED, removeLabelLayer);
×
90
        };
91

92
        view.addEventListener(VIEW_EVENTS.LAYER_REMOVED, removeLabelLayer);
2✔
93

94
        layer.whenReady = layer.whenReady.then(() => {
2✔
95
            view.addLayer(labelLayer);
2✔
96
            return layer;
2✔
97
        });
98
    }
99

100
    return layer;
71✔
101
}
102
const _eventCoords = new THREE.Vector2();
1✔
103
const matrix = new THREE.Matrix4();
1✔
104
const screen = new THREE.Vector2();
1✔
105
const ray = new THREE.Ray();
1✔
106
const direction = new THREE.Vector3();
1✔
107
const positionVector = new THREE.Vector3();
1✔
108
const coordinates = new Coordinates('EPSG:4326');
1✔
109
const viewers = [];
1✔
110
// Size of the camera frustrum, in meters
111
let screenMeters;
112

113
class View extends THREE.EventDispatcher {
114
    #layers = [];
115
    #pixelDepthBuffer = new Uint8Array(4);
116
    #fullSizeDepthBuffer;
117
    /**
118
     * Constructs an Itowns View instance
119
     *
120
     * @example <caption><b>Create a view with a custom Three.js camera.</b></caption>
121
     * var viewerDiv = document.getElementById('viewerDiv');
122
     * var customCamera = itowns.THREE.PerspectiveCamera();
123
     * var view = itowns.View('EPSG:4326', viewerDiv, { camera: { cameraThree: customCamera } });
124
     *
125
     * @example <caption><b>Create a view with an orthographic camera, and grant it with Three.js custom controls.</b></caption>
126
     * var viewerDiv = document.getElementById('viewerDiv');
127
     * var view = itowns.View('EPSG:4326', viewerDiv, { camera: { type: itowns.CAMERA_TYPE.ORTHOGRAPHIC } });
128
     * var customControls = itowns.THREE.OrbitControls(view.camera.camera3D, viewerDiv);
129
     *
130
     * @example <caption><b>Enable WebGl 1.0 instead of WebGl 2.0.</b></caption>
131
     * var viewerDiv = document.getElementById('viewerDiv');
132
     * const extent = new Extent('EPSG:3946', 1837816.94334, 1847692.32501, 5170036.4587, 5178412.82698);
133
     * var view = new itowns.View('EPSG:4326', viewerDiv, {  renderer: { isWebGL2: false } });
134
     *
135
     * @param {string} crs - The default CRS of Three.js coordinates. Should be a cartesian CRS.
136
     * @param {HTMLElement} viewerDiv - Where to instanciate the Three.js scene in the DOM
137
     * @param {Object=} options - Optional properties.
138
     * @param {object} [options.camera] - Options for the camera associated to the view. See {@link Camera} options.
139
     * @param {?MainLoop} options.mainLoop - {@link MainLoop} instance to use, otherwise a default one will be constructed
140
     * @param {?(WebGLRenderer|object)} options.renderer - {@link WebGLRenderer} instance to use, otherwise
141
     * a default one will be constructed. In this case, if options.renderer is an object, it will be used to
142
     * configure the renderer (see {@link c3DEngine}.  If not present, a new &lt;canvas> will be created and
143
     * added to viewerDiv (mutually exclusive with mainLoop)
144
     * @param {boolean} [options.renderer.isWebGL2=true] - enable webgl 2.0 for THREE.js.
145
     * @param {?Scene} [options.scene3D] - [THREE.Scene](https://threejs.org/docs/#api/en/scenes/Scene) instance to use, otherwise a default one will be constructed
146
     * @param {?Color} options.diffuse - [THREE.Color](https://threejs.org/docs/?q=color#api/en/math/Color) Diffuse color terrain material.
147
     * This color is applied to terrain if there isn't color layer on terrain extent (by example on pole).
148
     * @param {boolean} [options.enableFocusOnStart=true] - enable focus on dom element on start.
149
     *
150
     * @constructor
151
     */
152
    constructor(crs, viewerDiv, options = {}) {
185!
153
        if (!viewerDiv) {
37!
154
            throw new Error('Invalid viewerDiv parameter (must non be null/undefined)');
×
155
        }
156

157
        super();
37✔
158

159
        this.domElement = viewerDiv;
37✔
160

161
        this.referenceCrs = crs;
37✔
162

163
        let engine;
164
        // options.renderer can be 2 separate things:
165
        //   - an actual renderer (in this case we don't use viewerDiv)
166
        //   - options for the renderer to be created
167
        if (options.renderer && options.renderer.domElement) {
37!
168
            engine = new c3DEngine(options.renderer);
37✔
169
        } else {
170
            engine = new c3DEngine(viewerDiv, options.renderer);
×
171
        }
172

173
        this.mainLoop = options.mainLoop || new MainLoop(new Scheduler(), engine);
37✔
174

175
        this.scene = options.scene3D || new THREE.Scene();
37✔
176
        if (!options.scene3D) {
37!
177
            this.scene.matrixWorldAutoUpdate = false;
37✔
178
        }
179

180
        this.camera = new Camera(
37✔
181
            this.referenceCrs,
182
            this.mainLoop.gfxEngine.getWindowSize().x,
183
            this.mainLoop.gfxEngine.getWindowSize().y,
184
            options.camera);
185

186
        this._frameRequesters = { };
37✔
187

188
        window.addEventListener('resize', () => this.resize(), false);
37✔
189

190
        this._changeSources = new Set();
37✔
191

192
        if (__DEBUG__) {
193
            this.isDebugMode = true;
194
        }
195

196
        this._delayedFrameRequesterRemoval = [];
37✔
197

198
        this._allLayersAreReadyCallback = () => {
37✔
199
            // all layers must be ready
200
            const allReady = this.getLayers().every(layer => layer.ready);
×
201
            if (allReady &&
×
202
                    this.mainLoop.scheduler.commandsWaitingExecutionCount() == 0 &&
203
                    this.mainLoop.renderingState == RENDERING_PAUSED) {
204
                this.dispatchEvent({ type: VIEW_EVENTS.LAYERS_INITIALIZED });
×
205
                this.removeFrameRequester(MAIN_LOOP_EVENTS.UPDATE_END, this._allLayersAreReadyCallback);
×
206
            }
207
        };
208

209
        this.camera.resize(this.domElement.clientWidth, this.domElement.clientHeight);
37✔
210

211
        const fn = () => {
37✔
212
            this.removeEventListener(VIEW_EVENTS.LAYERS_INITIALIZED, fn);
×
213
            this.dispatchEvent({ type: VIEW_EVENTS.INITIALIZED });
×
214
        };
215

216
        this.addEventListener(VIEW_EVENTS.LAYERS_INITIALIZED, fn);
37✔
217

218
        this.#fullSizeDepthBuffer = new Uint8Array(4 * this.camera.width * this.camera.height);
37✔
219

220
        // Indicates that view's domElement can be focused (the negative value indicates that domElement can't be
221
        // focused sequentially using tab key). Focus is needed to capture some key events.
222
        this.domElement.tabIndex = -1;
37✔
223
        // Set focus on view's domElement.
224
        if (!options.disableFocusOnStart) {
37!
225
            this.domElement.focus();
37✔
226
        }
227

228
        // Create a custom `dblclick-right` event that is triggered when double right-clicking
229
        let rightClickTimeStamp;
230
        this.domElement.addEventListener('mouseup', (event) => {
37✔
231
            if (event.button === 2) {  // If pressed mouse button is right button
×
232
                // If time between two right-clicks is bellow 500 ms, triggers a `dblclick-right` event
233
                if (rightClickTimeStamp && event.timeStamp - rightClickTimeStamp < 500) {
×
234
                    this.domElement.dispatchEvent(new MouseEvent('dblclick-right', event));
×
235
                }
236
                rightClickTimeStamp = event.timeStamp;
×
237
            }
238
        });
239

240

241
        // push all viewer to keep source.cache
242
        viewers.push(this);
37✔
243
    }
244

245
    /**
246
     * Dispose viewer before delete it.
247
     *
248
     * Method dispose all viewer objects
249
     * - remove control
250
     * - remove all layers
251
     * - remove all frame requester
252
     * - remove all events
253
     * @param {boolean} [clearCache=false] Whether to clear all the caches or not (layers cache, style cache, tilesCache)
254
     */
255
    dispose(clearCache = false) {
9!
256
        const id = viewers.indexOf(this);
1✔
257
        if (id == -1) {
1!
258
            console.warn('View already disposed');
×
259
            return;
×
260
        }
261
        // controls dispose
262
        if (this.controls) {
1!
263
            if (typeof this.controls.dispose === 'function') {
×
264
                this.controls.dispose();
×
265
            }
266
            delete this.controls;
×
267
        }
268
        // remove alls frameRequester
269
        this.removeAllFrameRequesters();
1✔
270
        // remove alls events
271
        this.removeAllEvents();
1✔
272
        // remove all layers
273
        const layers = this.getLayers(l => !l.isTiledGeometryLayer && !l.isAtmosphere);
1!
274
        for (const layer of layers) {
1✔
275
            this.removeLayer(layer.id, clearCache);
×
276
        }
277
        const atmospheres = this.getLayers(l => l.isAtmosphere);
1✔
278
        for (const atmosphere of atmospheres) {
1✔
279
            this.removeLayer(atmosphere.id, clearCache);
×
280
        }
281
        const tileLayers = this.getLayers(l => l.isTiledGeometryLayer);
1✔
282
        for (const tileLayer of tileLayers) {
1✔
283
            this.removeLayer(tileLayer.id, clearCache);
1✔
284
        }
285
        viewers.splice(id, 1);
1✔
286
        // Remove remaining objects in the scene (e.g. helpers, debug, etc.)
287
        this.scene.traverse(ObjectRemovalHelper.cleanup);
1✔
288
    }
289

290
    /**
291
     * Add layer in viewer.
292
     * The layer id must be unique.
293
     *
294
     * The `layer.whenReady` is a promise that resolves when
295
     * the layer is done. This promise is also returned by
296
     * `addLayer` allowing to chain call.
297
     *
298
     * @param {LayerOptions|Layer|GeometryLayer} layer The layer to add in view.
299
     * @param {Layer=} parentLayer it's the layer to which the layer will be attached.
300
     * @return {Promise} a promise resolved with the new layer object when it is fully initialized or rejected if any error occurred.
301
     */
302
    addLayer(layer, parentLayer) {
71✔
303
        if (!layer || !layer.isLayer) {
71!
304
            return Promise.reject(new Error('Add Layer type object'));
×
305
        }
306
        const duplicate = this.getLayerById(layer.id);
71✔
307
        if (duplicate) {
71!
308
            return layer._reject(new Error(`Invalid id '${layer.id}': id already used`));
×
309
        }
310

311
        layer = _preprocessLayer(this, layer, parentLayer);
71✔
312

313
        if (parentLayer) {
71✔
314
            if (layer.isColorLayer) {
32✔
315
                const layerColors = this.getLayers(l => l.isColorLayer);
7✔
316
                layer.sequence = layerColors.length;
4✔
317

318
                const sumColorLayers = parentLayer.countColorLayersTextures(...layerColors, layer);
4✔
319

320
                if (sumColorLayers <= getMaxColorSamplerUnitsCount()) {
4!
321
                    parentLayer.attach(layer);
4✔
322
                } else {
323
                    return layer._reject(new Error(`Cant add color layer ${layer.id}: the maximum layer is reached`));
×
324
                }
325
            } else if (layer.isElevationLayer && layer.source.format == 'image/x-bil;bits=32') {
28✔
326
                layer.source.networkOptions.isWebGL2 = this.mainLoop.gfxEngine.renderer.capabilities.isWebGL2;
1✔
327
                parentLayer.attach(layer);
1✔
328
            } else {
329
                parentLayer.attach(layer);
27✔
330
            }
331
        } else {
332
            if (typeof (layer.update) !== 'function') {
39!
333
                return layer._reject(new Error('Cant add GeometryLayer: missing a update function'));
×
334
            }
335
            if (typeof (layer.preUpdate) !== 'function') {
39!
336
                return layer._reject(new Error('Cant add GeometryLayer: missing a preUpdate function'));
×
337
            }
338

339
            this.#layers.push(layer);
39✔
340
        }
341

342
        if (layer.object3d && !layer.object3d.parent && layer.object3d !== this.scene) {
71✔
343
            this.scene.add(layer.object3d);
58✔
344
        }
345

346
        Promise.all(layer._promises).then(() => {
71✔
347
            layer._resolve();
71✔
348
            this.notifyChange(parentLayer || layer, false);
71✔
349
            if (!this._frameRequesters[MAIN_LOOP_EVENTS.UPDATE_END] ||
71✔
350
                !this._frameRequesters[MAIN_LOOP_EVENTS.UPDATE_END].includes(this._allLayersAreReadyCallback)) {
351
                this.addFrameRequester(MAIN_LOOP_EVENTS.UPDATE_END, this._allLayersAreReadyCallback);
29✔
352
            }
353
            this.dispatchEvent({
71✔
354
                type: VIEW_EVENTS.LAYER_ADDED,
355
                layerId: layer.id,
356
            });
357
        }, layer._reject);
358

359
        return layer.whenReady;
71✔
360
    }
361

362
    /**
363
     * Removes a specific imagery layer from the current layer list. This removes layers inserted with attach().
364
     * @example
365
     * view.removeLayer('layerId');
366
     * @param {string} layerId The identifier
367
     * @param {boolean} [clearCache=false] Whether to clear all the layer cache or not
368
     * @return {boolean}
369
     */
370
    removeLayer(layerId, clearCache) {
371
        const layer = this.getLayerById(layerId);
2✔
372
        if (layer) {
6!
373
            const parentLayer = layer.parent;
2✔
374

375
            // Remove and dispose all nodes
376
            layer.delete(clearCache);
2✔
377

378
            // Detach layer if it's attached
379
            if (parentLayer && !parentLayer.detach(layer)) {
2!
380
                throw new Error(`Error to detach ${layerId} from ${parentLayer.id}`);
×
381
            } else if (parentLayer == undefined) {
2!
382
                // Remove layer from viewer
383
                this.#layers.splice(this.#layers.findIndex(l => l.id == layerId), 1);
2✔
384
            }
385
            if (layer.isColorLayer) {
2!
386
                // Update color layers sequence
387
                const imageryLayers = this.getLayers(l => l.isColorLayer);
×
388
                for (const color of imageryLayers) {
×
389
                    if (color.sequence > layer.sequence) {
×
390
                        color.sequence--;
×
391
                    }
392
                }
393
            }
394

395
            // Remove unused cache in all viewers
396

397
            // count of times the source is used in all viewer
398
            let sharedSourceCount = 0;
2✔
399
            for (const view of viewers) {
58✔
400
                // add count of times the source is used in other layers
401
                sharedSourceCount += view.getLayers(l => l.source.uid == layer.source.uid && l.crs == layer.crs).length;
126✔
402
            }
403
            // if sharedSourceCount equals to 0 so remove unused cache for this CRS
404
            layer.source.onLayerRemoved({ unusedCrs: sharedSourceCount == 0 ? layer.crs : undefined });
2!
405

406
            this.notifyChange(this.camera);
2✔
407

408
            this.dispatchEvent({
2✔
409
                type: VIEW_EVENTS.LAYER_REMOVED,
410
                layerId,
411
            });
412

413
            return true;
2✔
414
        } else {
415
            throw new Error(`${layerId} doesn't exist`);
×
416
        }
417
    }
418

419
    /**
420
     * Notifies the scene it needs to be updated due to changes exterior to the
421
     * scene itself (e.g. camera movement).
422
     * non-interactive events (e.g: texture loaded)
423
     * @param {*} changeSource
424
     * @param {boolean} needsRedraw - indicates if notified change requires a full scene redraw.
425
     */
426
    notifyChange(changeSource = undefined, needsRedraw = true) {
169✔
427
        if (changeSource) {
169✔
428
            this._changeSources.add(changeSource);
164✔
429
            if ((changeSource.isTileMesh || changeSource.isCamera)) {
164✔
430
                this.#fullSizeDepthBuffer.needsUpdate = true;
80✔
431
            }
432
        }
433
        this.mainLoop.scheduleViewUpdate(this, needsRedraw);
169✔
434
    }
435

436
    /**
437
     * Get all layers, with an optionnal filter applied.
438
     * The filter method will be called with 2 args:
439
     *   - 1st: current layer
440
     *   - 2nd: (optional) the geometry layer to which the current layer is attached
441
     * @example
442
     * // get all layers
443
     * view.getLayers();
444
     * // get all color layers
445
     * view.getLayers(layer => layer.isColorLayer);
446
     * // get all elevation layers
447
     * view.getLayers(layer => layer.isElevationLayer);
448
     * // get all geometry layers
449
     * view.getLayers(layer => layer.isGeometryLayer);
450
     * // get one layer with id
451
     * view.getLayers(layer => layer.id === 'itt');
452
     * @param {function(Layer):boolean} filter
453
     * @returns {Array<Layer>}
454
     */
455
    getLayers(filter) {
1,401✔
456
        const result = [];
467✔
457
        for (const layer of this.#layers) {
467✔
458
            if (!filter || filter(layer)) {
466✔
459
                result.push(layer);
326✔
460
            }
461
            if (layer.attachedLayers) {
1,368✔
462
                for (const attached of layer.attachedLayers) {
456✔
463
                    if (!filter || filter(attached, layer)) {
274✔
464
                        result.push(attached);
10✔
465
                    }
466
                }
467
            }
468
        }
469
        return result;
467✔
470
    }
471

472
    /**
473
     * Gets the layer by identifier.
474
     *
475
     * @param {String}  layerId  The layer identifier
476
     * @return {Layer}  The layer by identifier.
477
     */
478

479
    getLayerById(layerId) {
480
        return this.getLayers(l => l.id === layerId)[0];
94✔
481
    }
482

483
    /**
484
     * @name FrameRequester
485
     * @function
486
     *
487
     * @description
488
     * Method that will be called each time the `MainLoop` updates. This function
489
     * will be given as parameter the delta (in ms) between this update and the
490
     * previous one, and whether or not we just started to render again. This update
491
     * is considered as the "next" update if `view.notifyChange` was called during a
492
     * precedent update. If `view.notifyChange` has been called by something else
493
     * (other micro/macrotask, UI events etc...), then this update is considered as
494
     * being the "first". It can also receive optional arguments, depending on the
495
     * attach point of this function. Currently only `BEFORE_LAYER_UPDATE /
496
     * AFTER_LAYER_UPDATE` attach points provide an additional argument: the layer
497
     * being updated.
498
     * <br><br>
499
     *
500
     * This means that if a `frameRequester` function wants to animate something, it
501
     * should keep on calling `view.notifyChange` until its task is done.
502
     * <br><br>
503
     *
504
     * Implementors of `frameRequester` should keep in mind that this function will
505
     * be potentially called at each frame, thus care should be given about
506
     * performance.
507
     * <br><br>
508
     *
509
     * Typical frameRequesters are controls, module wanting to animate moves or UI
510
     * elements etc... Basically anything that would want to call
511
     * requestAnimationFrame.
512
     *
513
     * @param {number} dt
514
     * @param {boolean} updateLoopRestarted
515
     * @param {...*} args
516
     */
517
    /**
518
     * Add a frame requester to this view.
519
     *
520
     * FrameRequesters can activate the MainLoop update by calling view.notifyChange.
521
     *
522
     * @param {String} when - decide when the frameRequester should be called during
523
     * the update cycle. Can be any of {@link MAIN_LOOP_EVENTS}.
524
     * @param {FrameRequester} frameRequester - this function will be called at each
525
     * MainLoop update with the time delta between last update, or 0 if the MainLoop
526
     * has just been relaunched.
527
     */
528
    addFrameRequester(when, frameRequester) {
529
        if (typeof frameRequester !== 'function') {
66!
530
            throw new Error('frameRequester must be a function');
×
531
        }
532

533
        if (!this._frameRequesters[when]) {
66✔
534
            this._frameRequesters[when] = [frameRequester];
56✔
535
        } else {
536
            this._frameRequesters[when].push(frameRequester);
10✔
537
        }
538
    }
539

540
    /**
541
     * Remove a frameRequester.
542
     * The effective removal will happen either later; at worst it'll be at
543
     * the beginning of the next frame.
544
     *
545
     * @param {String} when - attach point of this requester. Can be any of
546
     * {@link MAIN_LOOP_EVENTS}.
547
     * @param {FrameRequester} frameRequester
548
     */
549
    removeFrameRequester(when, frameRequester) {
550
        if (this._frameRequesters[when].includes(frameRequester)) {
12!
551
            this._delayedFrameRequesterRemoval.push({ when, frameRequester });
12✔
552
        } else {
553
            console.error('Invalid call to removeFrameRequester: frameRequester isn\'t registered');
×
554
        }
555
    }
556

557
    /**
558
     * Removes all frame requesters.
559
     */
560
    removeAllFrameRequesters() {
561
        for (const when in this._frameRequesters) {
1✔
562
            if (Object.prototype.hasOwnProperty.call(this._frameRequesters, when)) {
×
563
                const frameRequesters = this._frameRequesters[when];
×
564
                for (const frameRequester of frameRequesters) {
×
565
                    this.removeFrameRequester(when, frameRequester);
×
566
                }
567
            }
568
        }
569
        this._executeFrameRequestersRemovals();
1✔
570
    }
571

572
    /**
573
     * Removes all viewer events.
574
     */
575
    removeAllEvents() {
576
        if (this._listeners === undefined) {
1!
577
            return;
×
578
        }
579

580
        for (const type in this._listeners) {
1✔
581
            if (Object.prototype.hasOwnProperty.call(this._listeners, type)) {
1!
582
                delete this._listeners[type];
1✔
583
            }
584
        }
585

586
        this._listeners = undefined;
1✔
587
    }
588

589
    _executeFrameRequestersRemovals() {
9✔
590
        for (const toDelete of this._delayedFrameRequesterRemoval) {
3✔
591
            const index = this._frameRequesters[toDelete.when].indexOf(toDelete.frameRequester);
×
592
            if (index >= 0) {
×
593
                this._frameRequesters[toDelete.when].splice(index, 1);
×
594
            } else {
595
                console.warn('FrameReq has already been removed');
×
596
            }
597
        }
598
        this._delayedFrameRequesterRemoval.length = 0;
3✔
599
    }
600

601
    /**
602
     * Execute a frameRequester.
603
     *
604
     * @param {String} when - attach point of this (these) requester(s). Can be any
605
     * of {@link MAIN_LOOP_EVENTS}.
606
     * @param {Number} dt - delta between this update and the previous one
607
     * @param {boolean} updateLoopRestarted
608
     * @param {...*} args - optional arguments
609
     */
610
    execFrameRequesters(when, dt, updateLoopRestarted, ...args) {
7!
611
        if (!this._frameRequesters[when]) {
14✔
612
            return;
13✔
613
        }
614

615
        if (this._delayedFrameRequesterRemoval.length > 0) {
1!
616
            this._executeFrameRequestersRemovals();
×
617
        }
618

619
        for (const frameRequester of this._frameRequesters[when]) {
1✔
620
            if (frameRequester.update) {
1!
621
                frameRequester.update(dt, updateLoopRestarted, args);
×
622
            } else {
623
                frameRequester(dt, updateLoopRestarted, args);
1✔
624
            }
625
        }
626
    }
627

628
    /**
629
     * Extract view coordinates from a mouse-event / touch-event
630
     * @param {event} event - event can be a MouseEvent or a TouchEvent
631
     * @param {THREE.Vector2} target - the target to set the view coords in
632
     * @param {number} [touchIdx=0] - finger index when using a TouchEvent
633
     * @return {THREE.Vector2|undefined} - view coordinates (in pixels, 0-0 = top-left of the View).
634
     * If the event is neither a `MouseEvent` nor a `TouchEvent`, the return is `undefined`.
635
     */
636
    eventToViewCoords(event, target = _eventCoords, touchIdx = 0) {
48!
637
        const br = this.domElement.getBoundingClientRect();
48✔
638

639
        if (event.touches && event.touches.length) {
48✔
640
            return target.set(event.touches[touchIdx].clientX - br.x,
30✔
641
                event.touches[touchIdx].clientY - br.y);
642
        } else if (event.offsetX !== undefined && event.offsetY !== undefined) {
18!
643
            const targetBoundingRect = event.target.getBoundingClientRect();
18✔
644
            return target.set(targetBoundingRect.x + event.offsetX - br.x,
18✔
645
                targetBoundingRect.y + event.offsetY - br.y);
646
        }
647
    }
648

649
    /**
650
     * Extract normalized coordinates (NDC) from a mouse-event / touch-event
651
     * @param {event} event - event can be a MouseEvent or a TouchEvent
652
     * @param {number} touchIdx - finger index when using a TouchEvent (default: 0)
653
     * @return {THREE.Vector2} - NDC coordinates (x and y are [-1, 1])
654
     */
655
    eventToNormalizedCoords(event, touchIdx = 0) {
×
656
        return this.viewToNormalizedCoords(this.eventToViewCoords(event, _eventCoords, touchIdx));
×
657
    }
658

659
    /**
660
     * Convert view coordinates to normalized coordinates (NDC)
661
     * @param {THREE.Vector2} viewCoords (in pixels, 0-0 = top-left of the View)
662
     * @param {THREE.Vector2} target
663
     * @return {THREE.Vector2} - NDC coordinates (x and y are [-1, 1])
664
     */
665
    viewToNormalizedCoords(viewCoords, target = _eventCoords) {
5✔
666
        target.x = 2 * (viewCoords.x / this.camera.width) - 1;
5✔
667
        target.y = -2 * (viewCoords.y / this.camera.height) + 1;
5✔
668
        return target;
5✔
669
    }
670

671
    /**
672
     * Convert NDC coordinates to view coordinates
673
     * @param {THREE.Vector2} ndcCoords
674
     * @return {THREE.Vector2} - view coordinates (in pixels, 0-0 = top-left of the View)
675
     */
676
    normalizedToViewCoords(ndcCoords) {
677
        _eventCoords.x = (ndcCoords.x + 1) * 0.5 * this.camera.width;
×
678
        _eventCoords.y = (ndcCoords.y - 1) * -0.5 * this.camera.height;
×
679
        return _eventCoords;
×
680
    }
681

682
    /**
683
     * Searches for objects in {@link GeometryLayer} and specified
684
     * `THREE.Object3D`, under the mouse or at a specified coordinates, in this
685
     * view.
686
     *
687
     * @param {Object} mouseOrEvt - Mouse position in window coordinates (from
688
     * the top left corner of the window) or `MouseEvent` or `TouchEvent`.
689
     * @param {number} [radius=0] - The picking will happen in a circle centered
690
     * on mouseOrEvt. This is the radius of this circle, in pixels.
691
     * @param {...GeometryLayer|string|Object3D} [where] - Where to look for
692
     * objects. It can be anything of {@link GeometryLayer}, IDs of layers, or
693
     * `THREE.Object3D`. If no location is specified, it will query on all
694
     * {@link GeometryLayer} present in this `View`.
695
     *
696
     * @return {Object[]} - An array of objects. Each element contains at least
697
     * an object property which is the `THREE.Object3D` under the cursor. Then
698
     * depending on the queried layer/source, there may be additionnal
699
     * properties (coming from `THREE.Raycaster` for instance).
700
     *
701
     * @example
702
     * view.pickObjectsAt({ x, y })
703
     * view.pickObjectsAt({ x, y }, 1, 'wfsBuilding')
704
     * view.pickObjectsAt({ x, y }, 3, 'wfsBuilding', myLayer)
705
     */
706
    pickObjectsAt(mouseOrEvt, radius = 0, ...where) {
5!
707
        const sources = [];
1✔
708

709
        where = where.length == 0 ? this.getLayers(l => l.isGeometryLayer) : where;
1!
710
        where.forEach((l) => {
1✔
711
            if (typeof l === 'string') {
1!
712
                l = this.getLayerById(l);
×
713
            }
714

715
            if (l && (l.isGeometryLayer || l.isObject3D)) {
1!
716
                sources.push(l);
1✔
717
            }
718
        });
719

720
        if (sources.length == 0) {
1!
721
            return [];
×
722
        }
723

724
        const results = [];
1✔
725
        const mouse = (mouseOrEvt instanceof Event) ? this.eventToViewCoords(mouseOrEvt) : mouseOrEvt;
1!
726

727
        for (const source of sources) {
1✔
728
            if (source.isAtmosphere) {
1!
729
                continue;
×
730
            }
731
            if (source.isGeometryLayer) {
1!
732
                if (!source.ready) {
1!
733
                    console.warn('view.pickObjectAt : layer is not ready : ', source);
×
734
                    continue;
×
735
                }
736

737
                source.pickObjectsAt(this, mouse, radius, results);
1✔
738
            } else {
739
                Picking.pickObjectsAt(this, mouse, radius, source, results);
×
740
            }
741
        }
742

743
        return results;
1✔
744
    }
745

746
    /**
747
     * Return the current zoom scale at the central point of the view. This
748
     * function compute the scale of a map.
749
     *
750
     * @param {number} pitch - Screen pitch, in millimeters ; 0.28 by default
751
     *
752
     * @return {number} The zoom scale.
753
     */
754
    getScale(pitch = 0.28) {
2!
755
        if (this.camera.camera3D.isOrthographicCamera) {
2!
756
            return pitch * 1E-3 / this.getPixelsToMeters();
×
757
        }
758
        return this.getScaleFromDistance(pitch, this.getDistanceFromCamera());
2✔
759
    }
760

761
    getScaleFromDistance(pitch = 0.28, distance = 1) {
3!
762
        pitch /= 1000;
3✔
763
        const fov = THREE.MathUtils.degToRad(this.camera.camera3D.fov);
3✔
764
        const unit = this.camera.height / (2 * distance * Math.tan(fov * 0.5));
3✔
765
        return pitch * unit;
3✔
766
    }
767

768
    /**
769
     * Given a screen coordinates, get the distance between the projected
770
     * coordinates and the camera associated to this view.
771
     *
772
     * @param {THREE.Vector2} [screenCoord] - The screen coordinate to get the
773
     * distance at. By default this is the middle of the screen.
774
     *
775
     * @return {number} The distance in meters.
776
     */
777
    getDistanceFromCamera(screenCoord) {
778
        this.getPickingPositionFromDepth(screenCoord, positionVector);
7✔
779
        return this.camera.camera3D.position.distanceTo(positionVector);
7✔
780
    }
781

782
    /**
783
     * Get, for a specific screen coordinate, the projected distance on the
784
     * surface of the main layer of the view.
785
     *
786
     * @param {number} [pixels=1] - The size, in pixels, to get in meters.
787
     * @param {THREE.Vector2} [screenCoord] - The screen coordinate to get the
788
     * projected distance at. By default, this is the middle of the screen.
789
     *
790
     * @return {number} The projected distance in meters.
791
     */
792
    getPixelsToMeters(pixels = 1, screenCoord) {
7✔
793
        if (this.camera.camera3D.isOrthographicCamera) {
7✔
794
            screenMeters = (this.camera.camera3D.right - this.camera.camera3D.left) / this.camera.camera3D.zoom;
4✔
795
            return pixels * screenMeters / this.camera.width;
4✔
796
        }
797
        return this.getPixelsToMetersFromDistance(pixels, this.getDistanceFromCamera(screenCoord));
3✔
798
    }
799

800
    getPixelsToMetersFromDistance(pixels = 1, distance = 1) {
3!
801
        return pixels * distance / this.camera._preSSE;
3✔
802
    }
803

804
    /**
805
     * Get, for a specific screen coordinate, the size in pixels of a projected
806
     * distance on the surface of the main layer of the view.
807
     *
808
     * @param {number} [meters=1] - The size, in meters, to get in pixels.
809
     * @param {THREE.Vector2} [screenCoord] - The screen coordinate to get the
810
     * projected distance at. By default, this is the middle of the screen.
811
     *
812
     * @return {number} The projected distance in pixels.
813
     */
814
    getMetersToPixels(meters = 1, screenCoord) {
1!
815
        if (this.camera.camera3D.isOrthographicCamera) {
1!
816
            screenMeters = (this.camera.camera3D.right - this.camera.camera3D.left) / this.camera.camera3D.zoom;
×
817
            return meters * this.camera.width / screenMeters;
×
818
        }
819
        return this.getMetersToPixelsFromDistance(meters, this.getDistanceFromCamera(screenCoord));
1✔
820
    }
821

822
    getMetersToPixelsFromDistance(meters = 1, distance = 1) {
1!
823
        return this.camera._preSSE * meters / distance;
1✔
824
    }
825

826
    /**
827
     * Searches for {@link Feature} in {@link ColorLayer}, under the mouse of at
828
     * a specified coordinates, in this view.
829
     *
830
     * @param {Object} mouseOrEvt - Mouse position in window coordinates (from
831
     * the top left corner of the window) or `MouseEvent` or `TouchEvent`.
832
     * @param {number} [radius=3] - The picking will happen in a circle centered
833
     * on mouseOrEvt. This is the radius of this circle, in pixels.
834
     * @param {...ColorLayer|GeometryLayer|string} [where] - The layers to look
835
     * into. If not specified, all {@link ColorLayer} and {@link GeometryLayer}
836
     * layers of this view will be looked in.
837
     *
838
     * @return {Object} - An object, with a property per layer. For example,
839
     * looking for features on layers `wfsBuilding` and `wfsRoads` will give an
840
     * object like `{ wfsBuilding: [...], wfsRoads: [] }`. Each property is made
841
     * of an array, that can be empty or filled with found features.
842
     *
843
     * @example
844
     * view.pickFeaturesAt({ x, y });
845
     * view.pickFeaturesAt({ x, y }, 1, 'wfsBuilding');
846
     * view.pickFeaturesAt({ x, y }, 3, 'wfsBuilding', myLayer);
847
     */
848
    pickFeaturesAt(mouseOrEvt, radius = 3, ...where) {
×
849
        if (Array.isArray(where[0])) {
×
850
            console.warn('Deprecated: the ...where argument of View#pickFeaturesAt should not be an array anymore, but a list: use the spread operator if needed.');
×
851
            where = where[0];
×
852
        }
853

854
        const layers = [];
×
855
        const result = {};
×
856

857
        where = where.length == 0 ? this.getLayers(l => l.isColorLayer || l.isGeometryLayer) : where;
×
858
        where.forEach((l) => {
×
859
            if (typeof l === 'string') {
×
860
                l = this.getLayerById(l);
×
861
            }
862

863
            if (l && l.isLayer) {
×
864
                result[l.id] = [];
×
865
                if (l.isColorLayer) { layers.push(l.id); }
×
866
            }
867
        });
868

869
        // Get the mouse coordinates to the correct system
870
        const mouse = (mouseOrEvt instanceof Event) ? this.eventToViewCoords(mouseOrEvt, _eventCoords) : mouseOrEvt;
×
871
        const objects = this.pickObjectsAt(mouse, radius, ...where);
×
872

873
        if (objects.length > 0) {
×
874
            objects.forEach(o => result[o.layer.id].push(o));
×
875
        }
876

877
        if (layers.length == 0) {
×
878
            return result;
×
879
        }
880

881
        this.getPickingPositionFromDepth(mouse, positionVector);
×
882
        coordinates.crs = this.referenceCrs;
×
883
        coordinates.setFromVector3(positionVector);
×
884

885
        // Get the correct precision; the position variable will be set in this
886
        // function.
887
        let precision;
888
        const precisions = {
×
889
            M: this.getPixelsToMeters(radius, mouse),
890
            D: 0.001 * radius,
891
        };
892

893
        if (this.isPlanarView) {
×
894
            precisions.D = precisions.M;
×
895
        } else if (this.getPixelsToDegrees) {
×
896
            precisions.D = this.getMetersToDegrees(precisions.M);
×
897
        }
898

899
        // Get the tile corresponding to where the cursor is
900
        const tiles = Picking.pickTilesAt(this, mouse, radius, this.tileLayer);
×
901

902
        for (const tile of tiles) {
×
903
            if (!tile.object.material) {
×
904
                continue;
×
905
            }
906

907
            for (const materialLayer of tile.object.material.getLayers(layers)) {
×
908
                for (const texture of materialLayer.textures) {
×
909
                    if (!texture.features) {
×
910
                        continue;
×
911
                    }
912

913
                    precision = CRS.isMetricUnit(texture.features.crs) ? precisions.M : precisions.D;
×
914

915
                    result[materialLayer.id] = result[materialLayer.id].concat(
×
916
                        FeaturesUtils.filterFeaturesUnderCoordinate(coordinates, texture.features, precision));
917
                }
918
            }
919
        }
920

921
        return result;
×
922
    }
923

924
    readDepthBuffer(x, y, width, height, buffer) {
925
        const g = this.mainLoop.gfxEngine;
66✔
926
        const currentWireframe = this.tileLayer.wireframe;
66✔
927
        const currentOpacity = this.tileLayer.opacity;
66✔
928
        const currentVisibility = this.tileLayer.visible;
66✔
929
        if (currentWireframe) {
66!
930
            this.tileLayer.wireframe = false;
×
931
        }
932
        if (currentOpacity < 1.0) {
66!
933
            this.tileLayer.opacity = 1.0;
×
934
        }
935
        if (!currentVisibility) {
66✔
936
            this.tileLayer.visible = true;
1✔
937
        }
938

939
        const restore = this.tileLayer.level0Nodes.map(n => RenderMode.push(n, RenderMode.MODES.DEPTH));
110✔
940
        buffer = g.renderViewToBuffer(
66✔
941
            { camera: this.camera, scene: this.tileLayer.object3d },
942
            { x, y, width, height, buffer });
943
        restore.forEach(r => r());
110✔
944

945
        if (this.tileLayer.wireframe !== currentWireframe) {
66!
946
            this.tileLayer.wireframe = currentWireframe;
×
947
        }
948
        if (this.tileLayer.opacity !== currentOpacity) {
66!
949
            this.tileLayer.opacity = currentOpacity;
×
950
        }
951
        if (this.tileLayer.visible !== currentVisibility) {
66✔
952
            this.tileLayer.visible = currentVisibility;
1✔
953
        }
954

955
        return buffer;
66✔
956
    }
957

958
    /**
959
     * Returns the world position (view's crs: referenceCrs) under view coordinates.
960
     * This position is computed with depth buffer.
961
     *
962
     * @param      {THREE.Vector2}  mouse  position in view coordinates (in pixel), if it's null so it's view's center.
963
     * @param      {THREE.Vector3}  [target=THREE.Vector3()] target. the result will be copied into this Vector3. If not present a new one will be created.
964
     * @return     {THREE.Vector3}  the world position in view's crs: referenceCrs.
965
     */
966

967
    getPickingPositionFromDepth(mouse, target = new THREE.Vector3()) {
102✔
968
        if (!this.tileLayer || this.tileLayer.level0Nodes.length == 0 || (!this.tileLayer.level0Nodes[0])) {
102✔
969
            target = undefined;
36✔
970
            return;
36✔
971
        }
972
        const l = this.mainLoop;
66✔
973
        const viewPaused = l.scheduler.commandsWaitingExecutionCount() == 0 && l.renderingState == RENDERING_PAUSED;
66✔
974
        const g = l.gfxEngine;
66✔
975
        const dim = g.getWindowSize();
66✔
976
        const camera = this.camera.camera3D;
66✔
977

978
        mouse = mouse || dim.clone().multiplyScalar(0.5);
66✔
979
        mouse.x = Math.floor(mouse.x);
66✔
980
        mouse.y = Math.floor(mouse.y);
66✔
981

982
        // Render/Read to buffer
983
        let buffer;
984
        if (viewPaused) {
66✔
985
            if (this.#fullSizeDepthBuffer.needsUpdate) {
1!
986
                this.readDepthBuffer(0, 0, dim.x, dim.y, this.#fullSizeDepthBuffer);
×
987
                this.#fullSizeDepthBuffer.needsUpdate = false;
×
988
            }
989
            const id = ((dim.y - mouse.y - 1) * dim.x + mouse.x) * 4;
1✔
990
            buffer = this.#fullSizeDepthBuffer.slice(id, id + 4);
1✔
991
        } else {
992
            buffer = this.readDepthBuffer(mouse.x, mouse.y, 1, 1, this.#pixelDepthBuffer);
65✔
993
        }
994

995
        screen.x = (mouse.x / dim.x) * 2 - 1;
66✔
996
        screen.y = -(mouse.y / dim.y) * 2 + 1;
66✔
997

998
        if (Capabilities.isLogDepthBufferSupported() && camera.type == 'PerspectiveCamera') {
66✔
999
            // TODO: solve this part with gl_FragCoord_Z and unproject
1000
            // Origin
1001
            ray.origin.copy(camera.position);
62✔
1002

1003
            // Direction
1004
            ray.direction.set(screen.x, screen.y, 0.5);
62✔
1005
            // Unproject
1006
            matrix.multiplyMatrices(camera.matrixWorld, matrix.copy(camera.projectionMatrix).invert());
62✔
1007
            ray.direction.applyMatrix4(matrix);
62✔
1008
            ray.direction.sub(ray.origin);
62✔
1009

1010
            direction.set(0, 0, 1.0);
62✔
1011
            direction.applyMatrix4(matrix);
62✔
1012
            direction.sub(ray.origin);
62✔
1013

1014
            const angle = direction.angleTo(ray.direction);
62✔
1015
            const orthoZ = g.depthBufferRGBAValueToOrthoZ(buffer, camera);
62✔
1016
            const length = orthoZ / Math.cos(angle);
62✔
1017
            target.addVectors(camera.position, ray.direction.setLength(length));
62✔
1018
        } else {
1019
            const gl_FragCoord_Z = g.depthBufferRGBAValueToOrthoZ(buffer, camera);
4✔
1020

1021
            target.set(screen.x, screen.y, gl_FragCoord_Z);
4✔
1022
            target.unproject(camera);
4✔
1023
        }
1024

1025
        if (target.length() > 10000000) { return undefined; }
66!
1026

1027
        return target;
66✔
1028
    }
1029

1030
    /**
1031
     * Returns the world {@link Coordinates} at given view coordinates.
1032
     *
1033
     * @param   {THREE.Vector2|event}   [mouse]     The view coordinates at which the world coordinates must be
1034
                                                    * returned. This parameter can also be set to a mouse event from
1035
                                                    * which the view coordinates will be deducted. If not specified, it
1036
                                                    * will be defaulted to the view's center coordinates.
1037
     * @param   {Coordinates}           [target]    The result will be copied into this {@link Coordinates}. If not
1038
                                                    * specified, a new {@link Coordinates} instance will be created.
1039
     *
1040
     * @returns {Coordinates}   The world {@link Coordinates} at the given view coordinates.
1041
     */
1042
    pickCoordinates(mouse, target = new Coordinates(this.tileLayer.extent.crs)) {
×
1043
        if (mouse instanceof Event) {
×
1044
            this.eventToViewCoords(mouse);
×
1045
        } else if (mouse && mouse.x !== undefined && mouse.y !== undefined) {
×
1046
            _eventCoords.copy(mouse);
×
1047
        } else {
1048
            _eventCoords.set(
×
1049
                this.mainLoop.gfxEngine.width / 2,
1050
                this.mainLoop.gfxEngine.height / 2,
1051
            );
1052
        }
1053

1054
        this.getPickingPositionFromDepth(_eventCoords, positionVector);
×
1055
        coordinates.crs = this.referenceCrs;
×
1056
        coordinates.setFromVector3(positionVector);
×
1057
        coordinates.as(target.crs, target);
×
1058

1059
        return target;
×
1060
    }
1061

1062
    /**
1063
     * Resize the viewer.
1064
     *
1065
     * @param {number} [width=viewerDiv.clientWidth] - The width to resize the
1066
     * viewer with. By default it is the `clientWidth` of the `viewerDiv`.
1067
     * @param {number} [height=viewerDiv.clientHeight] - The height to resize
1068
     * the viewer with. By default it is the `clientHeight` of the `viewerDiv`.
1069
     */
1070
    resize(width, height) {
1071
        if (width < 0 || height < 0) {
1!
1072
            console.warn(`Trying to resize the View with negative height (${height}) or width (${width}). Skipping resize.`);
×
1073
            return;
×
1074
        }
1075

1076
        if (width == undefined) {
1!
1077
            width = this.domElement.clientWidth;
×
1078
        }
1079

1080
        if (height == undefined) {
1!
1081
            height = this.domElement.clientHeight;
×
1082
        }
1083

1084
        this.#fullSizeDepthBuffer = new Uint8Array(4 * width * height);
1✔
1085
        this.mainLoop.gfxEngine.onWindowResize(width, height);
1✔
1086
        if (width !== 0 && height !== 0) {
1!
1087
            this.camera.resize(width, height);
1✔
1088
            this.notifyChange(this.camera.camera3D);
1✔
1089
        }
1090
    }
1091
}
1092

1093
export default View;
1✔
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

© 2025 Coveralls, Inc