Source: src/js/views/maps/ScaleBarView.js


'use strict';

define(
  [
    'jquery',
    'underscore',
    'backbone',
    'text!templates/maps/scale-bar.html'
  ],
  function (
    $,
    _,
    Backbone,
    Template
  ) {

    /**
    * @class ScaleBarView
    * @classdesc The scale bar is a legend for a map that shows the current longitude,
    * latitude, and elevation, as well as a scale bar to indicate the relative size of
    * geo-spatial features.
    * @classcategory Views/Maps
    * @name ScaleBarView
    * @extends Backbone.View
    * @screenshot views/maps/ScaleBarView.png
    * @since 2.18.0
    * @constructs
    */
    var ScaleBarView = Backbone.View.extend(
      /** @lends ScaleBarView.prototype */{

        /**
        * The type of View this is
        * @type {string}
        */
        type: 'ScaleBarView',

        /**
        * The HTML classes to use for this view's element
        * @type {string}
        */
        className: 'scale-bar',

        /**
         * The model that holds the current scale of the map in pixels:meters
         * @type {GeoScale}
         * @since 2.27.0
         */
        scaleModel: null,

        /**
         * The model that holds the current position of the mouse on the map
         * @type {GeoPoint}
         * @since 2.27.0
         */
        pointModel: null,

        /**
         * The primary HTML template for this view
         * @type {Underscore.template}
         */
        template: _.template(Template),

        /**
         * Classes that will be used to select elements from the template that will be
         * updated with new coordinates and scale.
         * @name ScaleBarView#classes
         * @type {Object}
         * @property {string} longitude The element that will contain the longitude
         * measurement
         * @property {string} latitude The element that will contain the latitude
         * measurement
         * @property {string} elevation The element that will contain the elevation
         * measurement
         * @property {string} bar The element that will be used as a scale bar
         * @property {string} distance The element that will contain the distance
         * measurement
         */
        classes: {
          longitude: 'scale-bar__coord--longitude',
          latitude: 'scale-bar__coord--latitude',
          elevation: 'scale-bar__coord--elevation',
          longitudeLabel: 'scale-bar__label--longitude',
          latitudeLabel: 'scale-bar__label--latitude',
          elevationLabel: 'scale-bar__label--elevation',
          bar: 'scale-bar__bar',
          distance: 'scale-bar__distance'
        },

        /**
         * Allowed values for the displayed distance measurement in the scale bar. The
         * length (in pixels) of the scale bar will be adjusted so that it is proportional
         * to one of the listed numbers in meters.
         * @type {number[]}
         */
        distances: [
          0.1,
          0.5,
          1,
          2,
          3,
          5,
          10,
          20,
          30,
          50,
          100,
          200,
          300,
          500,
          1000,
          2000,
          3000,
          5000,
          10000,
          20000,
          30000,
          50000,
          100000,
          200000,
          300000,
          500000,
          1000000,
          2000000,
          3000000,
          5000000,
          10000000,
          20000000,
          30000000,
          50000000
        ],

        /**
         * The maximum width of the scale bar element, in pixels
         * @type {number}
         */
        maxBarWidth: 100,

        /**
        * The events this view will listen to and the associated function to call.
        * @type {Object}
        */
        events: {
          // 'event selector': 'function',
        },

        /**
        * Executed when a new ScaleBarView is created
        * @param {Object} [options] - A literal object with options to pass to the view
        */
        initialize: function (options) {

          try {
            // Get all the options and apply them to this view
            if (typeof options == 'object') {
              for (const [key, value] of Object.entries(options)) {
                this[key] = value;
              }
            }
          } catch (e) {
            console.log('A ScaleBarView failed to initialize.', e);
          }

        },

        /**
        * Renders this view
        * @return {ScaleBarView} Returns the rendered view element
        */
        render: function () {

          try {

            // Save a reference to this view
            var view = this;

            // Insert the template into the view
            this.$el.html(this.template());

            // Ensure the view's main element has the given class name
            this.el.classList.add(this.className);

            // Select the elements that will be updatable
            this.subElements = {};
            for (const [element, className] of Object.entries(view.classes)) {
              view.subElements[element] = document.querySelector('.' + className)
            }

            // Start with empty values
            this.updateCoordinates()
            this.updateScale()

            // Listen for changes to the models
            this.listenToScaleModel()
            this.listenToPointModel()

            return this

          }
          catch (error) {
            console.log(
              'There was an error rendering a ScaleBarView' +
              '. Error details: ' + error
            );
          }
        },

        /**
         * Update the scale bar when the pixel:meters ratio changes
         * @since 2.27.0
         */
        listenToScaleModel: function () {
          const view = this;
          this.listenTo(this.scaleModel, 'change', function () {
            view.updateScale(
              view.scaleModel.get('pixels'),
              view.scaleModel.get('meters')
            );
          });
        },

        /**
         * Stop listening to the scale model
         * @since 2.27.0
         */
        stopListeningToScaleModel: function () {
          this.stopListening(this.scaleModel, 'change');
        },

        /**
         * Update the scale bar view when the lat and long change
         * @since 2.27.0
         */
        listenToPointModel: function () {
          const view = this;
          this.listenTo(this.pointModel, 'change:latitude change:longitude', function () {
            view.updateCoordinates(
              view.pointModel.get('latitude'),
              view.pointModel.get('longitude')
            );
          });
        },

        /**
         * Stop listening to the point model
         */
        stopListeningToPointModel: function () {
          this.stopListening(this.pointModel, 'change:latitude change:longitude');
        },

        /**
         * Updates the displayed coordinates on the scale bar view. Numbers are rounded so
         * that long and lat have 5 digits after the decimal point.
         * @param {number} latitude The north-south position of the point to show
         * coordinates for
         * @param {number} longitude The east-west position of the point to show
         * coordinates for
         * @param {number} elevation The distance from sea-level of the point to show
         * coordinates for
         */
        updateCoordinates: function (latitude, longitude, elevation) {
          try {

            if ((latitude || latitude === 0) && (longitude || longitude === 0)) {
              // Update the displayed coordinates
              this.subElements.latitude.textContent = Number.parseFloat(latitude).toFixed(5);
              this.subElements.longitude.textContent = Number.parseFloat(longitude).toFixed(5);
              this.subElements.latitudeLabel.style.display = null;
              this.subElements.longitudeLabel.style.display = null;
            } else {
              // Update the displayed coordinates
              this.subElements.latitude.textContent = '';
              this.subElements.longitude.textContent = '';
              this.subElements.latitudeLabel.style.display = 'none';
              this.subElements.longitudeLabel.style.display = 'none';
            }

            if ((elevation || elevation === 0)) {
              // TODO: round/prettify elevation number
              this.subElements.elevation.textContent = elevation + 'm';
              this.subElements.elevationLabel.style.display = 'none';
            } else {
              this.subElements.elevation.textContent = '';
              this.subElements.elevationLabel.style.display = 'none';
            }

          }
          catch (error) {
            console.log(
              'There was an error updating the coordinates in a ScaleBarView' +
              '. Error details: ' + error
            );
          }
        },

        /**
         * Change the width of the scale bar and the displayed measurement value based on
         * a new pixel:meters ratio. This function ensures that the resulting values are
         * 'pretty' - the pixel and meter measurements passed to this function do not need
         * to be within any range or rounded, though both values must be > 0.
         * @param {number} pixels A length in pixels
         * @param {number} meters A distance, in meters, that is equivalent to the given
         * distance in pixels
         */
        updateScale: function (pixels, meters) {
          try {

            // Hide the scale bar if a measurement is not available
            let label = null
            let barWidth = 0

            if (pixels && meters && pixels > 0 && meters > 0) {
              const prettyValues = this.prettifyScaleValues(pixels, meters)
              label = prettyValues.label
              barWidth = prettyValues.pixels
            }

            if (barWidth === undefined || barWidth === null || !label) {
              barWidth = 0
            }

            this.subElements.distance.textContent = label;
            this.subElements.bar.style.width = barWidth + 'px';

          }
          catch (error) {
            console.log(
              'Failed to update the ScaleBarView. Error details: ' + error
            );
          }
        },

        /**
         * Takes a pixel:meters ratio and returns values ready to use in the scale bar.
         * @param {number} pixels A length in pixels. Must be > 0.
         * @param {number} meters A distance, in meters, that is equivalent to the given
         * distance in pixels. Must be > 0.
         * @returns {Object} Returns the prettified values.  Returns null for both values
         * if a matching distance was not found (see {@link ScaleBarView#distances})
         * @property {number|null} pixels The updated pixel value that is less than the
         * maxBarWidth and equivalent to the distance given by the label.
         * @property {string|null} label A string that gives a rounded distance
         * measurement along with a unit, either meters or kilometers (when > 1000m).
         */
        prettifyScaleValues: function (pixels, meters) {
          try {

            const view = this
            let prettyValues = {
              pixels: null,
              label: null
            }

            if (pixels && meters && pixels > 0 && meters > 0) {

              const onePixelInMeters = meters / pixels

              // Find the first distance that makes the scale bar less than the maxBarWidth
              let distance;
              for (
                let i = view.distances.length - 1;
                !(distance !== undefined && distance !== null) && i >= 0;
                --i
              ) {
                if (view.distances[i] / onePixelInMeters < view.maxBarWidth) {
                  distance = view.distances[i];
                }
              }

              if ((distance !== undefined && distance !== null)) {

                let label;
                if (distance >= 1000) {
                  label = (distance / 1000).toString() + ' km';
                } else if (distance > 1) {
                  label = distance.toString() + ' m';
                } else {
                  label = (distance * 100).toString() + ' cm';
                }

                prettyValues = {
                  pixels: (distance / onePixelInMeters),
                  label: label
                }
              }
            }

            return prettyValues

          }
          catch (error) {
            console.log(
              'There was an error prettifying scale values in a ScaleBarView' +
              '. Error details: ' + error
            );
            return {
              pixels: null,
              label: null
            }
          }
        },

        /**
         * Function to execute when this view is removed from the DOM
         * @since 2.27.0
         */
        onClose: function () {
          this.stopListeningToScaleModel()
          this.stopListeningToPointModel()
        }

      }
    );

    return ScaleBarView;

  }
);