feature.js

var $ = require('jquery');
var inherit = require('./inherit');
var sceneObject = require('./sceneObject');
var timestamp = require('./timestamp');
var transform = require('./transform');
var geo_event = require('./event');

/**
 * General specification for features.
 *
 * @typedef {object} geo.feature.spec
 * @property {geo.layer} [layer] the parent layer associated with the feature.
 * @property {boolean|'auto'} [selectionAPI='auto'] If `'auto'`, enable
 *   selection events if any {@link geo.event.feature} events are bound to the
 *   feature.  Otherwise, if truthy, enable selection events on the feature.
 *   Selection events are those in {@link geo.event.feature}.  They can be
 *   bound via a call like
 *   ```
 *   feature.geoOn(geo.event.feature.mousemove, function (evt) {
 *     // do something with the feature
 *   });
 *   ```
 *   where the handler is passed a {@link geo.event.feature} object.
 * @property {boolean} [visible=true] If truthy, show the feature.  If falsy,
 *   hide the feature and do not allow interaction with it.
 * @property {string} [gcs] The interface gcs for this feature.  If `undefined`
 *   or `null`, this uses the layer's interface gcs.  This is a string used by
 *   {@link geo.transform}.
 * @property {number} [bin=null] The bin number is used to determine the order
 *   of multiple features on the same layer.  It has no effect except on the
 *   webgl renderer.  A negative value hides the feature without stopping
 *   interaction with it.  Otherwise, more features with higher bin numbers are
 *   drawn above those with lower bin numbers.  If two features have the same
 *   bin number, their order relative to one another is indeterminate and may
 *   be unstable.  A value of `null` will use the current position of the
 *   feature within its parent's list of children as the bin number.
 * @property {geo.renderer} [renderer] A reference to the renderer used for
 *   the feature.  If `null` or unset or identical to `layer.renderer()`, the
 *   layer's renderer is used.
 * @property {geo.feature.styleSpec} [style] An object that contains style
 *   values for the feature.
 */

/**
 * Style specification for a feature.
 *
 * @typedef {object} geo.feature.styleSpec
 */

/**
 * @typedef {geo.feature.spec} geo.feature.createSpec
 * @extends geo.feature.spec
 * @property {string} type A supported feature type.
 * @property {object[]} [data=[]] An array of arbitrary objects used to
 *   construct the feature.  These objects (and their associated indices in the
 *   array) will be passed back to style and attribute accessors provided by
 *   the user.
 */

/**
 * @typedef {geo.event} geo.feature.event
 * @property {number} index The index of the feature within the data array.
 * @property {object} data The data element associated with the indexed
 *   feature.
 * @property {geo.mouseState} mouse The mouse information during the event.
 * @property {object} [extra] Additional information about the feature.  This
 *   is sometimes used to identify a subsection of the feature.
 * @property {number} [eventID] A monotonically increasing number identifying
 *   this feature event loop.  This is provided on
 *   {@link geo.event.feature.mousemove}, {@link geo.event.feature.mouseclick},
 *   {@link geo.event.feature.mouseover}, {@link geo.event.feature.mouseout},
 *   {@link geo.event.feature.brush}, and {@link geo.event.feature.brushend}
 *   events, since each of those can trigger multiple events for one mouse
 *   action (all events triggered by the same mouse action will have the same
 *   `eventID`).
 * @property {boolean} [top] `true` if this is the top-most feature that the
 *   mouse is over.  Only the top-most feature gets
 *   {@link geo.event.feature.mouseon} events, whereas multiple features can
 *   get other events.
 */

/**
 * @typedef {object} geo.feature.searchResult
 * @property {object[]} found A list of elements from the data array that were
 *   found by the search.
 * @property {number[]} index A list of the indices of the elements that were
 *   found by the search.
 * @property {object[]} [extra] A list of additional information per found
 *   element.  The information is passed to events without change.
 */

/**
 * Create a new instance of class feature.
 *
 * @class
 * @alias geo.feature
 * @extends geo.sceneObject
 * @param {geo.feature.spec} [arg] A feature specification.
 * @returns {geo.feature}
 */
var feature = function (arg) {
  'use strict';
  if (!(this instanceof feature)) {
    return new feature(arg);
  }
  sceneObject.call(this);

  var util = require('./util');

  /**
   * @private
   */
  arg = arg || {};

  var m_this = this,
      s_exit = this._exit,
      s_geoOn = this.geoOn,
      s_geoOff = this.geoOff,
      m_ready,
      m_selectionAPI = arg.selectionAPI === undefined ? 'auto' : arg.selectionAPI,
      m_style = {},
      m_layer = arg.layer === undefined ? null : arg.layer,
      m_gcs = arg.gcs,
      m_visible = arg.visible === undefined ? true : arg.visible,
      m_bin = arg.bin === undefined ? null : arg.bin,
      m_renderer = arg.renderer === undefined || (m_layer && arg.renderer === m_layer.renderer()) ? null : arg.renderer,
      m_dataTime = timestamp(),
      m_buildTime = timestamp(),
      m_updateTime = timestamp(),
      m_dependentFeatures = [],
      m_selectedFeatures = [];

  // subclasses can add keys to this for styles that apply to subcomponents of
  // data items, such as individual vertices on lines or polygons.
  this._subfeatureStyles = {};

  /**
   * @property {boolean} ready `true` if this feature has been initialized,
   *    `false` if it was destroyed, `undefined` if it was created but not
   *    initialized.
   * @name geo.feature#ready
   */
  Object.defineProperty(this, 'ready', {
    get: function () {
      return m_ready;
    }
  });

  /**
   * Private method to bind mouse handlers on the map element.  This does
   * nothing if the selectionAPI is turned off.  Otherwise, it first unbinds
   * any existing handlers and then binds handlers.
   */
  this._bindMouseHandlers = function () {
    // Don't bind handlers for improved performance on features that don't
    // require it.
    if (!m_this.selectionAPI()) {
      return;
    }

    // First unbind to be sure that the handlers aren't bound twice.
    m_this._unbindMouseHandlers();

    m_this.geoOn(geo_event.mousemove, m_this._handleMousemove);
    m_this.geoOn(geo_event.mousedown, m_this._handleMousedown);
    m_this.geoOn(geo_event.mouseup, m_this._handleMouseup);
    m_this.geoOn(geo_event.mouseclick, m_this._handleMouseclick);
    m_this.geoOn(geo_event.brushend, m_this._handleBrushend);
    m_this.geoOn(geo_event.brush, m_this._handleBrush);
  };

  /**
   * Private method to unbind mouse handlers on the map element.
   */
  this._unbindMouseHandlers = function () {
    m_this.geoOff(geo_event.mousemove, m_this._handleMousemove);
    m_this.geoOff(geo_event.mousedown, m_this._handleMousedown);
    m_this.geoOff(geo_event.mouseup, m_this._handleMouseup);
    m_this.geoOff(geo_event.mouseclick, m_this._handleMouseclick);
    m_this.geoOff(geo_event.brushend, m_this._handleBrushend);
    m_this.geoOff(geo_event.brush, m_this._handleBrush);
  };

  /**
   * Search for features containing the given point.  This should be defined in
   * relevant subclasses.
   *
   * @param {geo.geoPosition} geo Coordinate.
   * @param {string|geo.transform|null} [gcs] Input gcs.  `undefined` to use
   *    the interface gcs, `null` to use the map gcs, or any other transform.
   * @returns {geo.feature.searchResult} An object with a list of features and
   *    feature indices that are located at the specified point.
   */
  this.pointSearch = function (geo, gcs) {
    // base class method does nothing
    return {
      index: [],
      found: []
    };
  };

  /**
   * Search for features contained within a rectangular region.
   *
   * @param {geo.geoPosition} lowerLeft Lower-left corner.
   * @param {geo.geoPosition} upperRight Upper-right corner.
   * @param {object} [opts] Additional search options.
   * @param {boolean} [opts.partial=false] If truthy, include features that are
   *    partially in the box, otherwise only include features that are fully
   *    within the region.
   * @param {string|geo.transform|null} [gcs] Input gcs.  `undefined` to use
   *    the interface gcs, `null` to use the map gcs, or any other transform.
   * @returns {geo.feature.searchResult} An object with a list of features and
   *    feature indices that are located at the specified point.
   */
  this.boxSearch = function (lowerLeft, upperRight, opts, gcs) {
    return m_this.polygonSearch([
      lowerLeft, {x: lowerLeft.x, y: upperRight.y},
      upperRight, {x: upperRight.x, y: lowerLeft.y}], opts, gcs);
  };

  /**
   * Search for features contained within a polygon.  This should be defined in
   * relevant subclasses.
   *
   * @param {geo.polygonObject} poly A polygon as an array of coordinates or an
   *    object with `outer` and optionally `inner` parameters.
   * @param {object} [opts] Additional search options.
   * @param {boolean} [opts.partial=false] If truthy, include features that are
   *    partially in the polygon, otherwise only include features that are
   *    fully within the region.
   * @param {string|geo.transform|null} [gcs] Input gcs.  `undefined` to use
   *    the interface gcs, `null` to use the map gcs, or any other transform.
   * @returns {geo.feature.searchResult} An object with a list of features and
   *    feature indices that are located at the specified point.
   */
  this.polygonSearch = function (poly, opts, gcs) {
    // base class method does nothing
    return {
      index: [],
      found: []
    };
  };

  /**
   * Private mousedown handler.  This uses `pointSearch` to determine which
   * features the mouse is over, then fires appropriate events.
   *
   * @param {geo.event} evt The event that triggered this handler.
   * @fires geo.event.feature.mousedown
   */
  this._handleMousedown = function (evt) {
    this._handleMousemove(evt, geo_event.feature.mousedown);
  };

  /**
   * Private mouseup handler.  This uses `pointSearch` to determine which
   * features the mouse is over, then fires appropriate events.
   *
   * @param {geo.event} evt The event that triggered this handler.
   * @fires geo.event.feature.mouseup
   */
  this._handleMouseup = function (evt) {
    this._handleMousemove(evt, geo_event.feature.mouseup);
  };

  /**
   * Private mousemove handler.  This uses `pointSearch` to determine which
   * features the mouse is over, then fires appropriate events.
   *
   * @param {geo.event} evt The event that triggered this handler.
   * @param {string} [updown] If "mouseup" or "mousedown", fire that event
   *    instead of mouseon.
   * @fires geo.event.feature.mouseover_order
   * @fires geo.event.feature.mouseover
   * @fires geo.event.feature.mouseout
   * @fires geo.event.feature.mousemove
   * @fires geo.event.feature.mouseoff
   * @fires geo.event.feature.mouseon
   * @fires geo.event.feature.mouseup
   * @fires geo.event.feature.mousedown
   */
  this._handleMousemove = function (evt, updown) {
    var mouse = evt && evt.mouse ? evt.mouse : m_this.layer().map().interactor().mouse(),
        data = m_this.data(),
        over = m_this.pointSearch(mouse.geo),
        newFeatures = [], oldFeatures = [], lastTop = -1, top = -1, extra;

    // exit if we have no old or new found entries
    if (!m_selectedFeatures.length && !over.index.length) {
      return;
    }

    extra = over.extra || {};

    // if we are over more than one item, trigger an event that is allowed to
    // reorder the values in evt.over.index.  Event handlers don't have to
    // maintain evt.over.found.  Handlers should not modify evt.over.extra or
    // evt.previous.
    if (over.index.length > 1) {
      m_this.geoTrigger(geo_event.feature.mouseover_order, {
        feature: m_this,
        mouse: mouse,
        previous: m_selectedFeatures,
        over: over,
        sourceEvent: evt
      });
    }

    feature.eventID += 1;

    if (updown) {
      over.index.forEach((i, idx) => {
        m_this.geoTrigger(updown, {
          data: data[i],
          index: i,
          extra: extra[i],
          mouse: mouse,
          eventID: feature.eventID,
          top: idx === over.length - 1,
          sourceEvent: evt
        }, true);
      });
      return;
    }

    // Get the index of the element that was previously on top
    if (m_selectedFeatures.length) {
      lastTop = m_selectedFeatures[m_selectedFeatures.length - 1];
    }

    // There are probably faster ways of doing this:
    newFeatures = over.index.filter(function (i) {
      return m_selectedFeatures.indexOf(i) < 0;
    });
    oldFeatures = m_selectedFeatures.filter(function (i) {
      return over.index.indexOf(i) < 0;
    });

    // Fire events for mouse in first.
    newFeatures.forEach(function (i, idx) {
      m_this.geoTrigger(geo_event.feature.mouseover, {
        data: data[i],
        index: i,
        extra: extra[i],
        mouse: mouse,
        eventID: feature.eventID,
        top: idx === newFeatures.length - 1,
        sourceEvent: evt
      }, true);
    });

    feature.eventID += 1;
    // Fire events for mouse out next
    oldFeatures.forEach(function (i, idx) {
      m_this.geoTrigger(geo_event.feature.mouseout, {
        data: data[i],
        index: i,
        mouse: mouse,
        eventID: feature.eventID,
        top: idx === oldFeatures.length - 1,
        sourceEvent: evt
      }, true);
    });

    feature.eventID += 1;
    // Fire events for mouse move last
    over.index.forEach(function (i, idx) {
      m_this.geoTrigger(geo_event.feature.mousemove, {
        data: data[i],
        index: i,
        extra: extra[i],
        mouse: mouse,
        eventID: feature.eventID,
        top: idx === over.index.length - 1,
        sourceEvent: evt
      }, true);
    });

    // Replace the selected features array
    m_selectedFeatures = over.index;

    // Get the index of the element that is now on top
    if (m_selectedFeatures.length) {
      top = m_selectedFeatures[m_selectedFeatures.length - 1];
    }

    if (lastTop !== top) {
      // The element on top changed so we need to fire mouseon/mouseoff
      if (lastTop !== -1) {
        m_this.geoTrigger(geo_event.feature.mouseoff, {
          data: data[lastTop],
          index: lastTop,
          mouse: mouse,
          sourceEvent: evt
        }, true);
      }

      if (top !== -1) {
        m_this.geoTrigger(geo_event.feature.mouseon, {
          data: data[top],
          index: top,
          extra: extra[top],
          mouse: mouse,
          sourceEvent: evt
        }, true);
      }
    }
  };

  /**
   * Clear our tracked selected features.
   *
   * @returns {this}
   */
  this._clearSelectedFeatures = function () {
    m_selectedFeatures = [];
    return m_this;
  };

  /**
   * Private mouseclick handler.  This uses `pointSearch` to determine which
   * features the mouse is over, then fires a click event for each such
   * feature.
   *
   * @param {geo.event} evt The event that triggered this handler.
   * @fires geo.event.feature.mouseclick_order
   * @fires geo.event.feature.mouseclick
   */
  this._handleMouseclick = function (evt) {
    var mouse = m_this.layer().map().interactor().mouse(),
        data = m_this.data(),
        over = m_this.pointSearch(mouse.geo),
        extra = over.extra || {};

    // if we are over more than one item, trigger an event that is allowed to
    // reorder the values in evt.over.index.  Event handlers don't have to
    // maintain evt.over.found.  Handlers should not modify evt.over.extra.
    if (over.index.length > 1) {
      m_this.geoTrigger(geo_event.feature.mouseclick_order, {
        feature: m_this,
        mouse: mouse,
        over: over,
        sourceEvent: evt
      });
    }
    mouse.buttonsDown = evt.buttonsDown;
    feature.eventID += 1;
    over.index.forEach(function (i, idx) {
      m_this.geoTrigger(geo_event.feature.mouseclick, {
        data: data[i],
        index: i,
        extra: extra[i],
        mouse: mouse,
        eventID: feature.eventID,
        top: idx === over.index.length - 1,
        sourceEvent: evt
      }, true);
    });
  };

  /**
   * Private brush handler.  This uses `polygonSearch` to determine which
   * features the brush includes, then fires appropriate events.
   *
   * @param {geo.brushSelection} brush The current brush selection.
   * @fires geo.event.feature.brush
   */
  this._handleBrush = function (brush) {
    let corners = [brush.gcs.lowerLeft, brush.gcs.lowerRight, brush.gcs.upperRight, brush.gcs.upperLeft];
    if (m_this.layer()) {
      const map = m_this.layer().map();
      corners = transform.transformCoordinates(map.gcs(), map.ingcs(), corners);
    }
    const search = m_this.polygonSearch(corners);

    feature.eventID += 1;
    search.index.forEach(function (idx, i) {
      m_this.geoTrigger(geo_event.feature.brush, {
        data: search.found[i],
        index: idx,
        mouse: brush.mouse,
        brush: brush,
        eventID: feature.eventID,
        top: i === search.index.length - 1
      }, true);
    });
  };

  /**
   * Private brushend handler.  This uses `polygonSearch` to determine which
   * features the brush includes, then fires appropriate events.
   *
   * @param {geo.brushSelection} brush The current brush selection.
   * @fires geo.event.feature.brushend
   */
  this._handleBrushend = function (brush) {
    let corners = [brush.gcs.lowerLeft, brush.gcs.lowerRight, brush.gcs.upperRight, brush.gcs.upperLeft];
    if (m_this.layer()) {
      const map = m_this.layer().map();
      corners = transform.transformCoordinates(map.gcs(), map.ingcs(), corners);
    }
    const search = m_this.polygonSearch(corners);

    feature.eventID += 1;
    search.index.forEach(function (idx, i) {
      m_this.geoTrigger(geo_event.feature.brushend, {
        data: search.found[i],
        index: idx,
        mouse: brush.mouse,
        brush: brush,
        eventID: feature.eventID,
        top: i === search.index.length - 1
      }, true);
    });
  };

  /**
   * Get/Set style used by the feature.  Styles can be constant values or
   * functions.  If a function, the style is typically called with parameters
   * such as `(dataElement, dataIndex)` or, if the specific style of a feature
   * has a subfeature style, with `(subfeatureElement, subfeatureIndex,
   * dataElement, dataIndex)`.
   *
   * See the <a href="#.styleSpec">style specification
   * <code>styleSpec</code></a> for available styles.
   *
   * @param {string|object} [arg1] If `undefined`, return the current style
   *    object.  If a string and `arg2` is undefined, return the style
   *    associated with the specified key.  If a string and `arg2` is defined,
   *    set the named style to the specified value.  Otherwise, extend the
   *    current style with the values in the specified object.
   * @param {*} [arg2] If `arg1` is a string, the new value for that style.
   * @returns {object|this} Either the entire style object, the value of a
   *    specific style, or the current class instance.
   */
  this.style = function (arg1, arg2) {
    if (arg1 === undefined) {
      return m_style;
    } else if (typeof arg1 === 'string' && arg2 === undefined) {
      return m_style[arg1];
    } else if (arg2 === undefined) {
      m_style = Object.assign({}, m_style, arg1);
      m_this.modified();
      return m_this;
    } else {
      m_style[arg1] = arg2;
      m_this.modified();
      return m_this;
    }
  };

  /**
   * A uniform getter that always returns a function even for constant styles.
   * This can also return all defined styles as functions in a single object.
   *
   * If the style `key` is a color, the returned function will also coerce
   * the result to be a {@link geo.geoColorObject}.
   *
   * @function style_DOT_get
   * @memberof geo.feature
   * @instance
   * @param {string} [key] If defined, return a function for the named style.
   *    Otherwise, return an object with a function for all defined styles.
   * @returns {function|object} Either a function for the named style or an
   *    object with functions for all defined styles.
   */
  this.style.get = function (key) {
    var out;
    if (key === undefined) {
      var all = {}, k;
      for (k in m_style) {
        if (m_style.hasOwnProperty(k)) {
          all[k] = m_this.style.get(k);
        }
      }
      return all;
    }
    if (key.toLowerCase().match(/color$/)) {
      if (util.isFunction(m_style[key])) {
        out = function () {
          return util.convertColor(
            m_style[key].apply(m_this, arguments)
          );
        };
      } else {
        // if the color is not a function, only convert it once
        out = util.ensureFunction(util.convertColor(m_style[key]));
      }
    } else {
      out = util.ensureFunction(m_style[key]);
    }
    return out;
  };

  /**
   * Set style(s) from array(s).  For each style, the array should have one
   * value per data item.  The values are not converted or validated.  Color
   * values should be {@link geo.geoColorObject}s.  If invalid values are given
   * the behavior is undefined.
   *   For some feature styles, if the first entry of an array is itself an
   * array, then each entry of the array is expected to be an array, and values
   * are used from these subarrays.  This allows a style to apply, for
   * instance, per vertex of a data item rather than per data item.
   *
   * @param {string|object} keyOrObject Either the name of a single style or
   *    an object where the keys are the names of styles and the values are
   *    each arrays.
   * @param {array} styleArray If keyOrObject is a string, an array of values
   *    for the style.  If keyOrObject is an object, this parameter is ignored.
   * @param {boolean} [refresh=false] `true` to redraw the feature when it has
   *    been updated.  If an object with styles is passed, the redraw is only
   *    done once.
   * @returns {this} The feature instance.
   */
  this.updateStyleFromArray = function (keyOrObject, styleArray, refresh) {
    if (typeof keyOrObject !== 'string') {
      $.each(keyOrObject, function (key, value) {
        m_this.updateStyleFromArray(key, value);
      });
    } else {
      /* colors are always expected to be objects with r, g, b values, so for
       * any color, make sure we don't have undefined entries. */
      var fallback;
      if (keyOrObject.toLowerCase().match(/color$/)) {
        fallback = {r: 0, g: 0, b: 0};
      }
      if (!Array.isArray(styleArray)) {
        return m_this;
      }
      if (m_this._subfeatureStyles[keyOrObject]) {
        if (styleArray.length && Array.isArray(styleArray[0])) {
          m_this.style(keyOrObject, function (v, j, d, i) {
            var val = (styleArray[i] || [])[j];
            return val !== undefined ? val : fallback;
          });
        } else {
          m_this.style(keyOrObject, function (v, j, d, i) {
            var val = styleArray[i];
            return val !== undefined ? val : fallback;
          });
        }
      } else {
        m_this.style(keyOrObject, function (d, i) {
          var val = styleArray[i];
          return val !== undefined ? val : fallback;
        });
      }
    }
    if (refresh && m_this.visible()) {
      m_this.draw();
    }
    return m_this;
  };

  /**
   * Get the layer referenced by the feature.
   *
   * @returns {geo.layer} The layer associated with the feature.
   */
  this.layer = function () {
    return m_layer;
  };

  /**
   * Get the renderer used by the feature.
   *
   * @returns {geo.renderer} The renderer used to render the feature.
   */
  this.renderer = function () {
    return m_renderer || (m_layer && m_layer.renderer());
  };

  /**
   * Get/Set the projection of the feature.
   *
   * @param {string?} [val] If `undefined`, return the current gcs.  If
   *    `null`, use the map's interface gcs.  Otherwise, set a new value for
   *    the gcs.
   * @returns {string|this} A string used by {@link geo.transform}.  If the
   *    map interface gcs is in use, that value will be returned.  If the gcs
   *    is set, return the current class instance.
   */
  this.gcs = function (val) {
    if (val === undefined) {
      if ((m_gcs === undefined || m_gcs === null) && m_layer) {
        return m_layer.map().ingcs();
      }
      return m_gcs;
    } else {
      m_gcs = val;
      m_this.modified();
      return m_this;
    }
  };

  /**
   * Convert from the feature's gcs coordinates to display coordinates.
   *
   * @param {geo.geoPosition} c The input coordinate to convert.
   * @returns {geo.screenPosition} Display space coordinates.
   */
  this.featureGcsToDisplay = function (c) {
    var map = m_layer.map();
    c = map.gcsToWorld(c, m_this.gcs());
    c = map.worldToDisplay(c);
    if (m_this.renderer().baseToLocal) {
      c = m_this.renderer().baseToLocal(c);
    }
    return c;
  };

  /**
   * Get/Set the visibility of the feature.
   *
   * @param {boolean} [val] A boolean to change the visibility, or `undefined`
   *    to return the visibility.
   * @param {boolean} [direct] If `true`, when getting the visibility,
   *    disregard the visibility of the parent layer, and when setting, refresh
   *    the state regardless of whether it has changed or not.  Otherwise, the
   *    functional visibility is returned, where both the feature and the layer
   *    must be visible for a `true` result.
   * @returns {boolean|this} Either the visibility (if getting) or the feature
   *    (if setting).
   */
  this.visible = function (val, direct) {
    if (val === undefined) {
      if (!direct && m_layer && m_layer.visible && !m_layer.visible()) {
        return false;
      }
      return m_visible;
    }
    if (m_visible !== val || direct) {
      m_visible = val;
      m_this.modified();
      if (m_layer && m_layer.visible && !m_layer.visible()) {
        val = false;
      }
      // bind or unbind mouse handlers on visibility change
      if (val) {
        m_this._bindMouseHandlers();
      } else {
        m_this._unbindMouseHandlers();
      }
      for (var i = 0; i < m_dependentFeatures.length; i += 1) {
        m_dependentFeatures[i].visible(m_visible, direct);
      }
    }
    return m_this;
  };

  /**
   * Get/Set a list of dependent features.  Dependent features have their
   * visibility changed at the same time as the feature.
   *
   * @param {geo.feature[]} [arg] If specified, the new list of dependent
   *    features.  Otherwise, return the current list of dependent features.
   * @returns {geo.feature[]|this} The current list of dependent features or
   *    a reference to `this`.
   */
  this.dependentFeatures = function (arg) {
    if (arg === undefined) {
      return m_dependentFeatures.slice();
    }
    m_dependentFeatures = arg.slice();
    return m_this;
  };

  /**
   * Get/Set bin of the feature.  The bin number is used to determine the order
   * of multiple features on the same layer.  It has no effect except on the
   * webgl renderer.  A negative value hides the feature without stopping
   * interaction with it.  Otherwise, features with higher bin numbers are
   * drawn above those with lower bin numbers.  If two features have the same
   * bin number, their order relative to one another is indeterminate and may
   * be unstable.
   *
   * @param {number} [val] The new bin number.  If `undefined`, return the
   *    current bin number.  If `null`, the bin is dynamically computed based
   *    on order within the parent.  If children are nested, this may not be
   *    what is desired.
   * @param {boolean} [actualValue] If truthy and `val` is undefined, return
   *    the actual value of bin, rather than the dynamically computed value.
   * @returns {number|this} The current bin number or a reference to `this`.
   */
  this.bin = function (val, actualValue) {
    if (val === undefined) {
      if (m_bin === null && !actualValue) {
        var parent = m_this.parent(),
            idx = parent ? parent.children().indexOf(m_this) : -1;
        return idx >= 0 ? idx : 0;
      }
      return m_bin;
    } else {
      if (util.isNonNullFinite(val)) {
        m_bin = parseInt(val, 10);
      } else {
        m_bin = null;
      }
      m_this.modified();
      return m_this;
    }
  };

  /**
   * Get/Set timestamp of data change.
   *
   * @param {geo.timestamp} [val] The new data timestamp object or `undefined`
   *    to get the current data timestamp object.
   * @returns {geo.timestamp|this}
   */
  this.dataTime = function (val) {
    if (val === undefined) {
      return m_dataTime;
    } else {
      m_dataTime = val;
      m_this.modified();
      return m_this;
    }
  };

  /**
   * Get/Set timestamp of last time a build happened.
   *
   * @param {geo.timestamp} [val] The new build timestamp object or `undefined`
   *    to get the current build timestamp object.
   * @returns {geo.timestamp|this}
   */
  this.buildTime = function (val) {
    if (val === undefined) {
      return m_buildTime;
    } else {
      m_buildTime = val;
      m_this.modified();
      return m_this;
    }
  };

  /**
   * Get/Set timestamp of last time an update happened.
   *
   * @param {geo.timestamp} [val] The new update timestamp object or
   *    `undefined` to get the current update timestamp object.
   * @returns {geo.timestamp|this}
   */
  this.updateTime = function (val) {
    if (val === undefined) {
      return m_updateTime;
    } else {
      m_updateTime = val;
      m_this.modified();
      return m_this;
    }
  };

  /**
   * Get/Set the data array for the feature.  This is equivalent to getting or
   * setting the `data` style, except that setting the data array via this
   * method updates the data timestamp, whereas setting it via the style does
   * not.
   *
   * @param {array} [data] A new data array or `undefined` to return the
   *    existing array.
   * @returns {array|this}
   */
  this.data = function (data) {
    if (data === undefined) {
      return m_this.style('data') || [];
    } else {
      m_this.style('data', data);
      m_this.dataTime().modified();
      m_this.modified();
      return m_this;
    }
  };

  /**
   * Get/Set if the selection API is enabled for this feature.
   *
   * @param {boolean|string} [arg] `undefined` to return the selectionAPI
   *    state, a boolean to change the state, or `'auto'` to set the state
   *    based on the existence of event handlers.  When getting the state, if
   *    `direct` is not specified, `'auto'` is never returned.
   * @param {boolean} [direct] If `true`, when getting the selectionAPI state,
   *    disregard the state of the parent layer, and when setting, refresh the
   *    state regardless of whether it has changed or not.
   * @returns {boolean|string|this} Either the selectionAPI state or the
   *    feature instance.
   */
  this.selectionAPI = function (arg, direct) {
    if (arg === undefined) {
      if (!direct && m_layer && m_layer.selectionAPI && !m_layer.selectionAPI()) {
        return false;
      }
      if (!direct && m_selectionAPI === 'auto') {
        return !!m_this.geoIsOn(Object.values(geo_event.feature));
      }
      return m_selectionAPI;
    }
    if (arg !== 'auto') {
      arg = !!arg;
    }
    if (arg !== m_selectionAPI || direct) {
      m_selectionAPI = arg;
      m_this._unbindMouseHandlers();
      m_this._bindMouseHandlers();
    }
    return m_this;
  };

  /**
   * If the selectionAPI is on, then setting
   * `this.geoOn(geo.event.feature.mouseover_order, this.mouseOverOrderHighestIndex)`
   * will make it so that the mouseon events prefer the highest index feature.
   *
   * @param {geo.event} evt The event; this should be triggered from
   *    {@link geo.event.feature.mouseover_order}.
   */
  this.mouseOverOrderHighestIndex = function (evt) {
    // sort the found indices.  The last one is the one "on top".
    evt.over.index.sort();
    // this isn't necessary, but ensures that other event handlers have
    // consistent information
    var data = evt.feature.data();
    evt.over.index.forEach(function (di, idx) {
      evt.over.found[idx] = data[di];
    });
  };

  /**
   * Initialize the class instance.  Derived classes should implement this.
   *
   * @param {geo.feature.spec} arg The feature specification.
   */
  this._init = function (arg) {
    if (!m_layer) {
      throw new Error('Feature requires a valid layer');
    }
    m_style = Object.assign(
      {},
      {opacity: 1.0},
      arg.style === undefined ? {} : arg.style);
    m_this._bindMouseHandlers();
    m_ready = true;
  };

  /**
   * Build.
   *
   * Derived classes should implement this.
   */
  this._build = function () {
  };

  /**
   * Update.
   *
   * Derived classes should implement this.
   */
  this._update = function () {
  };

  /**
   * Bind an event handler to this object.
   *
   * @param {string} event An event from {@link geo.event} or a user-defined
   *   value.
   * @param {function} handler A function that is called when `event` is
   *   triggered.  The function is passed a {@link geo.event} object.
   * @returns {this}
   */
  this.geoOn = function (event, handler) {
    var isAuto = m_this.selectionAPI(undefined, true) === 'auto',
        selection = isAuto && m_this.selectionAPI();
    var result = s_geoOn.apply(m_this, arguments);
    if (isAuto && !selection && m_this.selectionAPI()) {
      m_this._bindMouseHandlers();
    }
    return result;
  };

  /**
   * Remove handlers from one event or an array of events.  If no event is
   * provided all handlers will be removed.
   *
   * @param {string|string[]} [event] An event or a list of events from
   *   {@link geo.event} or defined by the user, or `undefined` to remove all
   *   events (in which case `arg` is ignored).
   * @param {(function|function[])?} [arg] A function or array of functions to
   *   remove from the events or a falsy value to remove all handlers from the
   *   events.
   * @returns {this}
   */
  this.geoOff = function (event, arg) {
    var isAuto = m_this.selectionAPI(undefined, true) === 'auto',
        selection = isAuto && m_this.selectionAPI();
    var result = s_geoOff.apply(m_this, arguments);
    if (isAuto && selection && !m_this.selectionAPI()) {
      m_this._unbindMouseHandlers();
    }
    return result;
  };

  /**
   * Destroy.  Unbind mouse handlers, clear internal variables, and call the
   * parent destroy method.
   *
   * Derived classes should implement this.
   */
  this._exit = function () {
    m_this._unbindMouseHandlers();
    m_selectedFeatures = [];
    m_style = {};
    s_exit();
    m_ready = false;
  };

  this._init(arg);
  return this;
};

/**
 * The most recent {@link geo.feature.event} triggered.
 * @type {number}
 */
feature.eventID = 0;

/**
 * Create a feature.  This defines a general interface; see individual feature
 * types for specific details.
 *
 * @param {geo.layer} layer The layer to add the feature to.
 * @param {geo.feature.spec} spec The feature specification.  At least the
 *      `type` must be specified.
 * @returns {geo.feature|null} The created feature or `null` for a failure.
 */
feature.create = function (layer, spec) {
  'use strict';

  // Check arguments
  if (!(layer instanceof require('./layer'))) {
    console.warn('Invalid layer');
    return null;
  }
  if (typeof spec !== 'object') {
    console.warn('Invalid spec');
    return null;
  }
  var type = spec.type;
  var feature = layer.createFeature(type, spec);
  if (!feature) {
    console.warn('Could not create feature type "' + type + '"');
    return null;
  }

  spec.data = spec.data || [];
  return feature.style(spec);
};

inherit(feature, sceneObject);
module.exports = feature;