ui/widget.js

var inherit = require('../inherit');
var sceneObject = require('../sceneObject');

/**
 * @typedef {object} geo.gui.widget.position
 * @property {string|number} [top] The position to the top of the container.
 *   A string css position or a number in pixels.
 * @property {string|number} [right] The position to the right of the
 *   container.  A string css position or a number in pixels.
 * @property {string|number} [bottom] The position to the bottom of the
 *   container.  A string css position or a number in pixels.
 * @property {string|number} [left] The position to the left of the container.
 * @property {string|number} [top] The position to the top of the container.
 * @property {*} [...] Additional css properties that affect position are
 *   allowed.  See the css specification for details.
 */

/**
 * @typedef {object} geo.gui.widget.spec
 *
 * @property {geo.layer} [layer] Layer associated with the widget.
 * @property {geo.gui.widget.position} [position] Location of the widget.
 * @property {geo.gui.widget} [parent] Optional parent widget.
 */

/**
 * Create a new instance of class widget.
 *
 * @class
 * @alias geo.gui.widget
 * @extends {geo.sceneObject}
 * @param {geo.gui.widget.spec} [arg] Options for the widget.
 * @returns {geo.gui.widget}
 */
var widget = function (arg) {
  'use strict';
  if (!(this instanceof widget)) {
    return new widget(arg);
  }
  arg = arg || {};
  sceneObject.call(this, arg);

  var geo_event = require('../event');

  var m_this = this,
      s_exit = this._exit,
      m_layer = arg.layer,
      m_canvas = null,
      m_position = arg.position === undefined ? { left: 0, top: 0 } : arg.position;

  if (arg.parent !== undefined && !(arg.parent instanceof widget)) {
    throw new Error('Parent must be of type geo.gui.widget');
  } else if (arg.parent) {
    m_this.parent(arg.parent);
  }

  /**
   * Initialize the widget.
   *
   * @returns {this}
   */
  this._init = function () {
    m_this.modified();
    return m_this;
  };

  /**
   * Clean up the widget.
   */
  this._exit = function () {
    m_this.children().forEach(function (child) {
      m_this.removeChild(child);
      child._exit();
    });

    m_this.layer().geoOff(geo_event.pan, m_this.repositionEvent);
    if (m_this.parentCanvas().removeChild && m_this.canvas()) {
      try {
        m_this.parentCanvas().removeChild(m_this.canvas());
      } catch (err) {
        // fail gracefully if the canvas is not a child of the parentCanvas
      }
    }
    s_exit();
  };

  /**
   * Return the layer associated with this widget.
   *
   * @returns {geo.layer}
   */
  this.layer = function () {
    return m_layer || (m_this.parent() && m_this.parent().layer());
  };

  /**
   * Create the canvas this widget will operate on.
   */
  this._createCanvas = function () {
    throw new Error('Must be defined in derived classes');
  };

  /**
   * Get/Set the canvas for the widget.
   *
   * @param {HTMLElement} [val] If specified, set the canvas, otherwise get
   *    the canvas.
   * @returns {HTMLElement|this} If getting the canvas, return the current
   *    value; otherwise, return this widget.
   */
  this.canvas = function (val) {
    if (val === undefined) {
      return m_canvas;
    }
    m_canvas = val;
    return m_this;
  };

  /**
   * Appends the canvas to the parent canvas.
   * The widget determines how to append itself to a parent, the parent can
   * either be another widget, or the UI Layer.
   */
  this._appendCanvasToParent = function () {
    m_this.parentCanvas().appendChild(m_this.canvas());
  };

  /**
   * Get the parent canvas (top level widgets define their layer as their
   * parent canvas).
   *
   * @returns {HTMLElement} The canvas of the widget's parent.
   */
  this.parentCanvas = function () {
    if (!m_this.parent()) {
      return m_this.layer().canvas();
    }
    return m_this.parent().canvas();
  };

  /**
   * Get or set the CSS positioning that a widget should be placed at.
   *
   * @param {geo.gui.widget.position} [pos] If unspecified, return the current
   *    position.  Otherwise, set the current position.
   * @param {boolean} [actualValue] If getting the position, if this is truthy,
   *    always return the stored value, not a value adjusted for display.
   * @returns {geo.gui.widget.position|this} Either the position or the widget
   *    instance.  If this is the position and `actualValue` is falsy,
   *    positions that specify an explicit `x` and `y` parameter will be
   *    converted to a value that can be used by the display css.
   */
  this.position = function (pos, actualValue) {
    if (pos !== undefined) {
      m_this.layer().geoOff(geo_event.pan, m_this.repositionEvent);
      var clearPosition = {};
      for (var attr in m_position) {
        if (m_position.hasOwnProperty(attr)) {
          clearPosition[attr] = null;
        }
      }
      m_position = pos;
      if (m_position.hasOwnProperty('x') && m_position.hasOwnProperty('y')) {
        m_this.layer().geoOn(geo_event.pan, m_this.repositionEvent);
      }
      m_this.reposition(Object.assign(clearPosition, m_this.position()));
      return m_this;
    }
    if (m_position.hasOwnProperty('x') && m_position.hasOwnProperty('y') && !actualValue) {
      var position = m_this.layer().map().gcsToDisplay(m_position);

      return {
        left: position.x,
        top: position.y,
        right: null,
        bottom: null
      };
    }

    return m_position;
  };

  /**
   * Repositions a widget.
   *
   * @param {geo.gui.widget.position} [position] The new position for the
   *    widget.  `undefined` uses the stored position value.
   * @returns {this}
   */
  this.reposition = function (position) {
    position = position || m_this.position();
    if (m_this.canvas() && m_this.canvas().style) {
      m_this.canvas().style.position = 'absolute';

      for (var cssAttr in position) {
        if (position.hasOwnProperty(cssAttr)) {
          // if the property is a number, add px to it, otherwise set it to the
          // specified value.  Setting a property to null clears it.  Setting to
          // undefined doesn't alter it.
          if (/^\s*(-|\+)?(\d+(\.\d*)?|\d*\.\d+)([eE](-|\+)?\d+)?\s*$/.test(position[cssAttr])) {
            // this ensures that the number is a float with no more than 3
            // decimal places (Chrome does this automatically, but doing so
            // explicitly makes testing more consistent).  It will be an
            // integer when possible.
            m_this.canvas().style[cssAttr] = parseFloat(parseFloat(position[cssAttr]).toFixed(3)) + 'px';
          } else {
            m_this.canvas().style[cssAttr] = position[cssAttr];
          }
        }
      }
    }
    return m_this;
  };

  /**
   * If the position is based on map coordinates, this gets called when the
   * map is panned to resposition the widget.
   *
   * @returns {this}
   */
  this.repositionEvent = function () {
    return m_this.reposition();
  };

  /**
   * Report if the top left of widget (or its current x, y position) is within
   * the viewport.
   *
   * @returns {boolean} True if the widget is within the viewport.
   */
  this.isInViewport = function () {
    var position = m_this.position();
    var layer = m_this.layer();

    return ((position.left >= 0 && position.top >= 0) &&
            (position.left <= layer.width() && position.top <= layer.height()));
  };

  if (m_position.hasOwnProperty('x') && m_position.hasOwnProperty('y')) {
    this.layer().geoOn(geo_event.pan, m_this.repositionEvent);
  }
};
inherit(widget, sceneObject);
module.exports = widget;