Source: src/js/models/metadata/eml211/EMLGeoCoverage.js

/* global define */
define(["jquery", "underscore", "backbone", "models/DataONEObject"], function (
  $,
  _,
  Backbone,
  DataONEObject
) {
  /**
   * @class EMLGeoCoverage
   * @classdesc A description of geographic coverage of a dataset, per the EML
   * 2.1.1 metadata standard
   * @classcategory Models/Metadata/EML211
   * @extends Backbone.Model
   * @constructor
   */
  var EMLGeoCoverage = Backbone.Model.extend(
    /** @lends EMLGeoCoverage.prototype */ {
      defaults: {
        objectXML: null,
        objectDOM: null,
        parentModel: null,
        description: null,
        east: null,
        north: null,
        south: null,
        west: null,
      },

      initialize: function (attributes) {
        if (attributes && attributes.objectDOM)
          this.set(this.parse(attributes.objectDOM));

        //specific attributes to listen to
        this.on(
          "change:description " +
            "change:east " +
            "change:west " +
            "change:south " +
            "change:north",
          this.trickleUpChange
        );
      },

      /*
       * Maps the lower-case EML node names (valid in HTML DOM) to the
       * camel-cased EML node names (valid in EML). Used during parse() and
       * serialize()
       */
      nodeNameMap: function () {
        return {
          altitudemaximum: "altitudeMaximum",
          altitudeminimum: "altitudeMinimum",
          altitudeunits: "altitudeUnits",
          boundingaltitudes: "boundingAltitudes",
          boundingcoordinates: "boundingCoordinates",
          eastboundingcoordinate: "eastBoundingCoordinate",
          geographiccoverage: "geographicCoverage",
          geographicdescription: "geographicDescription",
          northboundingcoordinate: "northBoundingCoordinate",
          southboundingcoordinate: "southBoundingCoordinate",
          westboundingcoordinate: "westBoundingCoordinate",
        };
      },

      /**
       * The map of error keys to human-readable error messages to use for
       * validation issues.
       * @type {Object}
       * @property {string} default - When the error is not in this list
       * @property {string} north - When the Northwest latitude is not in the
       * valid range
       * @property {string} east - When the Southeast longitude is not in the
       * valid range
       * @property {string} south - When the Southeast latitude is not in the
       * valid range
       * @property {string} west - When the Northwest longitude is not in the
       * valid range
       * @property {string} missing - When a long or lat coordinate is missing
       * @property {string} description - When a description is missing
       * @property {string} needPair - When there are no coordinate pairs
       * @property {string} northSouthReversed - When the North latitude is less
       * than the South latitude
       * @property {string} crossesAntiMeridian - When the bounding box crosses
       * the anti-meridian
       * @since 2.27.0
       */
      errorMessages: {
        "default": "Please correct the geographic coverage.",
        "north": "Northwest latitude out of range, must be >-90 and <90. Please correct the latitude.",
        "east": "Southeast longitude out of range (-180 to 180). Please adjust the longitude.",
        "south": "Southeast latitude out of range, must be >-90 and <90. Please correct the latitude.",
        "west": "Northwest longitude out of range (-180 to 180). Check and correct the longitude.",
        "missing": "Latitude and longitude are required for each coordinate. Please complete all fields.",
        "description": "Missing location description. Please add a brief description.",
        "needPair": "Location requires at least one coordinate pair. Please add coordinates.",
        "northSouthReversed": "North latitude should be greater than South. Please swap the values.",
        "crossesAntiMeridian": "Bounding box crosses the anti-meridian. Please use multiple boxes that meet at the anti-meridian instead.",
      },

      /**
       * Parses the objectDOM to populate this model with data.
       * @param {Element} objectDOM - The EML object element
       * @returns {Object} The EMLGeoCoverage data
       *
       * @example - Example input XML
       * <geographicCoverage scope="document">
       *   <geographicDescription>Rhine-Main-Observatory</geographicDescription>
       *   <boundingCoordinates>
       *     <westBoundingCoordinate>9.0005</westBoundingCoordinate>
       *     <eastBoundingCoordinate>9.0005</eastBoundingCoordinate>
       *     <northBoundingCoordinate>50.1600</northBoundingCoordinate>
       *     <southBoundingCoordinate>50.1600</southBoundingCoordinate>
       *   </boundingCoordinates>
       * </geographicCoverage>
       */
      parse: function (objectDOM) {
        var modelJSON = {};

        if (!objectDOM) {
          if (this.get("objectDOM")) var objectDOM = this.get("objectDOM");
          else return {};
        }

        //Create a jQuery object of the DOM
        var $objectDOM = $(objectDOM);

        //Get the geographic description
        modelJSON.description = $objectDOM
          .children("geographicdescription")
          .text();

        //Get the bounding coordinates
        var boundingCoordinates = $objectDOM.children("boundingcoordinates");
        if (boundingCoordinates) {
          modelJSON.east = boundingCoordinates
            .children("eastboundingcoordinate")
            .text()
            .replace("+", "");
          modelJSON.north = boundingCoordinates
            .children("northboundingcoordinate")
            .text()
            .replace("+", "");
          modelJSON.south = boundingCoordinates
            .children("southboundingcoordinate")
            .text()
            .replace("+", "");
          modelJSON.west = boundingCoordinates
            .children("westboundingcoordinate")
            .text()
            .replace("+", "");
        }

        return modelJSON;
      },

      /**
       * Converts this EMLGeoCoverage to XML
       * @returns {string} The XML string
       */
      serialize: function () {
        const objectDOM = this.updateDOM();
        let xmlString = objectDOM?.outerHTML;
        if (!xmlString) xmlString = objectDOM?.[0]?.outerHTML;

        //Camel-case the XML
        xmlString = this.formatXML(xmlString);

        return xmlString;
      },

      /*
       * Makes a copy of the original XML DOM and updates it with the new values
       * from the model.
       */
      updateDOM: function () {
        var objectDOM;

        if (!this.isValid()) {
          return "";
        }

        if (this.get("objectDOM")) {
          objectDOM = $(this.get("objectDOM").cloneNode(true));
        } else {
          objectDOM = $(document.createElement("geographiccoverage"));
        }

        //If only one point is given, make sure both points are the same
        if (
          this.get("north") &&
          this.get("west") &&
          !this.get("south") &&
          !this.get("east")
        ) {
          this.set("south", this.get("north"));
          this.set("east", this.get("west"));
        } else if (
          this.get("south") &&
          this.get("east") &&
          !this.get("north") &&
          !this.get("west")
        ) {
          this.set("north", this.get("south"));
          this.set("west", this.get("east"));
        }

        // Description
        if (!objectDOM.children("geographicdescription").length)
          objectDOM.append(
            $(document.createElement("geographicdescription")).text(
              this.get("description")
            )
          );
        else
          objectDOM
            .children("geographicdescription")
            .text(this.get("description"));

        // Create the bounding coordinates element
        var boundingCoordinates = objectDOM.find("boundingcoordinates");
        if (!boundingCoordinates.length) {
          boundingCoordinates = document.createElement("boundingcoordinates");
          objectDOM.append(boundingCoordinates);
        }

        //Empty out the coordinates first
        $(boundingCoordinates).empty();

        //Add the four coordinate values
        $(boundingCoordinates).append(
          $(document.createElement("westboundingcoordinate")).text(
            this.get("west")
          ),
          $(document.createElement("eastboundingcoordinate")).text(
            this.get("east")
          ),
          $(document.createElement("northboundingcoordinate")).text(
            this.get("north")
          ),
          $(document.createElement("southboundingcoordinate")).text(
            this.get("south")
          )
        );

        return objectDOM;
      },

      /**
       * Sometimes we'll need to add a space between error messages, but only if
       * an error has already been triggered. Use addSpace to accomplish this.
       *
       * @param {string} msg The string that will be appended
       * @param {bool} front A flag that when set will append the whitespace to
       * the front of 'msg'
       * @return {string} The string that was passed in, 'msg', with whitespace
       * appended
       */
      addSpace: function (msg, front) {
        if (typeof front === "undefined") {
          front = false;
        }
        if (msg) {
          if (front) {
            return " " + msg;
          }
          return (msg += " ");
        }
        return msg;
      },

      /**
       * Because the same error messages are used in a couple of different
       * places, we centralize the strings and access here.
       *
       * @param {string} area Specifies the area that the error message belongs
       * to. Browse through the switch statement to find the one you need.
       * @return {string} The error message
       */
      getErrorMessage: function (area) {
        return this.errorMessages[area] || this.errorMessages.default;
      },

      /**
       * Generates an object that describes the current state of each latitude
       * and longitude box. The status includes whether there is a value and if
       * the value is valid.
       *
       * @return {array} An array containing the current state of each
       * coordinate box, including: value (the value of the coordinate converted
       * to a number), isSet (whether the coordinate has a value), and isValid
       * (whether the value is in the correct range)
       */
      getCoordinateStatus: function () {
        var north = this.get("north"),
          east = this.get("east"),
          south = this.get("south"),
          west = this.get("west");

        const isDefined = (value) =>
          typeof value !== "undefined" && value != null && value !== "";

        return {
          north: {
            value: Number(north),
            isSet: isDefined(north),
            isValid: this.validateCoordinate(north, -90, 90),
          },
          east: {
            value: Number(east),
            isSet: isDefined(east),
            isValid: this.validateCoordinate(east, -180, 180),
          },
          south: {
            value: Number(south),
            isSet: isDefined(south),
            isValid: this.validateCoordinate(south, -90, 90),
          },
          west: {
            value: Number(west),
            isSet: isDefined(west),
            isValid: this.validateCoordinate(west, -180, 180),
          },
        };
      },

      /**
       * Checks the status object for conditions that warrant an error message
       * to the user. This is called during the validation processes (validate()
       * and updateModel()) after the status object has been created by
       * getCoordinateStatus().
       *
       * @param status The status object, holding the state of the coordinates
       * @return {string} Any errors that need to be displayed to the user
       */
      generateStatusErrors: function (status) {
        var errorMsg = "";

        // Northwest Latitude
        if (status.north.isSet && !status.north.isValid) {
          errorMsg = this.addSpace(errorMsg);
          errorMsg += this.getErrorMessage("north");
        }
        // Northwest Longitude
        if (status.west.isSet && !status.west.isValid) {
          errorMsg = this.addSpace(errorMsg);
          errorMsg += this.getErrorMessage("west");
        }
        // Southeast Latitude
        if (status.south.isSet && !status.south.isValid) {
          errorMsg = this.addSpace(errorMsg);
          errorMsg += this.getErrorMessage("south");
        }
        // Southeast Longitude
        if (status.east.isSet && !status.east.isValid) {
          errorMsg = this.addSpace(errorMsg);
          errorMsg += this.getErrorMessage("east");
        }
        return errorMsg;
      },

      /**
       * This grabs the various location elements and validates the user input.
       * In the case of an error, we append an error string (errMsg) so that we
       * display all of the messages at the same time. This validates the entire
       * location row by adding extra checks for a description and for
       * coordinate pairs
       *
       * @return {string} The error messages that the user will see
       */
      validate: function () {
        var errors = {};

        if (!this.get("description")) {
          errors.description = this.getErrorMessage("description");
        }

        var pointStatuses = this.getCoordinateStatus();

        if (
          !pointStatuses.north.isSet &&
          !pointStatuses.south.isSet &&
          !pointStatuses.east.isSet &&
          !pointStatuses.west.isSet
        ) {
          errors.north = this.getErrorMessage("needPair");
          errors.west = "";
        }

        //Check that all the values are correct
        if (pointStatuses.north.isSet && !pointStatuses.north.isValid)
          errors.north = this.getErrorMessage("north");
        if (pointStatuses.south.isSet && !pointStatuses.south.isValid)
          errors.south = this.getErrorMessage("south");
        if (pointStatuses.east.isSet && !pointStatuses.east.isValid)
          errors.east = this.getErrorMessage("east");
        if (pointStatuses.west.isSet && !pointStatuses.west.isValid)
          errors.west = this.getErrorMessage("west");

        if (pointStatuses.north.isSet && !pointStatuses.west.isSet)
          errors.west = this.getErrorMessage("missing");
        else if (!pointStatuses.north.isSet && pointStatuses.west.isSet)
          errors.north = this.getErrorMessage("missing");
        else if (pointStatuses.south.isSet && !pointStatuses.east.isSet)
          errors.east = this.getErrorMessage("missing");
        else if (!pointStatuses.south.isSet && pointStatuses.east.isSet)
          errors.south = this.getErrorMessage("missing");

        // Verify latitudes: north should be > south.
        if (
          pointStatuses.north.isSet &&
          pointStatuses.south.isSet &&
          pointStatuses.north.isValid &&
          pointStatuses.south.isValid
        ) {
          if (pointStatuses.north.value < pointStatuses.south.value) {
            const msg = this.getErrorMessage("northSouthReversed");
            errors.north = msg;
            errors.south = msg;
          }
        }

        // For longitudes, don't allow bounding boxes that attempt to traverse
        // the anti-meridian
        if (
          pointStatuses.east.isSet &&
          pointStatuses.west.isSet &&
          pointStatuses.east.isValid &&
          pointStatuses.west.isValid
        ) {
          if (pointStatuses.east.value < pointStatuses.west.value) {
            const msg = this.getErrorMessage("crossesAntiMeridian");
            errors.east = msg;
            errors.west = msg;
          }
        }

        if (Object.keys(errors).length) return errors;
        else return false;
      },

      /**
       * Checks for any coordinates with missing counterparts.
       *
       * @param status The status of the coordinates
       * @return {bool} True if there are missing coordinates, false otherwise
       */
      hasMissingPoint: function (status) {
        if (
          (status.north.isSet && !status.west.isSet) ||
          (!status.north.isSet && status.west.isSet)
        ) {
          return true;
        } else if (
          (status.south.isSet && !status.east.isSet) ||
          (!status.south.isSet && status.east.isSet)
        ) {
          return true;
        }

        return false;
      },

      /**
       * Checks that there are either two or four coordinate values. If there
       * aren't, it means that the user still needs to enter coordinates.
       *
       * @param status The current state of the coordinates
       * @return {bool} True if there are pairs, false otherwise
       */
      checkForPairs: function (status) {
        var isSet = _.filter(status, function (coord) {
          return coord.isSet == true;
        });

        if (isSet.length == 0) {
          return false;
        }
        return true;
      },

      /**
       * Validate a coordinate String by making sure it can be coerced into a
       * number and is within the given bounds. Note: Min and max are inclusive
       *
       * @param value {string} The value of the edit area that will be validated
       * @param min The minimum value that 'value' can be
       * @param max The maximum value that 'value' can be
       * @return {bool} True if the validation passed, otherwise false
       */
      validateCoordinate: function (value, min, max) {
        if (
          typeof value === "undefined" ||
          value === null ||
          (value === "" && isNaN(value))
        ) {
          return false;
        }

        var parsed = Number(value);

        if (isNaN(parsed)) {
          return false;
        }

        if (parsed < min || parsed > max) {
          return false;
        }

        return true;
      },

      /**
       * Climbs up the model hierarchy until it finds the EML model
       *
       * @return {EML211|false} - Returns the EML 211 Model or false if not
       * found
       */
      getParentEML: function () {
        var emlModel = this.get("parentModel"),
          tries = 0;

        while (emlModel.type !== "EML" && tries < 6) {
          emlModel = emlModel.get("parentModel");
          tries++;
        }

        if (emlModel && emlModel.type == "EML") return emlModel;
        else return false;
      },

      /**
       * Apply the change event on the parent EML model
       */
      trickleUpChange: function () {
        const parentModel = this.get("parentModel");
        if (!parentModel) return;
        parentModel.trigger("change");
        parentModel.trigger("change:geoCoverage");
      },

      /**
       * See DataONEObject.formatXML()
       */
      formatXML: function (xmlString) {
        return DataONEObject.prototype.formatXML.call(this, xmlString);
      },
    }
  );

  return EMLGeoCoverage;
});