Source: Models/Cesium.js

'use strict';

/*global require*/
var Cartesian2 = require('terriajs-cesium/Source/Core/Cartesian2');
var Cartesian3 = require('terriajs-cesium/Source/Core/Cartesian3');
var Cartographic = require('terriajs-cesium/Source/Core/Cartographic');
var CesiumMath = require('terriajs-cesium/Source/Core/Math');
var defaultValue = require('terriajs-cesium/Source/Core/defaultValue');
var defined = require('terriajs-cesium/Source/Core/defined');
var destroyObject = require('terriajs-cesium/Source/Core/destroyObject');
var DeveloperError = require('terriajs-cesium/Source/Core/DeveloperError');
var Ellipsoid = require('terriajs-cesium/Source/Core/Ellipsoid');
var Entity = require('terriajs-cesium/Source/DataSources/Entity');
var formatError = require('terriajs-cesium/Source/Core/formatError');
var getTimestamp = require('terriajs-cesium/Source/Core/getTimestamp');
var ImageryLayer = require('terriajs-cesium/Source/Scene/ImageryLayer');
var JulianDate = require('terriajs-cesium/Source/Core/JulianDate');
var knockout = require('terriajs-cesium/Source/ThirdParty/knockout');
var loadWithXhr = require('../Core/loadWithXhr');
var Matrix4 = require('terriajs-cesium/Source/Core/Matrix4');
var Rectangle = require('terriajs-cesium/Source/Core/Rectangle');
var sampleTerrain = require('terriajs-cesium/Source/Core/sampleTerrain');
var SceneTransforms = require('terriajs-cesium/Source/Scene/SceneTransforms');
var ScreenSpaceEventType = require('terriajs-cesium/Source/Core/ScreenSpaceEventType');
var TaskProcessor = require('terriajs-cesium/Source/Core/TaskProcessor');
var Transforms = require('terriajs-cesium/Source/Core/Transforms');
var when = require('terriajs-cesium/Source/ThirdParty/when');
var EventHelper = require('terriajs-cesium/Source/Core/EventHelper');
var ImagerySplitDirection = require('terriajs-cesium/Source/Scene/ImagerySplitDirection');

var CesiumSelectionIndicator = require('../Map/CesiumSelectionIndicator');
var Feature = require('./Feature');
var GlobeOrMap = require('./GlobeOrMap');
var inherit = require('../Core/inherit');
var TerriaError = require('../Core/TerriaError');
var PickedFeatures = require('../Map/PickedFeatures');
var ViewerMode = require('./ViewerMode');

/**
 * The Cesium viewer component
 *
 * @alias Cesium
 * @constructor
 * @extends GlobeOrMap
 *
 * @param {Terria} terria The Terria instance.
 * @param {Viewer} viewer The Cesium viewer instance.
 */
var Cesium = function(terria, viewer) {
    GlobeOrMap.call(this, terria);

    /**
     * Gets or sets the Cesium {@link Viewer} instance.
     * @type {Viewer}
     */
    this.viewer = viewer;

    /**
     * Gets or sets the Cesium {@link Scene} instance.
     * @type {Scene}
     */
    this.scene = viewer.scene;

    /**
     * Gets or sets whether the viewer has stopped rendering since startup or last set to false.
     * @type {Boolean}
     */
    this.stoppedRendering = false;

    /**
     * Gets or sets whether to output info to the console when starting and stopping rendering loop.
     * @type {Boolean}
     */
    this.verboseRendering = false;

    /**
     * Gets or sets whether this viewer _can_ show a splitter.
     * @type {Boolean}
     */
    this.canShowSplitter = true;

    this._lastClockTime = new JulianDate(0, 0.0);
    this._lastCameraViewMatrix = new Matrix4();
    this._lastCameraMoveTime = 0;

    this._selectionIndicator = new CesiumSelectionIndicator(this);

    this._removePostRenderListener = this.scene.postRender.addEventListener(postRender.bind(undefined, this));
    this._removeInfoBoxCloseListener = undefined;
    this._boundNotifyRepaintRequired = this.notifyRepaintRequired.bind(this);

    this._pauseMapInteractionCount = 0;

    this.scene.imagerySplitPosition = this.terria.splitPosition;

    // Handle left click by picking objects from the map.
    viewer.screenSpaceEventHandler.setInputAction(function(e) {
        this.pickFromScreenPosition(e.position);
    }.bind(this), ScreenSpaceEventType.LEFT_CLICK);

    // Force a repaint when the mouse moves or the window changes size.
    var canvas = this.viewer.canvas;
    canvas.addEventListener('mousemove', this._boundNotifyRepaintRequired, false);
    canvas.addEventListener('mousedown', this._boundNotifyRepaintRequired, false);
    canvas.addEventListener('mouseup', this._boundNotifyRepaintRequired, false);
    canvas.addEventListener('touchstart', this._boundNotifyRepaintRequired, false);
    canvas.addEventListener('touchend', this._boundNotifyRepaintRequired, false);
    canvas.addEventListener('touchmove', this._boundNotifyRepaintRequired, false);

    if (defined(window.PointerEvent)) {
        canvas.addEventListener('pointerdown', this._boundNotifyRepaintRequired, false);
        canvas.addEventListener('pointerup', this._boundNotifyRepaintRequired, false);
        canvas.addEventListener('pointermove', this._boundNotifyRepaintRequired, false);
    }

    // Detect available wheel event
    this._wheelEvent = undefined;
    if ('onwheel' in canvas) {
        // spec event type
        this._wheelEvent = 'wheel';
    } else if (defined(document.onmousewheel)) {
        // legacy event type
        this._wheelEvent = 'mousewheel';
    } else {
        // older Firefox
        this._wheelEvent = 'DOMMouseScroll';
    }

    canvas.addEventListener(this._wheelEvent, this._boundNotifyRepaintRequired, false);

    window.addEventListener('resize', this._boundNotifyRepaintRequired, false);

    // Force a repaint when the feature info box is closed.  Cesium can't close its info box
    // when the clock is not ticking, for reasons that are not clear.
    if (defined(this.viewer.infoBox)) {
        this._removeInfoBoxCloseListener = this.viewer.infoBox.viewModel.closeClicked.addEventListener(this._boundNotifyRepaintRequired);
    }

    if (defined(this.viewer._clockViewModel)) {
        var clock = this.viewer._clockViewModel;
        this._shouldAnimateSubscription = knockout.getObservable(clock, 'shouldAnimate').subscribe(this._boundNotifyRepaintRequired);
        this._currentTimeSubscription = knockout.getObservable(clock, 'currentTime').subscribe(this._boundNotifyRepaintRequired);
    }

    if (defined(this.viewer.timeline)) {
        this.viewer.timeline.addEventListener('settime', this._boundNotifyRepaintRequired, false);
    }

    this._selectedFeatureSubscription = knockout.getObservable(this.terria, 'selectedFeature').subscribe(function() {
        selectFeature(this);
    }, this);

    this._splitterPositionSubscription = knockout.getObservable(this.terria, 'splitPosition').subscribe(function() {
        if (this.scene) {
            this.scene.imagerySplitPosition = this.terria.splitPosition;
            this.notifyRepaintRequired();
        }
    }, this);

    this._showSplitterSubscription = knockout.getObservable(terria, 'showSplitter').subscribe(function() {
        this.updateAllItemsForSplitter();
    }, this);

    // Hacky way to force a repaint when an async load request completes
    var that = this;
    this._originalLoadWithXhr = loadWithXhr.load;
    loadWithXhr.load = function(url, responseType, method, data, headers, deferred, overrideMimeType, preferText, timeout) {
        deferred.promise.always(that._boundNotifyRepaintRequired);
        that._originalLoadWithXhr(url, responseType, method, data, headers, deferred, overrideMimeType, preferText, timeout);
    };

    // Hacky way to force a repaint when a web worker sends something back.
    this._originalScheduleTask = TaskProcessor.prototype.scheduleTask;
    TaskProcessor.prototype.scheduleTask = function(parameters, transferableObjects) {
        var result = that._originalScheduleTask.call(this, parameters, transferableObjects);

        if (!defined(this._originalWorkerMessageSinkRepaint)) {
            this._originalWorkerMessageSinkRepaint = this._worker.onmessage;

            var taskProcessor = this;
            this._worker.onmessage = function(event) {
                taskProcessor._originalWorkerMessageSinkRepaint(event);

                if (that.isDestroyed()) {
                    taskProcessor._worker.onmessage = taskProcessor._originalWorkerMessageSinkRepaint;
                    taskProcessor._originalWorkerMessageSinkRepaint = undefined;
                } else {
                    that.notifyRepaintRequired();
                }
            };
        }

        return result;
    };

    this.eventHelper = new EventHelper();

    // If the render loop crashes, inform the user and then switch to 2D.
    this.eventHelper.add(this.scene.renderError, function(scene, error) {
        this.terria.error.raiseEvent(new TerriaError({
            sender: this,
            title: 'Error rendering in 3D',
            message: '\
<p>An error occurred while rendering in 3D.  This probably indicates a bug in ' + terria.appName + ' or an incompatibility with your system \
or web browser.  We\'ll now switch you to 2D so that you can continue your work.  We would appreciate it if you report this \
error by sending an email to <a href="mailto:' + terria.supportEmail + '">' + terria.supportEmail + '</a> with the \
technical details below.  Thank you!</p><pre>' + formatError(error) + '</pre>'
        }));

        this.terria.viewerMode = ViewerMode.Leaflet;
    }, this);

    this.eventHelper.add(this.scene.globe.tileLoadProgressEvent, this.updateTilesLoadingCount.bind(this));

    selectFeature(this);
};

inherit(GlobeOrMap, Cesium);

Cesium.prototype.destroy = function() {
    if (defined(this._selectionIndicator)) {
        this._selectionIndicator.destroy();
        this._selectionIndicator = undefined;
    }

    if (defined(this._removePostRenderListener)) {
        this._removePostRenderListener();
        this._removePostRenderListener = undefined;
    }

    if (defined(this._removeInfoBoxCloseListener)) {
        this._removeInfoBoxCloseListener();
    }

    if (defined(this._shouldAnimateSubscription)) {
        this._shouldAnimateSubscription.dispose();
        this._shouldAnimateSubscription = undefined;
    }

    if (defined(this._currentTimeSubscription)) {
        this._currentTimeSubscription.dispose();
        this._currentTimeSubscription = undefined;
    }

    if (defined(this.viewer.timeline)) {
        this.viewer.timeline.removeEventListener('settime', this._boundNotifyRepaintRequired, false);
    }

    if (defined(this._selectedFeatureSubscription)) {
        this._selectedFeatureSubscription.dispose();
        this._selectedFeatureSubscription = undefined;
    }

    if (defined(this._splitterPositionSubscription)) {
        this._splitterPositionSubscription.dispose();
        this._splitterPositionSubscription = undefined;
    }

    if (defined(this._showSplitterSubscription)) {
        this._showSplitterSubscription.dispose();
        this._showSplitterSubscription = undefined;
    }

    this.viewer.canvas.removeEventListener('mousemove', this._boundNotifyRepaintRequired, false);
    this.viewer.canvas.removeEventListener('mousedown', this._boundNotifyRepaintRequired, false);
    this.viewer.canvas.removeEventListener('mouseup', this._boundNotifyRepaintRequired, false);
    this.viewer.canvas.removeEventListener('touchstart', this._boundNotifyRepaintRequired, false);
    this.viewer.canvas.removeEventListener('touchend', this._boundNotifyRepaintRequired, false);
    this.viewer.canvas.removeEventListener('touchmove', this._boundNotifyRepaintRequired, false);

    if (defined(window.PointerEvent)) {
        this.viewer.canvas.removeEventListener('pointerdown', this._boundNotifyRepaintRequired, false);
        this.viewer.canvas.removeEventListener('pointerup', this._boundNotifyRepaintRequired, false);
        this.viewer.canvas.removeEventListener('pointermove', this._boundNotifyRepaintRequired, false);
    }

    this.viewer.canvas.removeEventListener(this._wheelEvent, this._boundNotifyRepaintRequired, false);


    window.removeEventListener('resize', this._boundNotifyRepaintRequired, false);

    loadWithXhr.load = this._originalLoadWithXhr;
    TaskProcessor.prototype.scheduleTask = this._originalScheduleTask;

    this.eventHelper.removeAll();

    GlobeOrMap.disposeCommonListeners(this);

    return destroyObject(this);
};

Cesium.prototype.isDestroyed = function() {
    return false;
};

var cartesian3Scratch = new Cartesian3();
var enuToFixedScratch = new Matrix4();
var southwestScratch = new Cartesian3();
var southeastScratch = new Cartesian3();
var northeastScratch = new Cartesian3();
var northwestScratch = new Cartesian3();
var southwestCartographicScratch = new Cartographic();
var southeastCartographicScratch = new Cartographic();
var northeastCartographicScratch = new Cartographic();
var northwestCartographicScratch = new Cartographic();

/**
 * Gets the current extent of the camera.  This may be approximate if the viewer does not have a strictly rectangular view.
 * @return {Rectangle} The current visible extent.
 */
Cesium.prototype.getCurrentExtent = function() {
    var scene = this.scene;
    var camera = scene.camera;

    var width = scene.canvas.clientWidth;
    var height = scene.canvas.clientHeight;

    var centerOfScreen = new Cartesian2(width / 2.0, height / 2.0);
    var pickRay = scene.camera.getPickRay(centerOfScreen);
    var center = scene.globe.pick(pickRay, scene);

    if (!defined(center)) {
        // TODO: binary search to find the horizon point and use that as the center.
        return this.terria.homeView.rectangle;
    }

    var ellipsoid = this.scene.globe.ellipsoid;

    var fovy = scene.camera.frustum.fovy * 0.5;
    var fovx = Math.atan(Math.tan(fovy) * scene.camera.frustum.aspectRatio);

    var cameraOffset = Cartesian3.subtract(camera.positionWC, center, cartesian3Scratch);
    var cameraHeight = Cartesian3.magnitude(cameraOffset);
    var xDistance = cameraHeight * Math.tan(fovx);
    var yDistance = cameraHeight * Math.tan(fovy);

    var southwestEnu = new Cartesian3(-xDistance, -yDistance, 0.0);
    var southeastEnu = new Cartesian3(xDistance, -yDistance, 0.0);
    var northeastEnu = new Cartesian3(xDistance, yDistance, 0.0);
    var northwestEnu = new Cartesian3(-xDistance, yDistance, 0.0);

    var enuToFixed = Transforms.eastNorthUpToFixedFrame(center, ellipsoid, enuToFixedScratch);
    var southwest = Matrix4.multiplyByPoint(enuToFixed, southwestEnu, southwestScratch);
    var southeast = Matrix4.multiplyByPoint(enuToFixed, southeastEnu, southeastScratch);
    var northeast = Matrix4.multiplyByPoint(enuToFixed, northeastEnu, northeastScratch);
    var northwest = Matrix4.multiplyByPoint(enuToFixed, northwestEnu, northwestScratch);

    var southwestCartographic = ellipsoid.cartesianToCartographic(southwest, southwestCartographicScratch);
    var southeastCartographic = ellipsoid.cartesianToCartographic(southeast, southeastCartographicScratch);
    var northeastCartographic = ellipsoid.cartesianToCartographic(northeast, northeastCartographicScratch);
    var northwestCartographic = ellipsoid.cartesianToCartographic(northwest, northwestCartographicScratch);

    // Account for date-line wrapping
    if (southeastCartographic.longitude < southwestCartographic.longitude) {
        southeastCartographic.longitude += CesiumMath.TWO_PI;
    }
    if (northeastCartographic.longitude < northwestCartographic.longitude) {
        northeastCartographic.longitude += CesiumMath.TWO_PI;
    }

    var rect = new Rectangle(
        CesiumMath.convertLongitudeRange(Math.min(southwestCartographic.longitude, northwestCartographic.longitude)),
        Math.min(southwestCartographic.latitude, southeastCartographic.latitude),
        CesiumMath.convertLongitudeRange(Math.max(northeastCartographic.longitude, southeastCartographic.longitude)),
        Math.max(northeastCartographic.latitude, northwestCartographic.latitude));
    rect.center = center;
    return rect;
};

/**
 * Gets the current container element.
 * @return {Element} The current container element.
 */
Cesium.prototype.getContainer = function() {
    return this.viewer.container;
};

/**
 * Zooms to a specified camera view or extent with a smooth flight animation.
 *
 * @param {CameraView|Rectangle} viewOrExtent The view or extent to which to zoom.
 * @param {Number} [flightDurationSeconds=3.0] The length of the flight animation in seconds.
 */
Cesium.prototype.zoomTo = function(viewOrExtent, flightDurationSeconds) {
    if (!defined(viewOrExtent)) {
        throw new DeveloperError('viewOrExtent is required.');
    }

    flightDurationSeconds = defaultValue(flightDurationSeconds, 3.0);

    var that = this;

    return when().then(function() {
        if (viewOrExtent instanceof Rectangle) {

            var camera = that.scene.camera;

            // Work out the destination that the camera would naturally fly to
            var destinationCartesian = camera.getRectangleCameraCoordinates(viewOrExtent);
            var destination = Ellipsoid.WGS84.cartesianToCartographic(destinationCartesian);

            var terrainProvider = that.scene.globe.terrainProvider;
            var level = 6; // A sufficiently coarse tile level that still has approximately accurate height
            var positions = [Rectangle.center(viewOrExtent)];

            // Perform an elevation query at the centre of the rectangle
            return sampleTerrain(terrainProvider, level, positions).then(function(results) {

                // Add terrain elevation to camera altitude
                var finalDestinationCartographic = {
                    longitude: destination.longitude,
                    latitude: destination.latitude,
                    height: destination.height + results[0].height
                };

                var finalDestination = Ellipsoid.WGS84.cartographicToCartesian(finalDestinationCartographic);

                camera.flyTo({
                    duration: flightDurationSeconds,
                    destination: finalDestination
                });
            });

        } else if (defined(viewOrExtent.position)) {
            that.scene.camera.flyTo({
                duration: flightDurationSeconds,
                destination: viewOrExtent.position,
                orientation: {
                    direction: viewOrExtent.direction,
                    up: viewOrExtent.up
                }
            });
        } else {
            that.scene.camera.flyTo({
                duration: flightDurationSeconds,
                destination: viewOrExtent.rectangle
            });
        }

    }).then(function() {
        that.notifyRepaintRequired();
    });

};

/**
 * Captures a screenshot of the map.
 * @return {Promise} A promise that resolves to a data URL when the screenshot is ready.
 */
Cesium.prototype.captureScreenshot = function() {
    var deferred = when.defer();

    var removeCallback = this.scene.postRender.addEventListener(function() {
        removeCallback();
        try {
            const cesiumCanvas = this.scene.canvas;

            // If we're using the splitter, draw the split position as a vertical white line.
            let canvas = cesiumCanvas;
            if (this.terria.showSplitter) {
                canvas = document.createElement('canvas');
                canvas.width = cesiumCanvas.width;
                canvas.height = cesiumCanvas.height;

                const context = canvas.getContext('2d');
                context.drawImage(cesiumCanvas, 0, 0);

                const x = this.terria.splitPosition * cesiumCanvas.width;
                context.strokeStyle = this.terria.baseMapContrastColor;
                context.beginPath();
                context.moveTo(x, 0);
                context.lineTo(x, cesiumCanvas.height);
                context.stroke();
            }

            deferred.resolve(canvas.toDataURL('image/png'));
        } catch (e) {
            deferred.reject(e);
        }
    }, this);

    this.scene.render(this.terria.clock.currentTime);

    return deferred.promise;
};

/**
 * Notifies the viewer that a repaint is required.
 */
Cesium.prototype.notifyRepaintRequired = function() {
    if (this.verboseRendering && !this.viewer.useDefaultRenderLoop) {
        console.log('starting rendering @ ' + getTimestamp());
    }
    this._lastCameraMoveTime = getTimestamp();
    this.viewer.useDefaultRenderLoop = true;
};

/**
 * Computes the screen position of a given world position.
 * @param  {Cartesian3} position The world position in Earth-centered Fixed coordinates.
 * @param  {Cartesian2} [result] The instance to which to copy the result.
 * @return {Cartesian2} The screen position, or undefined if the position is not on the screen.
 */
Cesium.prototype.computePositionOnScreen = function(position, result) {
    return SceneTransforms.wgs84ToWindowCoordinates(this.scene, position, result);
};

/**
 * Adds an attribution to the globe.
 * @param {Credit} attribution The attribution to add.
 */
Cesium.prototype.addAttribution = function(attribution) {
    if (attribution) {
        this.scene.frameState.creditDisplay.addDefaultCredit(attribution);
    }
};

/**
 * Removes an attribution from the globe.
 * @param {Credit} attribution The attribution to remove.
 */
Cesium.prototype.removeAttribution = function(attribution) {
    if (attribution) {
        this.scene.frameState.creditDisplay.removeDefaultCredit(attribution);
    }
};

/**
 * Gets all attribution currently active on the globe or map.
 * @returns {String[]} The list of current attributions, as HTML strings.
 */
Cesium.prototype.getAllAttribution = function() {
    const credits = this.scene.frameState.creditDisplay._currentFrameCredits.screenCredits.values.concat(this.scene.frameState.creditDisplay._currentFrameCredits.lightboxCredits.values);
    return credits.map(credit => credit.html);
};

/**
 * Updates the order of layers, moving layers where {@link CatalogItem#keepOnTop} is true to the top.
 */
Cesium.prototype.updateLayerOrderToKeepOnTop = function() {
    // move alwaysOnTop layers to the top
    var items = this.terria.nowViewing.items;
    var scene = this.scene;
    for (var l = items.length - 1; l >= 0; l--) {
        if (items[l].imageryLayer && items[l].keepOnTop) {
            scene.imageryLayers.raiseToTop(items[l].imageryLayer);
        }
    }
};

Cesium.prototype.updateLayerOrderAfterReorder = function() {
    // because this Cesium model does the reordering via raise and lower, no action needed.
};

// useful for counting the number of items in composite and non-composite items
function countNumberOfSubItems(item) {
    if (defined(item.items)) {
        return item.items.length;
    } else {
        return 1;
    }
}

/**
 * Raise an item's level in the viewer
 * This does not check that index is valid
 * @param {Number} index The index of the item to raise
 */
Cesium.prototype.raise = function(index) {
    var items = this.terria.nowViewing.items;
    var item = items[index];
    var itemAbove = items[index - 1];

    if (!defined(itemAbove.items) && !defined(itemAbove.imageryLayer)) {
        return;
    }

    // Both item and itemAbove may either have a single imageryLayer, or be a composite item
    // Composite items have an items array of further items.
    // Define n as the number of subitems in ItemAbove (1 except for composites)
    // if item is a composite, then raise each subitem in item n times,
    // starting with the one at the top - which is the last one in the list
    // if item is not a composite, just raise the item n times directly.

    var n = countNumberOfSubItems(itemAbove);
    var i, j, subItem;

    if (defined(item.items)) {
        for (i = item.items.length - 1; i >= 0; --i) {
            subItem = item.items[i];
            if (defined(subItem.imageryLayer)) {
                for (j = 0; j < n; ++j) {
                    this.scene.imageryLayers.raise(subItem.imageryLayer);
                }
            }
        }
    }

    if (!defined(item.imageryLayer)) {
        return;
    }

    for (j = 0; j < n; ++j) {
        this.scene.imageryLayers.raise(item.imageryLayer);
    }
};

/**
 * Lower an item's level in the viewer
 * This does not check that index is valid
 * @param {Number} index The index of the item to lower
 */
Cesium.prototype.lower = function(index) {
    var items = this.terria.nowViewing.items;
    var item = items[index];
    var itemBelow = items[index + 1];
    if (!defined(itemBelow.items) && !defined(itemBelow.imageryLayer)) {
        return;
    }

    // same considerations as above, but lower composite subitems starting at the other end of the list

    var n = countNumberOfSubItems(itemBelow);
    var i, j, subItem;

    if (defined(item.items)) {
        for (i = 0; i < item.items.length; ++i) {
            subItem = item.items[i];
            if (defined(subItem.imageryLayer)) {
                for (j = 0; j < n; ++j) {
                    this.scene.imageryLayers.lower(subItem.imageryLayer);
                }
            }
        }
    }

    if (!defined(item.imageryLayer)) {
        return;
    }

    for (j = 0; j < n; ++j) {
        this.scene.imageryLayers.lower(item.imageryLayer);
    }
};

/**
 * Lowers this imagery layer to the bottom, underneath all other layers.  If this item is not enabled or not shown,
 * this method does nothing.
 * @param {CatalogItem} item The item to lower to the bottom (usually a basemap)
 */
Cesium.prototype.lowerToBottom = function(item) {

    if (defined(item.items)) {
        // the front item is at the end of the list.
        // so to preserve order of any subitems, send any subitems to the bottom in order from the front
        for (var i = item.items.length - 1; i >= 0; --i) {
            var subItem = item.items[i];
            this.lowerToBottom(subItem);  // recursive
        }
    }

    if (!defined(item._imageryLayer)) {
        return;
    }

    this.terria.cesium.scene.imageryLayers.lowerToBottom(item._imageryLayer);
};

Cesium.prototype.adjustDisclaimer = function() {

};

/**
 * Picks features based off a latitude, longitude and (optionally) height.
 * @param {Object} latlng The position on the earth to pick.
 * @param {Object} imageryLayerCoords A map of imagery provider urls to the coords used to get features for those imagery
 *     providers - i.e. x, y, level
 * @param existingFeatures An optional list of existing features to concatenate the ones found from asynchronous picking to.
 */
Cesium.prototype.pickFromLocation = function(latlng, imageryLayerCoords, existingFeatures) {
    var pickPosition = this.scene.globe.ellipsoid.cartographicToCartesian(Cartographic.fromDegrees(latlng.lng, latlng.lat, latlng.height));
    var pickPositionCartographic = Ellipsoid.WGS84.cartesianToCartographic(pickPosition);

    var promises = [];
    var imageryLayers = [];
    for (var i = this.scene.imageryLayers.length - 1; i >= 0; i--) {
        var imageryLayer = this.scene.imageryLayers.get(i);
        var imageryProvider = imageryLayer._imageryProvider;

        if (imageryProvider.url && imageryLayerCoords[imageryProvider.url]) {
            var coords = imageryLayerCoords[imageryProvider.url];
            promises.push(imageryProvider.pickFeatures(coords.x, coords.y, coords.level, pickPositionCartographic.longitude, pickPositionCartographic.latitude));
            imageryLayers.push(imageryLayer);
        }
    }

    var result = this._buildPickedFeatures(imageryLayerCoords, pickPosition, existingFeatures, promises, imageryLayers, pickPositionCartographic.height);

    var mapInteractionModeStack = this.terria.mapInteractionModeStack;
    if (defined(mapInteractionModeStack) && mapInteractionModeStack.length > 0) {
        mapInteractionModeStack[mapInteractionModeStack.length - 1].pickedFeatures = result;
    } else {
        this.terria.pickedFeatures = result;
    }
};

/**
 * Picks features based on coordinates relative to the Cesium window. Will draw a ray from the camera through the point
 * specified and set terria.pickedFeatures based on this.
 *
 * @param {Cartesian3} screenPosition The position on the screen.
 */
Cesium.prototype.pickFromScreenPosition = function(screenPosition) {
    var pickRay = this.scene.camera.getPickRay(screenPosition);
    var pickPosition = this.scene.globe.pick(pickRay, this.scene);
    var pickPositionCartographic = Ellipsoid.WGS84.cartesianToCartographic(pickPosition);

    var vectorFeatures = this.pickVectorFeatures(screenPosition);

    var providerCoords = this._attachProviderCoordHooks();
    var pickRasterPromise = this.scene.imageryLayers.pickImageryLayerFeatures(pickRay, this.scene);

    var result = this._buildPickedFeatures(providerCoords, pickPosition, vectorFeatures, [pickRasterPromise], undefined, pickPositionCartographic.height);

    var mapInteractionModeStack = this.terria.mapInteractionModeStack;
    if (defined(mapInteractionModeStack) && mapInteractionModeStack.length > 0) {
        mapInteractionModeStack[mapInteractionModeStack.length - 1].pickedFeatures = result;
    } else {
        this.terria.pickedFeatures = result;
    }
};

/**
 * Picks all *vector* features (e.g. GeoJSON) shown at a certain position on the screen, ignoring raster features
 * (e.g. WFS). Because all vector features are already in memory, this is synchronous.
 *
 * @param {Cartesian2} screenPosition position on the screen to look for features
 * @returns {Feature[]} The features found.
 */
Cesium.prototype.pickVectorFeatures = function(screenPosition) {
    // Pick vector features
    var vectorFeatures = [];
    var picked = this.scene.drillPick(screenPosition);
    for (var i = 0; i < picked.length; ++i) {
        var id = picked[i].id;

        if (id && id.entityCollection && id.entityCollection.owner && id.entityCollection.owner.name === GlobeOrMap._featureHighlightName) {
            continue;
        }

        if (!defined(id) && defined(picked[i].primitive)) {
            id = picked[i].primitive.id;
        }
        if (id instanceof Entity && vectorFeatures.indexOf(id) === -1) {
            var feature = Feature.fromEntityCollectionOrEntity(id);
            vectorFeatures.push(feature);
        }
    }

    return vectorFeatures;
};

/**
 * Hooks into the {@link ImageryProvider#pickFeatures} method of every imagery provider in the scene - when this method is
 * evaluated (usually as part of feature picking), it will record the tile coordinates used against the url of the
 * imagery provider in an object that is returned by this method. Hooks are removed immediately after being executed once.
 *
 * @returns {{x, y, level}} A map of urls to the coords used by the imagery provider when picking features. Will
 *     initially be empty but will be updated as the hooks are evaluated.
 * @private
 */
Cesium.prototype._attachProviderCoordHooks = function() {
    var providerCoords = {};

    var pickFeaturesHook = function(imageryProvider, oldPick, x, y, level, longitude, latitude) {
        var featuresPromise = oldPick.call(imageryProvider, x, y, level, longitude, latitude);

        // Use url to uniquely identify providers because what else can we do?
        if (imageryProvider.url) {
            providerCoords[imageryProvider.url] = {
                x: x,
                y: y,
                level: level
            };
        }

        imageryProvider.pickFeatures = oldPick;
        return featuresPromise;
    };

    for (var j = 0; j < this.scene.imageryLayers.length; j++) {
        var imageryProvider = this.scene.imageryLayers.get(j).imageryProvider;
        imageryProvider.pickFeatures = pickFeaturesHook.bind(undefined, imageryProvider, imageryProvider.pickFeatures);
    }

    return providerCoords;
};

/**
 * Builds a {@link PickedFeatures} object from a number of inputs.
 *
 * @param {{x, y, level}} providerCoords A map of imagery provider urls to the coords used to get features for that provider.
 * @param {Cartesian3} pickPosition The position in the 3D model that has been picked.
 * @param {Entity[]} existingFeatures Existing features - the results of feature promises will be appended to this.
 * @param {Promise[]} featurePromises Zero or more promises that each resolve to a list of {@link ImageryLayerFeatureInfo}s
 *     (usually there will be one promise per ImageryLayer. These will be combined as part of
 *     {@link PickedFeatures#allFeaturesAvailablePromise} and their results used to build the final
 *     {@link PickedFeatures#features} array.
 * @param {ImageryLayer[]} imageryLayers An array of ImageryLayers that should line up with the one passed as featurePromises.
 * @param {number} defaultHeight The height to use for feature position heights if none is available when picking.
 * @returns {PickedFeatures} A {@link PickedFeatures} object that is a combination of everything passed.
 * @private
 */
Cesium.prototype._buildPickedFeatures = function(providerCoords, pickPosition, existingFeatures, featurePromises, imageryLayers, defaultHeight) {
    var result = new PickedFeatures();

    result.providerCoords = providerCoords;
    result.pickPosition = pickPosition;

    result.allFeaturesAvailablePromise = when.all(featurePromises).then(function(allFeatures) {
        result.isLoading = false;

        result.features = allFeatures.reduce(function(resultFeaturesSoFar, imageryLayerFeatures, i) {
            if (!defined(imageryLayerFeatures)) {
                return resultFeaturesSoFar;
            }

            return resultFeaturesSoFar.concat(imageryLayerFeatures.map(function(feature) {
                if (defined(imageryLayers)) {
                    feature.imageryLayer = imageryLayers[i];
                }

                if (!defined(feature.position)) {
                    feature.position = Ellipsoid.WGS84.cartesianToCartographic(pickPosition);
                }

                // If the picked feature does not have a height, use the height of the picked location.
                // This at least avoids major parallax effects on the selection indicator.
                if (!defined(feature.position.height) || feature.position.height === 0.0) {
                    feature.position.height = defaultHeight;
                }
                return this._createFeatureFromImageryLayerFeature(feature);
            }.bind(this)));
        }.bind(this), defaultValue(existingFeatures, []));
    }.bind(this)).otherwise(function() {
        result.isLoading = false;
        result.error = 'An unknown error occurred while picking features.';
    });

    return result;
};

/**
 * Returns a new layer using a provided ImageryProvider, and adds it to the scene.
 * Note the optional parameters are a superset of the Leaflet version of this function, with one deletion (onProjectionError).
 *
 * @param {Object} options Options
 * @param {ImageryProvider} options.imageryProvider The imagery provider to create a new layer for.
 * @param {Number} [layerIndex] The index to add the layer at.  If omitted, the layer will added on top of all existing layers.
 * @param {Rectangle} [options.rectangle=imageryProvider.rectangle] The rectangle of the layer.  This rectangle
 *        can limit the visible portion of the imagery provider.
 * @param {Number|Function} [options.opacity=1.0] The alpha blending value of this layer, from 0.0 to 1.0.
 *                          This can either be a simple number or a function with the signature
 *                          <code>function(frameState, layer, x, y, level)</code>.  The function is passed the
 *                          current frame state, this layer, and the x, y, and level coordinates of the
 *                          imagery tile for which the alpha is required, and it is expected to return
 *                          the alpha value to use for the tile.
 * @param {Boolean} [options.clipToRectangle]
 * @param {Boolean} [options.treat403AsError]
 * @param {Boolean} [options.treat403AsError]
 * @param {Boolean} [options.ignoreUnknownTileErrors]
 * @param {Function} [options.onLoadError]
 * @returns {ImageryLayer} The newly created layer.
 */
Cesium.prototype.addImageryProvider = function(options) {
    var scene = this.scene;

    var errorEvent = options.imageryProvider.errorEvent;

    if (defined(errorEvent)) {
        errorEvent.addEventListener(options.onLoadError);
    }

    var result = new ImageryLayer(options.imageryProvider, {
        show : false,
        alpha : options.opacity,
        rectangle : options.clipToRectangle ? options.rectangle : undefined,
        isRequired : options.isRequiredForRendering   // TODO: This doesn't seem to be a valid option for ImageryLayer - remove (and upstream)?
    });

    // layerIndex is an optional parameter used when the imageryLayer corresponds to a CsvCatalogItem whose selected item has just changed
    // to ensure that the layer is re-added in the correct position
    scene.imageryLayers.add(result, options.layerIndex);

    this.updateLayerOrderToKeepOnTop();

    return result;
};

Cesium.prototype.removeImageryLayer = function(options) {
    var scene = this.scene;
    scene.imageryLayers.remove(options.layer);
};

Cesium.prototype.showImageryLayer = function(options) {
    options.layer.show = true;
};

Cesium.prototype.hideImageryLayer = function(options) {
    options.layer.show = false;
};

Cesium.prototype.isImageryLayerShown = function(options) {
    return options.layer.show;
};

Cesium.prototype.updateItemForSplitter = function(item) {
    if (!defined(item.splitDirection) || !defined(item.imageryLayer)) {
        return;
    }

    const terria = item.terria;

    if (terria.showSplitter) {
        item.imageryLayer.splitDirection = item.splitDirection;
    } else {
        item.imageryLayer.splitDirection = ImagerySplitDirection.NONE;
    }

    // Also update the next layer, if any.
    if (item._nextLayer) {
        item._nextLayer.splitDirection = item.imageryLayer.splitDirection;
    }

    this.notifyRepaintRequired();
};

Cesium.prototype.pauseMapInteraction = function() {
    ++this._pauseMapInteractionCount;
    if (this._pauseMapInteractionCount === 1) {
        this.scene.screenSpaceCameraController.enableInputs = false;
    }
};

Cesium.prototype.resumeMapInteraction = function() {
    --this._pauseMapInteractionCount;
    if (this._pauseMapInteractionCount === 0) {
        setTimeout(() => {
            if (this._pauseMapInteractionCount === 0) {
                this.scene.screenSpaceCameraController.enableInputs = true;
            }
        }, 0);
    }
};

function postRender(cesium, date) {
    // We can safely stop rendering when:
    //  - the camera position hasn't changed in over a second,
    //  - there are no tiles waiting to load, and
    //  - the clock is not animating
    //  - there are no tweens in progress

    var now = getTimestamp();

    var scene = cesium.scene;

    if (!Matrix4.equalsEpsilon(cesium._lastCameraViewMatrix, scene.camera.viewMatrix, 1e-5)) {
        cesium._lastCameraMoveTime = now;
    }

    var cameraMovedInLastSecond = now - cesium._lastCameraMoveTime < 1000;

    var surface = scene.globe._surface;
    var tilesWaiting = !surface._tileProvider.ready || surface._tileLoadQueueHigh.length > 0 || surface._tileLoadQueueMedium.length > 0 || surface._tileLoadQueueLow.length > 0 || surface._debug.tilesWaitingForChildren > 0;

    if (!cameraMovedInLastSecond && !tilesWaiting && !cesium.viewer.clock.shouldAnimate && cesium.scene.tweens.length === 0) {
        if (cesium.verboseRendering) {
            console.log('stopping rendering @ ' + getTimestamp());
        }
        cesium.viewer.useDefaultRenderLoop = false;
        cesium.stoppedRendering = true;
    }

    Matrix4.clone(scene.camera.viewMatrix, cesium._lastCameraViewMatrix);

    var feature = cesium.terria.selectedFeature;
    if (defined(feature) && defined(feature.position)) {
        cesium._selectionIndicator.position = feature.position.getValue(cesium.terria.clock.currentTime);
    }
    cesium._selectionIndicator.update();
}

function selectFeature(cesium) {
    var feature = cesium.terria.selectedFeature;

    cesium._highlightFeature(feature);

    if (defined(feature) && defined(feature.position)) {
        cesium._selectionIndicator.position = feature.position.getValue(cesium.terria.clock.currentTime);
        cesium._selectionIndicator.animateAppear();
    } else {
        cesium._selectionIndicator.animateDepart();
    }

    cesium._selectionIndicator.update();
}

module.exports = Cesium;