Models/GeoJsonCatalogItem.js

"use strict";

/*global require*/

var Cartesian3 = require("terriajs-cesium/Source/Core/Cartesian3").default;
var Color = require("terriajs-cesium/Source/Core/Color").default;
var ColorMaterialProperty = require("terriajs-cesium/Source/DataSources/ColorMaterialProperty")
  .default;
var defined = require("terriajs-cesium/Source/Core/defined").default;

var DeveloperError = require("terriajs-cesium/Source/Core/DeveloperError")
  .default;
var Entity = require("terriajs-cesium/Source/DataSources/Entity").default;
var knockout = require("terriajs-cesium/Source/ThirdParty/knockout").default;
var loadBlob = require("../Core/loadBlob");
var loadJson = require("../Core/loadJson");
var PolylineGraphics = require("terriajs-cesium/Source/DataSources/PolylineGraphics")
  .default;
var PropertyBag = require("terriajs-cesium/Source/DataSources/PropertyBag")
  .default;
var JulianDate = require("terriajs-cesium/Source/Core/JulianDate").default;
var when = require("terriajs-cesium/Source/ThirdParty/when").default;
var defaultValue = require("terriajs-cesium/Source/Core/defaultValue").default;
var zip = require("terriajs-cesium/Source/ThirdParty/zip").default;

var PointGraphics = require("terriajs-cesium/Source/DataSources/PointGraphics")
  .default;
const HeightReference = require("terriajs-cesium/Source/Scene/HeightReference")
  .default;

var DataSourceCatalogItem = require("./DataSourceCatalogItem");
var standardCssColors = require("../Core/standardCssColors");
var formatPropertyValue = require("../Core/formatPropertyValue");
var hashFromString = require("../Core/hashFromString");
var inherit = require("../Core/inherit");
var Metadata = require("./Metadata");
var promiseFunctionToExplicitDeferred = require("../Core/promiseFunctionToExplicitDeferred");
var proxyCatalogItemUrl = require("./proxyCatalogItemUrl");
var readJson = require("../Core/readJson");
var TerriaError = require("../Core/TerriaError");
var Reproject = require("../Map/Reproject");
var i18next = require("i18next").default;

/**
 * A {@link CatalogItem} representing GeoJSON feature data.
 *
 * @alias GeoJsonCatalogItem
 * @constructor
 * @extends CatalogItem
 *
 * @param {Terria} terria The Terria instance.
 * @param {String} [url] The URL from which to retrieve the GeoJSON data.
 */
var GeoJsonCatalogItem = function(terria, url) {
  DataSourceCatalogItem.call(this, terria);

  this._dataSource = undefined;
  this._readyData = undefined;

  this.url = url;

  /**
   * Gets or sets the GeoJSON data, represented as a binary blob, object literal, or a Promise for one of those things.
   * If this property is set, {@link CatalogItem#url} is ignored.
   * This property is observable.
   * @type {Blob|Object|Promise}
   */
  this.data = undefined;

  /**
   * Gets or sets the URL from which the {@link GeoJsonCatalogItem#data} was obtained.  This will be used
   * to resolve any resources linked in the GeoJSON file, if any.
   * @type {String}
   */
  this.dataSourceUrl = undefined;

  /**
   * Gets or sets an object of style information which will be used instead of the default, but won't override
   * styles set on individual GeoJSON features. Styles follow the SimpleStyle spec: https://github.com/mapbox/simplestyle-spec/tree/master/1.1.0
   * `marker-opacity` and numeric values for `marker-size` are also supported.
   * @type {Object}
   */
  this.style = undefined;

  /**
   * Gets or sets a value indicating whether the features in this GeoJSON should be clamped to the terrain surface.
   * @type {Boolean}
   */
  this.clampToGround = undefined;

  /**
   * Gets or sets the opacity (alpha) of the data item, where 0.0 is fully transparent and 1.0 is
   * fully opaque.  This property is observable.
   * @type {Number}
   * @default 0.6
   */
  this.opacity = 0.6;

  knockout.track(this, [
    "data",
    "dataSourceUrl",
    "style",
    "clampToGround",
    "opacity"
  ]);

  knockout.getObservable(this, "opacity").subscribe(function() {
    updateOpacity(this);
  }, this);
};

inherit(DataSourceCatalogItem, GeoJsonCatalogItem);

Object.defineProperties(GeoJsonCatalogItem.prototype, {
  /**
   * Gets the type of data member represented by this instance.
   * @memberOf GeoJsonCatalogItem.prototype
   * @type {String}
   */
  type: {
    get: function() {
      return "geojson";
    }
  },

  /**
   * Gets a human-readable name for this type of data source, 'GeoJSON'.
   * @memberOf GeoJsonCatalogItem.prototype
   * @type {String}
   */
  typeName: {
    get: function() {
      return i18next.t("models.geoJson.name");
    }
  },

  /**
   * Gets the metadata associated with this data source and the server that provided it, if applicable.
   * @memberOf GeoJsonCatalogItem.prototype
   * @type {Metadata}
   */
  metadata: {
    get: function() {
      // TODO: maybe return the FeatureCollection's properties?
      var result = new Metadata();
      result.isLoading = false;
      result.dataSourceErrorMessage = i18next.t(
        "models.geoJson.dataSourceErrorMessage"
      );
      result.serviceErrorMessage = i18next.t(
        "models.geoJson.serviceErrorMessage"
      );
      return result;
    }
  },
  /**
   * Gets the data source associated with this catalog item.
   * @memberOf GeoJsonCatalogItem.prototype
   * @type {DataSource}
   */
  dataSource: {
    get: function() {
      return this._dataSource;
    }
  },

  /**
   * Gets a value indicating whether the opacity of this data source can be changed.
   * @memberOf GeoJsonCatalogItem.prototype
   * @type {Boolean}
   */
  supportsOpacity: {
    get: function() {
      return true;
    }
  }
});

GeoJsonCatalogItem.prototype._getValuesThatInfluenceLoad = function() {
  return [this.url, this.data];
};

var zipFileRegex = /.zip\b/i;
var geoJsonRegex = /.geojson\b/i;

var simpleStyleIdentifiers = [
  "title",
  "description", //
  "marker-size",
  "marker-symbol",
  "marker-color",
  "stroke", //
  "stroke-opacity",
  "stroke-width",
  "fill",
  "fill-opacity"
];

// This next function modelled on Cesium.geoJsonDataSource's defaultDescribe.
function describeWithoutUnderscores(properties, nameProperty) {
  var html = "";
  if (properties instanceof PropertyBag) {
    // unwrap the properties from the PropertyBag
    properties = properties.getValue(JulianDate.now());
  }
  for (var key in properties) {
    if (properties.hasOwnProperty(key)) {
      if (key === nameProperty || simpleStyleIdentifiers.indexOf(key) !== -1) {
        continue;
      }
      var value = properties[key];
      if (typeof value === "object") {
        value = describeWithoutUnderscores(value);
      } else {
        value = formatPropertyValue(value);
      }
      key = key.replace(/_/g, " ");
      if (defined(value)) {
        html += "<tr><th>" + key + "</th><td>" + value + "</td></tr>";
      }
    }
  }
  if (html.length > 0) {
    html =
      '<table class="cesium-infoBox-defaultTable"><tbody>' +
      html +
      "</tbody></table>";
  }
  return html;
}

GeoJsonCatalogItem.prototype._load = function() {
  var codeSplitDeferred = when.defer();

  var that = this;
  require.ensure(
    "terriajs-cesium/Source/DataSources/GeoJsonDataSource",
    function() {
      var GeoJsonDataSource = require("terriajs-cesium/Source/DataSources/GeoJsonDataSource")
        .default;

      promiseFunctionToExplicitDeferred(codeSplitDeferred, function() {
        // If there is an existing data source, remove it first.
        var reAdd = false;
        if (defined(that._dataSource)) {
          reAdd = that.terria.dataSources.remove(that._dataSource, true);
        }

        that._dataSource = new GeoJsonDataSource(that.name);

        if (reAdd) {
          that.terria.dataSources.add(that._dataSource);
        }

        if (defined(that.data)) {
          return when(that.data, function(data) {
            var promise;
            if (typeof Blob !== "undefined" && data instanceof Blob) {
              promise = readJson(data);
            } else if (data instanceof String || typeof data === "string") {
              try {
                promise = JSON.parse(data);
              } catch (e) {
                throw new TerriaError({
                  sender: that,
                  title: i18next.t("models.geoJson.errorLoadingTitle"),
                  message: i18next.t("models.geoJson.errorParsingMessage", {
                    appName: that.terria.appName,
                    email:
                      '<a href="mailto:' +
                      that.terria.supportEmail +
                      '">' +
                      that.terria.supportEmail +
                      "</a>."
                  })
                });
              }
            } else {
              promise = data;
            }

            return when(promise, function(json) {
              that.data = json;
              return updateModelFromData(that, json);
            }).otherwise(function() {
              throw new TerriaError({
                sender: that,
                title: i18next.t("models.geoJson.errorLoadingTitle"),
                message: i18next.t("models.geoJson.errorLoadingMessage", {
                  appName: that.terria.appName,
                  email:
                    '<a href="mailto:' +
                    that.terria.supportEmail +
                    '">' +
                    that.terria.supportEmail +
                    "</a>."
                })
              });
            });
          });
        } else {
          var jsonPromise;
          if (zipFileRegex.test(that.url)) {
            if (typeof FileReader === "undefined") {
              throw new TerriaError({
                sender: that,
                title: i18next.t("models.geoJson.unsupportedBrowserTitle"),
                message: i18next.t("models.geoJson.unsupportedBrowserMessage", {
                  appName: that.terria.appName,
                  chrome:
                    '<a href="http://www.google.com/chrome" target="_blank">' +
                    i18next.t("models.geoJson.chrome") +
                    "</a>",
                  firefox:
                    '<a href="http://www.mozilla.org/firefox" target="_blank">' +
                    i18next.t("models.geoJson.firefox") +
                    "</a>",
                  internetExplorer:
                    '<a href="http://www.microsoft.com/ie" target="_blank">' +
                    i18next.t("models.geoJson.internetExplorer") +
                    "</a>"
                })
              });
            }

            jsonPromise = loadBlob(
              proxyCatalogItemUrl(that, that.url, "1d")
            ).then(function(blob) {
              var deferred = when.defer();
              zip.createReader(
                new zip.BlobReader(blob),
                function(reader) {
                  // Look for a file with a .geojson extension.
                  reader.getEntries(function(entries) {
                    var resolved = false;
                    for (var i = 0; i < entries.length; i++) {
                      var entry = entries[i];
                      if (geoJsonRegex.test(entry.filename)) {
                        getJson(entry, deferred);
                        resolved = true;
                      }
                    }

                    if (!resolved) {
                      deferred.reject();
                    }
                  });
                },
                function(e) {
                  deferred.reject(e);
                }
              );
              return deferred.promise;
            });
          } else {
            jsonPromise = loadJson(proxyCatalogItemUrl(that, that.url, "1d"));
          }

          return jsonPromise
            .then(function(json) {
              return updateModelFromData(that, json);
            })
            .otherwise(function(e) {
              if (e instanceof TerriaError) {
                throw e;
              }

              throw new TerriaError({
                sender: that,
                title: i18next.t("models.geoJson.couldNotLoadTitle"),
                message: i18next.t("models.geoJson.couldNotLoadMessage", {
                  cors:
                    '<a href="http://enable-cors.org/" target="_blank">CORS</a>',
                  email:
                    '<a href="mailto:' +
                    that.terria.supportEmail +
                    '">' +
                    that.terria.supportEmail +
                    "</a>."
                })
              });
            });
        }
      });
    },
    "Cesium-DataSources"
  );

  return codeSplitDeferred.promise;
};

function getJson(entry, deferred) {
  entry.getData(new zip.Data64URIWriter(), function(uri) {
    deferred.resolve(loadJson(uri));
  });
}

function updateModelFromData(geoJsonItem, geoJson) {
  // If this GeoJSON data is an object literal with a single property, treat that
  // property as the name of the data source, and the property's value as the
  // actual GeoJSON.
  var numProperties = 0;
  var propertyName;
  for (propertyName in geoJson) {
    if (geoJson.hasOwnProperty(propertyName)) {
      ++numProperties;
      if (numProperties > 1) {
        break; // no need to count past 2 properties.
      }
    }
  }

  var name;
  if (numProperties === 1) {
    name = propertyName;
    geoJson = geoJson[propertyName];

    // If we don't already have a name, or our name is just derived from our URL, update the name.
    if (
      !defined(geoJsonItem.name) ||
      geoJsonItem.name.length === 0 ||
      nameIsDerivedFromUrl(geoJsonItem.name, geoJsonItem.url)
    ) {
      geoJsonItem.name = name;
    }
  }

  // Reproject the features if they're not already EPSG:4326.
  var promise = reprojectToGeographic(geoJsonItem, geoJson);

  return when(promise, function() {
    geoJsonItem._readyData = geoJson;

    return loadGeoJson(geoJsonItem);
  });
}

function nameIsDerivedFromUrl(name, url) {
  if (name === url) {
    return true;
  }

  if (!url) {
    return false;
  }

  // Is the name just the end of the URL?
  var indexOfNameInUrl = url.lastIndexOf(name);
  if (indexOfNameInUrl >= 0 && indexOfNameInUrl === url.length - name.length) {
    return true;
  }

  return false;
}

/**
 * Get a random color for the data based on the passed string (usually dataset name).
 * @private
 * @param  {String[]} cssColors Array of css colors, eg. ['#AAAAAA', 'red'].
 * @param  {String} name Name to base the random choice on.
 * @return {String} A css color, eg. 'red'.
 */
function getRandomCssColor(cssColors, name) {
  var index = hashFromString(name || "") % cssColors.length;
  return cssColors[index];
}

function loadGeoJson(geoJsonItem) {
  /* Style information is applied as follows, in decreasing priority:
    - simple-style properties set directly on individual features in the GeoJSON file
    - simple-style properties set as the 'Style' property on the catalog item
    - our 'options' set below (and point styling applied after Cesium loads the GeoJSON)
    - if anything is underspecified there, then Cesium's defaults come in.

    See https://github.com/mapbox/simplestyle-spec/tree/master/1.1.0
    */

  function defaultColor(colorString, name) {
    if (colorString === undefined) {
      var color = Color.fromCssColorString(
        getRandomCssColor(standardCssColors.highContrast, name)
      );
      color.alpha = 1;
      return color;
    } else {
      return Color.fromCssColorString(colorString);
    }
  }

  function getColor(color) {
    if (typeof color === "string" || color instanceof String) {
      return Color.fromCssColorString(color);
    } else {
      return color;
    }
  }

  function parseMarkerSize(sizeString) {
    var sizes = {
      small: 24,
      medium: 48,
      large: 64
    };

    if (sizeString === undefined) {
      return undefined;
    }

    if (sizes[sizeString]) {
      return sizes[sizeString];
    }
    return parseInt(sizeString, 10); // SimpleStyle doesn't allow 'marker-size: 20', but people will do it.
  }

  var dataSource = geoJsonItem._dataSource;
  var style = defaultValue(geoJsonItem.style, {});

  var options = {
    describe: describeWithoutUnderscores,
    markerSize: defaultValue(parseMarkerSize(style["marker-size"]), 20),
    markerSymbol: style["marker-symbol"], // and undefined if none
    markerColor: defaultColor(style["marker-color"], geoJsonItem.name),
    strokeWidth: defaultValue(style["stroke-width"], 2),
    polygonStroke: getColor(defaultValue(style.stroke, "#000000")),
    polylineStroke: defaultColor(style.stroke, geoJsonItem.name),
    markerOpacity: style["marker-opacity"], // not in SimpleStyle spec or supported by Cesium but see below
    clampToGround: geoJsonItem.clampToGround,
    markerUrl: defaultValue(style["marker-url"], null) // not in SimpleStyle spec but gives an alternate to maki marker symbols
  };

  options.fill = defaultColor(style.fill, (geoJsonItem.name || "") + " fill");
  if (defined(style["stroke-opacity"])) {
    options.stroke.alpha = parseFloat(style["stroke-opacity"]);
  }
  if (defined(style["fill-opacity"])) {
    options.fill.alpha = parseFloat(style["fill-opacity"]);
  } else {
    options.fill.alpha = 0.75;
  }
  geoJsonItem.opacity = options.fill.alpha;
  return dataSource.load(geoJsonItem._readyData, options).then(function() {
    var entities = dataSource.entities.values;

    for (var i = 0; i < entities.length; ++i) {
      var entity = entities[i];

      var properties = entity.properties || {};
      // If we've got a marker url use that in a billboard
      if (
        (defined(entity.billboard) && defined(options.markerUrl)) ||
        (defined(entity.billboard) && properties["marker-url"])
      ) {
        const url = options.markerUrl
          ? options.markerUrl
          : properties["marker-url"].getValue();
        entity.billboard = {
          image: proxyCatalogItemUrl(geoJsonItem, url),
          width: style["marker-width"],
          height: style["marker-height"],
          rotation: style["marker-angle"],
          color: Color.WHITE
        };

        /* If no marker symbol was provided but Cesium has generated one for a point, then turn it into
         a filled circle instead of the default marker. */
      } else if (
        defined(entity.billboard) &&
        !defined(properties["marker-symbol"]) &&
        !defined(options.markerSymbol)
      ) {
        entity.point = new PointGraphics({
          color: getColor(
            defaultValue(properties["marker-color"], options.markerColor)
          ),
          pixelSize: defaultValue(
            properties["marker-size"],
            options.markerSize / 2
          ),
          outlineWidth: defaultValue(
            properties["stroke-width"],
            options.strokeWidth
          ),
          outlineColor: getColor(
            defaultValue(properties.stroke, options.polygonStroke)
          ),
          heightReference: options.clampToGround
            ? HeightReference.RELATIVE_TO_GROUND
            : null
        });
        if (defined(properties["marker-opacity"])) {
          // not part of SimpleStyle spec, but why not?
          entity.point.color.alpha = parseFloat(properties["marker-opacity"]);
        }
        entity.billboard = undefined;
      }
      if (defined(entity.billboard) && defined(properties["marker-opacity"])) {
        entity.billboard.color = new Color(
          1.0,
          1.0,
          1.0,
          parseFloat(properties["marker-opacity"])
        );
      }

      // Cesium on Windows can't render polygons with a stroke-width > 1.0.  And even on other platforms it
      // looks bad because WebGL doesn't mitre the lines together nicely.
      // As a workaround for the special case where the polygon is unfilled anyway, change it to a polyline.
      if (
        defined(entity.polygon) &&
        polygonHasWideOutline(entity.polygon) &&
        !polygonIsFilled(entity.polygon)
      ) {
        entity.polyline = new PolylineGraphics();
        entity.polyline.show = entity.polygon.show;

        if (defined(entity.polygon.outlineColor)) {
          entity.polyline.material = new ColorMaterialProperty(
            entity.polygon.outlineColor.getValue()
          );
        }

        var hierarchy = entity.polygon.hierarchy.getValue();

        var positions = hierarchy.positions;
        closePolyline(positions);

        entity.polyline.positions = positions;
        entity.polyline.width = entity.polygon.outlineWidth;

        createEntitiesFromHoles(dataSource.entities, hierarchy.holes, entity);

        entity.polygon = undefined;
      }
    }
  });
}

function createEntitiesFromHoles(entityCollection, holes, mainEntity) {
  if (!defined(holes)) {
    return;
  }

  for (var i = 0; i < holes.length; ++i) {
    createEntityFromHole(entityCollection, holes[i], mainEntity);
  }
}

function createEntityFromHole(entityCollection, hole, mainEntity) {
  if (
    !defined(hole) ||
    !defined(hole.positions) ||
    hole.positions.length === 0
  ) {
    return;
  }

  var entity = new Entity();

  entity.name = mainEntity.name;
  entity.availability = mainEntity.availability;
  entity.description = mainEntity.description;
  entity.properties = mainEntity.properties;

  entity.polyline = new PolylineGraphics();
  entity.polyline.show = mainEntity.polyline.show;
  entity.polyline.material = mainEntity.polyline.material;
  entity.polyline.width = mainEntity.polyline.width;

  closePolyline(hole.positions);
  entity.polyline.positions = hole.positions;

  entityCollection.add(entity);

  createEntitiesFromHoles(entityCollection, hole.holes, mainEntity);
}

function closePolyline(positions) {
  // If the first and last positions are more than a meter apart, duplicate the first position so the polyline is closed.
  if (
    positions.length >= 2 &&
    !Cartesian3.equalsEpsilon(
      positions[0],
      positions[positions.length - 1],
      0.0,
      1.0
    )
  ) {
    positions.push(positions[0]);
  }
}

function polygonHasWideOutline(polygon) {
  return defined(polygon.outlineWidth) && polygon.outlineWidth.getValue() > 1;
}

function polygonIsFilled(polygon) {
  var fill = true;
  if (defined(polygon.fill)) {
    fill = polygon.fill.getValue();
  }

  if (!fill) {
    return false;
  }

  if (!defined(polygon.material)) {
    // The default is solid white.
    return true;
  }

  var materialProperties = polygon.material.getValue();
  if (
    defined(materialProperties) &&
    defined(materialProperties.color) &&
    materialProperties.color.alpha === 0.0
  ) {
    return false;
  }

  return true;
}

function reprojectToGeographic(geoJsonItem, geoJson) {
  var code;

  if (!defined(geoJson.crs)) {
    code = undefined;
  } else if (geoJson.crs.type === "EPSG") {
    code = "EPSG:" + geoJson.crs.properties.code;
  } else if (
    geoJson.crs.type === "name" &&
    defined(geoJson.crs.properties) &&
    defined(geoJson.crs.properties.name)
  ) {
    code = Reproject.crsStringToCode(geoJson.crs.properties.name);
  }

  geoJson.crs = {
    type: "EPSG",
    properties: {
      code: "4326"
    }
  };

  if (!Reproject.willNeedReprojecting(code)) {
    return true;
  }

  return when(
    Reproject.checkProjection(
      geoJsonItem.terria.configParameters.proj4ServiceBaseUrl,
      code
    ),
    function(result) {
      if (result) {
        filterValue(geoJson, "coordinates", function(obj, prop) {
          obj[prop] = filterArray(obj[prop], function(pts) {
            if (pts.length === 0) return [];
            return reprojectPointList(pts, code);
          });
        });
      } else {
        throw new DeveloperError(
          "The crs code for this datasource is unsupported."
        );
      }
    }
  );
}

// Reproject a point list based on the supplied crs code.
function reprojectPointList(pts, code) {
  if (!(pts[0] instanceof Array)) {
    return Reproject.reprojectPoint(pts, code, "EPSG:4326");
  }
  var pts_out = [];
  for (var i = 0; i < pts.length; i++) {
    pts_out.push(Reproject.reprojectPoint(pts[i], code, "EPSG:4326"));
  }
  return pts_out;
}

// Find a member by name in the gml.
function filterValue(obj, prop, func) {
  for (var p in obj) {
    if (obj.hasOwnProperty(p) === false) {
      continue;
    } else if (p === prop) {
      if (func && typeof func === "function") {
        func(obj, prop);
      }
    } else if (typeof obj[p] === "object") {
      filterValue(obj[p], prop, func);
    }
  }
}

// Filter a geojson coordinates array structure.
function filterArray(pts, func) {
  if (!(pts[0] instanceof Array) || !(pts[0][0] instanceof Array)) {
    pts = func(pts);
    return pts;
  }

  var result = new Array(pts.length);
  for (var i = 0; i < pts.length; i++) {
    result[i] = filterArray(pts[i], func); //at array of arrays of points
  }
  return result;
}

function updateOpacity(item) {
  const entities = item.dataSource.entities.values;
  item.dataSource.entities.suspendEvents();
  for (var i = 0; i < entities.length; i++) {
    const entity = entities[i];

    if (defined(entity.billboard)) {
      entity.billboard.color = entity.billboard.color
        .getValue()
        .withAlpha(item.opacity);
    }

    if (defined(entity.point)) {
      entity.point.color = entity.point.color
        .getValue()
        .withAlpha(item.opacity);
      entity.point.outlineColor = entity.point.outlineColor
        .getValue()
        .withAlpha(item.opacity);
    }

    if (defined(entity.polyline)) {
      entity.polyline.material.color = entity.polyline.material
        .getValue()
        .color.withAlpha(item.opacity);
    }

    if (defined(entity.polygon)) {
      entity.polygon.material.color = entity.polygon.material
        .getValue()
        .color.withAlpha(item.opacity);
      entity.polygon.outlineColor = entity.polygon.outlineColor
        .getValue()
        .withAlpha(item.opacity);
    }
  }
  item.dataSource.entities.resumeEvents();
  item.terria.currentViewer.notifyRepaintRequired();
}

module.exports = GeoJsonCatalogItem;