Models/Cesium.js

"use strict";

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

var CesiumSelectionIndicator = require("../Map/CesiumSelectionIndicator");
var Feature = require("./Feature");
var GlobeOrMap = require("./GlobeOrMap");
var inherit = require("../Core/inherit");
var pollToPromise = require("../Core/pollToPromise");
var TerriaError = require("../Core/TerriaError");
var PickedFeatures = require("../Map/PickedFeatures");
var ViewerMode = require("./ViewerMode");
var i18next = require("i18next").default;

/**
 * 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;

  /**
   * Gets the {@link DataSourceDisplay} used to render a {@link DataSource}.
   * @type {DataSourceDisplay}
   */
  this.dataSourceDisplay = undefined;

  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;

  this.supportsPolylinesOnTerrain = this.scene.context.depthTexture;

  // 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: i18next.t("models.cesiumTerrain.errorRenderingTitle"),
          message:
            i18next.t("models.cesiumTerrain.errorRenderingTitle", {
              appName: terria.appName,
              email:
                '<a href="mailto:' +
                terria.supportEmail +
                '">' +
                terria.supportEmail +
                "</a>."
            }) +
            "<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);
  window.removeEventListener("resize", this._setViewerResolution, 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|DataSource|Cesium3DTileset} target The view, extent, DataSource, or Cesium3DTileset to which to zoom.
 * @param {Number} [flightDurationSeconds=3.0] The length of the flight animation in seconds.
 */
Cesium.prototype.zoomTo = function(target, flightDurationSeconds) {
  if (!defined(target)) {
    throw new DeveloperError("target is required.");
  }

  flightDurationSeconds = defaultValue(flightDurationSeconds, 3.0);

  var that = this;
  that.lastTarget = target;
  return when()
    .then(function() {
      if (target instanceof Rectangle) {
        var camera = that.scene.camera;

        // Work out the destination that the camera would naturally fly to
        var destinationCartesian = camera.getRectangleCameraCoordinates(target);
        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(target)];

        // Perform an elevation query at the centre of the rectangle
        return sampleTerrain(terrainProvider, level, positions).then(function(
          results
        ) {
          if (that.lastTarget !== target) {
            return;
          }
          // 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(target.entities)) {
        // Zooming to a DataSource
        if (target.isLoading && defined(target.loadingEvent)) {
          var deferred = when.defer();
          var removeEvent = target.loadingEvent.addEventListener(function() {
            removeEvent();
            deferred.resolve();
          });
          return deferred.promise.then(function() {
            if (that.lastTarget !== target) {
              return;
            }
            return zoomToDataSource(that, target, flightDurationSeconds);
          });
        }
        return zoomToDataSource(that, target);
      } else if (defined(target.readyPromise)) {
        return target.readyPromise.then(function() {
          if (defined(target.boundingSphere) && that.lastTarget === target) {
            zoomToBoundingSphere(that, target, flightDurationSeconds);
          }
        });
      } else if (defined(target.boundingSphere)) {
        return zoomToBoundingSphere(that, target);
      } else if (defined(target.position)) {
        that.scene.camera.flyTo({
          duration: flightDurationSeconds,
          destination: target.position,
          orientation: {
            direction: target.direction,
            up: target.up
          }
        });
      } else {
        that.scene.camera.flyTo({
          duration: flightDurationSeconds,
          destination: target.rectangle
        });
      }
    })
    .then(function() {
      that.notifyRepaintRequired();
    });
};

var boundingSphereScratch = new BoundingSphere();

function zoomToDataSource(cesium, target, flightDurationSeconds) {
  return pollToPromise(
    function() {
      var dataSourceDisplay = cesium.dataSourceDisplay;
      var entities = target.entities.values;

      var boundingSpheres = [];
      for (var i = 0, len = entities.length; i < len; i++) {
        var state = BoundingSphereState.PENDING;
        try {
          state = dataSourceDisplay.getBoundingSphere(
            entities[i],
            false,
            boundingSphereScratch
          );
        } catch (e) {}

        if (state === BoundingSphereState.PENDING) {
          return false;
        } else if (state !== BoundingSphereState.FAILED) {
          boundingSpheres.push(BoundingSphere.clone(boundingSphereScratch));
        }
      }

      var boundingSphere = BoundingSphere.fromBoundingSpheres(boundingSpheres);
      cesium.scene.camera.flyToBoundingSphere(boundingSphere, {
        duration: flightDurationSeconds
      });
      return true;
    },
    {
      pollInterval: 100,
      timeout: 5000
    }
  );
}

function zoomToBoundingSphere(cesium, target, flightDurationSeconds) {
  var boundingSphere = target.boundingSphere;
  var modelMatrix = target.modelMatrix;
  if (modelMatrix) {
    boundingSphere = BoundingSphere.transform(boundingSphere, modelMatrix);
  }
  cesium.scene.camera.flyToBoundingSphere(boundingSphere, {
    offset: new HeadingPitchRange(0.0, -0.5, boundingSphere.radius),
    duration: flightDurationSeconds
  });
}

/**
 * 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.
 * @param {Boolean} ignoreSplitter An optional arg to ignore the splitter and return all feature picking results. Defaults to false.
 */
Cesium.prototype.pickFromLocation = function(
  latlng,
  imageryLayerCoords,
  existingFeatures,
  ignoreSplitter
) {
  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 = [];

  if (this.terria.allowFeatureInfoRequests) {
    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,
    ignoreSplitter
  );

  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.
 * @param {Boolean} ignoreSplitter An optional arg to ignore the splitter and return all feature picking results. Defaults to false.
 */
Cesium.prototype.pickFromScreenPosition = function(
  screenPosition,
  ignoreSplitter
) {
  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.terria.allowFeatureInfoRequests
    ? this.scene.imageryLayers.pickImageryLayerFeatures(pickRay, this.scene)
    : Promise.resolve();

  var result = this._buildPickedFeatures(
    providerCoords,
    pickPosition,
    vectorFeatures,
    [pickRasterPromise],
    undefined,
    pickPositionCartographic.height,
    ignoreSplitter
  );
  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 pickedList = this.scene.drillPick(screenPosition);
  for (var i = 0; i < pickedList.length; ++i) {
    var picked = pickedList[i];
    var id = picked.id;

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

    if (!defined(id) && defined(picked.primitive)) {
      id = picked.primitive.id;
    }
    if (id instanceof Entity && vectorFeatures.indexOf(id) === -1) {
      var feature = Feature.fromEntityCollectionOrEntity(id);
      vectorFeatures.push(feature);
    } else if (
      picked.primitive &&
      picked.primitive._catalogItem &&
      picked.primitive._catalogItem.getFeaturesFromPickResult
    ) {
      var result = picked.primitive._catalogItem.getFeaturesFromPickResult(
        screenPosition,
        picked
      );
      if (result) {
        if (Array.isArray(result)) {
          vectorFeatures.push(...result);
        } else {
          vectorFeatures.push(result);
        }
      }
    }
  }

  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.
 * @param {Boolean} ignoreSplitter Ignore the splitter and return all feature picking results
 * @returns {PickedFeatures} A {@link PickedFeatures} object that is a combination of everything passed.
 * @private
 */
Cesium.prototype._buildPickedFeatures = function(
  providerCoords,
  pickPosition,
  existingFeatures,
  featurePromises,
  imageryLayers,
  defaultHeight,
  ignoreSplitter
) {
  ignoreSplitter = defaultValue(ignoreSplitter, false);
  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;
            }

            var features = 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)
            );

            if (this.terria.showSplitter && !ignoreSplitter) {
              // Select only features from the same side or both sides of the splitter
              var screenPosition = this.computePositionOnScreen(
                result.pickPosition
              );
              var pickedSide = this.terria.getSplitterSideForScreenPosition(
                screenPosition
              );

              features = features.filter(function(feature) {
                var splitDirection = feature.imageryLayer.splitDirection;
                return (
                  splitDirection === pickedSide ||
                  splitDirection === ImagerySplitDirection.NONE
                );
              });
            }

            return resultFeaturesSoFar.concat(features);
          }.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)) {
    var position;

    if (defined(cesium.dataSourceDisplay)) {
      var originalEntity = defined(feature.cesiumEntity)
        ? feature.cesiumEntity
        : feature;
      var state = cesium.dataSourceDisplay.getBoundingSphere(
        originalEntity,
        true,
        boundingSphereScratch
      );

      if (state === BoundingSphereState.DONE) {
        position = Cartesian3.clone(boundingSphereScratch.center);
      }
    }

    if (!defined(position) && defined(feature.position)) {
      position = feature.position.getValue(cesium.terria.clock.currentTime);
    }

    if (defined(position)) {
      cesium._selectionIndicator.position = position;
    }
  }

  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;