quadFeature.js

var $ = require('jquery');
var inherit = require('./inherit');
var feature = require('./feature');

/**
 * Quad position specification
 *
 * @typedef {object} geo.quadFeature.position
 * @property {geo.geoPosition} [ul] Upper left coordinate.
 * @property {geo.geoPosition} [ur] Upper right coordinate.
 * @property {geo.geoPosition} [ll] Lower left coordinate.
 * @property {geo.geoPosition} [lr] Lower right coordinate.
 * @property {object} [crop] Image tile crop size in image pixels.  Areas
 *   beyond the width ``x`` and height ``y`` are transparent. ``left``,
 *   ``top``, ``right``, ``bottom`` extract a specific part of the image tile
 *   as the source and expand it to fill the conceptual space before any crop
 *   width and height are applied.
 * @property {number} [crop.x] Width of image after crop.
 * @property {number} [crop.y] Height of image after crop.
 * @property {number} [crop.left] Left coordinate of image source.
 * @property {number} [crop.top] Top coordinate of image source.
 * @property {number} [crop.right] Right coordinate of image source.
 * @property {number} [crop.bottom] Bottom coordinate of image source.
 */

/**
 * Quad feature specification.
 *
 * @typedef {geo.feature.spec} geo.quadFeature.spec
 * @extends geo.feature.spec
 * @property {geo.quadFeature.position|function} [position] Position of the
 *   quad.  Default is (data).  The position specifies the corners of the quad:
 *   ll, lr, ur, ul.  At least two opposite corners must be specified.   The
 *   corners do not have to physically correspond to the order specified, but
 *   rather correspond to that part of an image or video (if there is one).  If
 *   a corner is unspecified, it will use the x coordinate from one adjacent
 *   corner, the y coordinate from the other adjacent corner, and the average
 *   z value of those two corners.  For instance, if ul is unspecified, it is
 *   {x: ll.x, y: ur.y}.  Note that each quad is rendered as a pair of
 *   triangles: (ll, lr, ul) and (ur, ul, lr).  Nothing special is done for
 *   quads that are not convex or quads that have substantially different
 *   transformations for those two triangles.
 * @property {boolean} [cacheQuads=true] If truthy, a set of internal
 *   information is stored on each data item in the _cachedQuad attribute.  If
 *   this is falsy, the data item is not altered.  If the data (positions,
 *   opacity, etc.) of individual quads will change, set this to `false` or
 *   call `cacheUpdate` on the data item or for all data.
 * @property {geo.quadFeature.styleSpec} [style] Style object with default
 *   style options.
 * @property {boolean|number} [nearestPixel] If true, image quads are
 *   rendered with near-neighbor sampling.  If false, with interpolated
 *   sampling.  If a number, interpolate at that zoom level or below and
 *   nearest neighbor at that zoom level or above.
 */

/**
 * Style specification for a quad feature.
 *
 * @typedef {geo.feature.styleSpec} geo.quadFeature.styleSpec
 * @extends geo.feature.styleSpec
 * @property {geo.geoColor|function} [color] Color for quads without images.
 *   Default is white (`{r: 1, g: 1, b: 1}`).
 * @property {number|function} [opacity=1] Opacity for the quads.
 * @property {number|function} [depth=0] Default z-coordinate for positions
 *   that don't explicitly specify one.
 * @property {boolean|function} [drawOnAsyncResourceLoaded=true] Redraw quads
 *   when images or videos are loaded after initial render.
 * @property {Image|string|function} [image] Image for each data item.  If
 *   falsy and `video` is also falsy, the quad is a solid color.  Default is
 *   (data).image.
 * @property {HTMLVideoElement|string|function} [video] Video for each data
 *   item.  If falsy and `image` is also falsy, the quad is a solid color.
 *   Default is (data).video.
 * @property {boolean|function} [delayRenderWhenSeeking=true] If any video has a
 *   truthy value and is seeking, delaying rendering the entire feature.  This
 *   prevents blinking when seeking a playing video, but may cause stuttering
 *   when there are multiple videos.
 * @property {geo.geoColor|function} [previewColor=null] If specified, a color
 *   to show on image and video quads while waiting for the image or video to
 *   load.
 * @property {Image|string|function} [previewImage=null] If specified, an image
 *   to show on image quads while waiting for the quad-specific image to load.
 *   This will only be shown if it (the preview image) is already loaded.
 */

/**
 * Create a new instance of class quadFeature.
 *
 * @class
 * @alias geo.quadFeature
 * @param {geo.quadFeature.spec} arg Options object.
 * @extends geo.feature
 * @returns {geo.quadFeature}
 */
var quadFeature = function (arg) {
  'use strict';

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

  if (!(this instanceof quadFeature)) {
    return new quadFeature(arg);
  }
  arg = arg || {};
  feature.call(this, arg);

  /**
   * @private
   */
  var m_this = this,
      s_init = this._init,
      m_cacheQuads,
      m_nearestPixel = arg.nearestPixel,
      m_nextQuadId = 0,
      m_images = [],
      m_videos = [],
      m_quads;

  this.featureType = 'quad';

  /**
   * Track a list of object->object mappings.  The mappings are kept in a list.
   * This marks all known mappings as unused.  If they are not marked as used
   * before `_objectListEnd` is called, that function will remove them.
   *
   * @param {array} list The list of mappings.
   */
  this._objectListStart = function (list) {
    $.each(list, function (idx, item) {
      item.used = false;
    });
  };

  /**
   * Get the value from a list of object->object mappings.  If the key object
   * is not present, return `undefined`.  If found, the entry is marked as
   * being in use.
   *
   * @param {array} list The list of mappings.
   * @param {object} entry The key to search for.
   * @returns {object} The associated object or undefined.
   */
  this._objectListGet = function (list, entry) {
    for (var i = 0; i < list.length; i += 1) {
      if (list[i].entry === entry) {
        list[i].used = true;
        return list[i].value;
      }
    }
    return undefined;
  };

  /**
   * Add a new object to a list of object->object mappings.  The key object
   * should not exist, or this will create a duplicate.  The new entry is
   * marked as being in use.
   *
   * @param {array} list The list of mappings.
   * @param {object} entry The key to add.
   * @param {object} value The value to store with the entry.
   */
  this._objectListAdd = function (list, entry, value) {
    list.push({entry: entry, value: value, used: true});
  };

  /**
   * Remove all unused entries from a list of object->object mappings.
   *
   * @param {array} list The list of mappings.
   */
  this._objectListEnd = function (list) {
    for (var i = list.length - 1; i >= 0; i -= 1) {
      if (!list[i].used) {
        list.splice(i, 1);
      }
    }
  };

  /**
   * Point search method for selection api.  Returns markers containing the
   * given point.
   *
   * @param {geo.geoPosition} coordinate Coordinate in input gcs to check if it
   *    is located in any quad.
   * @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 {object} An object with `index`: a list of quad indices, `found`:
   *    a list of quads that contain the specified coordinate, and `extra`: an
   *    object with keys that are quad indices and values that are objects with
   *    `basis.x` and `basis.y`, values from 0 - 1 relative to interior of the
   *    quad.
   */
  this.pointSearch = function (coordinate, gcs) {
    var found = [], indices = [], extra = {},
        poly1 = [{}, {}, {}, {}], poly2 = [{}, {}, {}, {}],
        order1 = [0, 1, 2, 0], order2 = [1, 2, 3, 1],
        data = m_this.data(),
        map = m_this.layer().map(),
        i, coordbasis;
    gcs = (gcs === null ? map.gcs() : (gcs === undefined ? map.ingcs() : gcs));
    coordinate = transform.transformCoordinates(gcs, map.gcs(), coordinate);
    if (!m_quads) {
      m_this._generateQuads();
    }
    $.each([m_quads.clrQuads, m_quads.imgQuads, m_quads.vidQuads], function (idx, quadList) {
      quadList.forEach(function (quad, idx) {
        for (i = 0; i < order1.length; i += 1) {
          poly1[i].x = quad.pos[order1[i] * 3];
          poly1[i].y = quad.pos[order1[i] * 3 + 1];
          poly1[i].z = quad.pos[order1[i] * 3 + 2];
          poly2[i].x = quad.pos[order2[i] * 3];
          poly2[i].y = quad.pos[order2[i] * 3 + 1];
          poly2[i].z = quad.pos[order2[i] * 3 + 2];
        }
        if (util.pointInPolygon(coordinate, poly1) ||
            util.pointInPolygon(coordinate, poly2)) {
          indices.push(quad.idx);
          found.push(data[quad.idx]);
          /* If a point is in the quad (based on pointInPolygon, above), check
           * where in the quad it is located.  We want to output coordinates
           * where the upper-left is (0, 0) and the lower-right is (1, 1). */
          coordbasis = util.pointToTriangleBasis2d(
            coordinate, poly1[0], poly1[1], poly1[2]);
          if (!coordbasis || coordbasis.x + coordbasis.y > 1) {
            coordbasis = util.pointToTriangleBasis2d(
              coordinate, poly2[2], poly2[1], poly2[0]);
            if (coordbasis) {
              /* In the second triangle, (0, 0) is upper-right, (1, 0) is
               * upper-left, and (0, 1) is lower-right.  Invert x to get to
               * the desired output coordinates. */
              coordbasis.x = 1 - coordbasis.x;
            }
          } else {
            /* In the first triangle, (0, 0) is lower-left, (1, 0) is lower-
             * right, and (0, 1) is upper-left.  Invert y to get to the
             * desired output coordinates. */
            coordbasis.y = 1 - coordbasis.y;
          }
          if (coordbasis) {
            extra[quad.idx] = {basis: coordbasis, _quad: quad};
          }
        }
      });
    });
    return {
      index: indices,
      found: found,
      extra: extra
    };
  };

  /**
   * Get/Set position.
   *
   * @memberof geo.quadFeature
   * @param {object|function} [val] Object or function that returns the
   *    position of each quad.  `undefined` to get the current position value.
   * @returns {geo.quadFeature|this}
   */
  this.position = function (val) {
    if (val === undefined) {
      return m_this.style('position');
    } else {
      m_this.style('position', util.ensureFunction(val));
      m_this.dataTime().modified();
      m_this.modified();
    }
    return m_this;
  };

  /**
   * Given a data item and its index, fetch its position and ensure we have
   * complete information for the quad.  This generates missing corners and z
   * values.
   *
   * @param {function} posFunc A function to call to get the position of a data
   *   item.  It is passed (d, i).
   * @param {function} depthFunc A function to call to get the z-value of a
   *   data item.  It is passed (d, i).
   * @param {object} d A data item.  Used to fetch position and possibly depth.
   * @param {number} i The index within the data.  Used to fetch position and
   *   possibly depth.
   * @returns {object|undefined} Either an object with all four corners, or
   *   `undefined` if no such object can be generated.  The coordinates have
   *   been converted to map coordinates.
   */
  this._positionToQuad = function (posFunc, depthFunc, d, i) {
    var initPos = posFunc.call(m_this, d, i);
    if ((!initPos.ll || !initPos.ur) && (!initPos.ul || !initPos.lr)) {
      return;
    }
    var gcs = m_this.gcs(),
        map_gcs = m_this.layer().map().gcs(),
        pos = {};
    $.each(['ll', 'lr', 'ul', 'ur'], function (idx, key) {
      if (initPos[key] !== undefined) {
        pos[key] = {};
        if (initPos[key].x === undefined) {
          pos[key] = [initPos[key][0], initPos[key][1], initPos[key][2]];
        } else {
          pos[key] = [initPos[key].x, initPos[key].y, initPos[key].z];
        }
        if (pos[key][2] === undefined) {
          pos[key][2] = depthFunc.call(m_this, d, i);
        }
        if (gcs !== map_gcs && gcs !== false) {
          pos[key] = transform.transformCoordinates(
            gcs, map_gcs, pos[key]);
        }
      }
    });
    pos.ll = pos.ll || [pos.ul[0], pos.lr[1], (pos.ul[2] + pos.lr[2]) / 2];
    pos.lr = pos.lr || [pos.ur[0], pos.ll[1], (pos.ur[2] + pos.ll[2]) / 2];
    pos.ur = pos.ur || [pos.lr[0], pos.ul[1], (pos.lr[2] + pos.ul[2]) / 2];
    pos.ul = pos.ul || [pos.ll[0], pos.ur[1], (pos.ll[2] + pos.ur[2]) / 2];
    return pos;
  };

  /**
   * Renderers can subclass this when needed.
   *
   * This is called when a video qaud may have changed play state.
   * @param {object} quad The quad record that triggered this.
   * @param {jQuery.Event} [evt] The event that triggered this.
   */
  this._checkQuadUpdate = function (quad, evt) {
  };

  /**
   * Convert the current data set to a set of 3 arrays: quads that are a solid
   * color, quads that have an image, and quads that have a video.  All quads
   * are objects with pos (a 12 value array containing 4 three-dimensional
   * position coordinates), and opacity.  Color quads also have a color.  Image
   * quads may have an image element if the image is loaded.  If it isn't, this
   * element will be missing.  For preview images, the image quad will have a
   * reference to the preview element that may later be removed.  If a preview
   * color is used, the quad will be in both lists, but may be removed from the
   * color quad list once the image is loaded.  Video quads may have a video
   * element if the video is loaded.
   *
   * The value for origin is one of an ll corner from one of the quads with the
   * smallest sum of diagonals.  The assumption is that, if using the origin to
   * improve precision, the smallest quads are the ones most in need of this
   * benefit.
   *
   * @returns {object} An object with `clrQuads`, `imgQuads`, and `vidQuads`,
   *   each of which is an array; and `origin`, which is a triplet that is
   *   guaranteed to be one of the quads' corners for a quad with the smallest
   *   sum of diagonal lengths.
   */
  this._generateQuads = function () {
    var posFunc = m_this.position(),
        imgFunc = m_this.style.get('image'),
        vidFunc = m_this.style.get('video'),
        delayFunc = m_this.style.get('delayRenderWhenSeeking'),
        colorFunc = m_this.style.get('color'),
        depthFunc = m_this.style.get('depth'),
        opacityFunc = m_this.style.get('opacity'),
        loadedFunc = m_this.style.get('drawOnAsyncResourceLoaded'),
        previewColorFunc = m_this.style.get('previewColor'),
        previewImageFunc = m_this.style.get('previewImage'),
        data = m_this.data(),
        clrQuads = [], imgQuads = [], vidQuads = [],
        origin = [0, 0, 0], origindiag2, diag2;
    /* Keep track of images that we are using.  This prevents creating
     * additional Image elements for repeated urls. */
    m_this._objectListStart(m_images);
    m_this._objectListStart(m_videos);
    $.each(data, function (i, d) {
      if (d._cachedQuad) {
        diag2 = d._cachedQuad.diag2;
        if (origindiag2 === undefined || (d._cachedQuad.diag2 &&
            d._cachedQuad.diag2 < origindiag2)) {
          origin = d._cachedQuad.ll;
          origindiag2 = d._cachedQuad.diag2;
        }
        if (d._cachedQuad.clrquad) {
          clrQuads.push(d._cachedQuad.clrquad);
        }
        if (d._cachedQuad.imgquad) {
          if (d._cachedQuad.imageEntry) {
            m_this._objectListGet(m_images, d._cachedQuad.imageEntry);
          }
          imgQuads.push(d._cachedQuad.imgquad);
        }
        if (d._cachedQuad.vidquad) {
          if (d._cachedQuad.videoEntry) {
            m_this._objectListGet(m_videos, d._cachedQuad.videoEntry);
          }
          vidQuads.push(d._cachedQuad.vidquad);
        }
        return;
      }
      var quad, reload, image, video, prev_onload, prev_onerror, defer,
          pos, img, vid, opacity, previewColor, previewImage, quadinfo = {};

      pos = m_this._positionToQuad(posFunc, depthFunc, d, i);
      opacity = opacityFunc.call(m_this, d, i);
      if (pos === undefined || !opacity || opacity < 0) {
        return;
      }
      diag2 = Math.pow(pos.ll[0] - pos.ur[0], 2) + Math.pow(pos.ll[1] -
          pos.ur[1], 2) + Math.pow(pos.ll[2] - pos.ur[0], 2) + Math.pow(
        pos.lr[0] - pos.ur[0], 2) + Math.pow(pos.lr[1] - pos.ur[1], 2) +
        Math.pow(pos.lr[2] - pos.ur[0], 2);
      quadinfo.diag2 = diag2;
      quadinfo.ll = pos.ll;
      if (origindiag2 === undefined || (diag2 && diag2 < origindiag2)) {
        origin = pos.ll;
        origindiag2 = diag2;
      }
      pos = [
        pos.ll[0], pos.ll[1], pos.ll[2],
        pos.lr[0], pos.lr[1], pos.lr[2],
        pos.ul[0], pos.ul[1], pos.ul[2],
        pos.ur[0], pos.ur[1], pos.ur[2]
      ];
      quad = {
        idx: i,
        pos: pos,
        opacity: opacity,
        zOffset: d.zOffset
      };
      if (d.reference) {
        quad.reference = d.reference;
      }
      if (d.crop) {
        quad.crop = d.crop;
      }
      img = imgFunc.call(m_this, d, i);
      vid = img ? null : vidFunc.call(m_this, d, i);
      if (img) {
        quadinfo.imageEntry = img;
        /* Handle image quads */
        image = m_this._objectListGet(m_images, img);
        if (image === undefined) {
          if (img instanceof Image || img instanceof HTMLCanvasElement) {
            image = img;
          } else {
            image = new Image();
            image.src = img;
          }
          m_this._objectListAdd(m_images, img, image);
        }
        if (util.isReadyImage(image) || image instanceof HTMLCanvasElement) {
          quad.image = image;
        } else {
          previewColor = undefined;
          previewImage = previewImageFunc.call(m_this, d, i);
          if (previewImage && util.isReadyImage(previewImage)) {
            quad.image = previewImage;
          } else {
            previewColor = previewColorFunc.call(m_this, d, i);
            quad.color = util.convertColor(previewColor);
            if (quad.color && quad.color.r !== undefined && quad.color.g !== undefined && quad.color.b !== undefined) {
              clrQuads.push(quad);
              quadinfo.clrquad = quad;
            } else {
              previewColor = undefined;
            }
          }
          reload = loadedFunc.call(m_this, d, i);
          if (reload) {
            // add a promise to the layer if this image might complete
            defer = util.isReadyImage(image, true) ? null : $.Deferred();
            prev_onload = image.onload;
            image.onload = function () {
              if (previewColor !== undefined) {
                if ($.inArray(quad, clrQuads) >= 0) {
                  clrQuads.splice($.inArray(quad, clrQuads), 1);
                }
                delete quadinfo.clrquad;
              }
              quad.image = image;
              m_this.dataTime().modified();
              m_this.modified();
              m_this._update();
              m_this.layer().draw();
              if (defer) {
                defer.resolve();
              }
              if (prev_onload) {
                return prev_onload.apply(m_this, arguments);
              }
            };
            prev_onerror = image.onerror;
            image.onerror = function () {
              if (defer) {
                defer.reject();
              }
              if (prev_onerror) {
                return prev_onerror.apply(m_this, arguments);
              }
            };
            if (defer) {
              m_this.layer().addPromise(defer.promise());
            }
          } else if (previewColor === undefined && !quad.image) {
            /* the image isn't ready and we don't want to reload, so don't add
             * it to the list of image quads */
            return;
          }
        }
        imgQuads.push(quad);
        quadinfo.imgquad = quad;
      } else if (vid) {
        /* Handle video quads */
        quadinfo.videoEntry = vid;
        video = m_this._objectListGet(m_videos, vid);
        if (video === undefined) {
          if (vid instanceof HTMLVideoElement) {
            video = vid;
          } else {
            video = document.createElement('video');
            video.src = vid;
          }
          m_this._objectListAdd(m_videos, vid, video);
          /* monitor some media events that may indicate a change of play state
           * or seeking */
          $(video).off('.geojsvideo')
            .on('seeking.geojsvideo canplay.geojsvideo pause.geojsvideo playing.geojsvideo', function (evt) {
              m_this._checkQuadUpdate(quad, evt);
            });
        }
        quad.delayRenderWhenSeeking = delayFunc.call(m_this, d, i);
        if (quad.delayRenderWhenSeeking === undefined) {
          quad.delayRenderWhenSeeking = true;
        }
        if (util.isReadyVideo(video)) {
          quad.video = video;
        } else {
          previewColor = previewColorFunc.call(m_this, d, i);
          quad.color = util.convertColor(previewColor);
          if (quad.color && quad.color.r !== undefined && quad.color.g !== undefined && quad.color.b !== undefined) {
            clrQuads.push(quad);
            quadinfo.clrquad = quad;
          } else {
            previewColor = undefined;
          }
          reload = loadedFunc.call(m_this, d, i);
          if (reload) {
            // add a promise to the layer if this video might load
            defer = util.isReadyVideo(video, true) ? null : $.Deferred();
            prev_onload = video.onloadeddata;
            video.onloadeddata = function () {
              if (previewColor !== undefined) {
                if ($.inArray(quad, clrQuads) >= 0) {
                  clrQuads.splice($.inArray(quad, clrQuads), 1);
                }
                delete quadinfo.clrquad;
              }
              quad.video = video;
              m_this.dataTime().modified();
              m_this.modified();
              m_this._update();
              m_this.layer().draw();
              if (defer) {
                defer.resolve();
              }
              if (prev_onload) {
                return prev_onload.apply(m_this, arguments);
              }
            };
            prev_onerror = video.onerror;
            video.onerror = function () {
              if (defer) {
                defer.reject();
              }
              if (prev_onerror) {
                return prev_onerror.apply(m_this, arguments);
              }
            };
            if (defer) {
              m_this.layer().addPromise(defer.promise());
            }
          } else if (previewColor === undefined && !quad.video) {
            /* the video isn't ready and we don't want to reload, so don't add
             * it to the list of video quads */
            return;
          }
        }
        vidQuads.push(quad);
        quadinfo.vidquad = quad;
      } else {
        /* Handle color quads */
        quad.color = util.convertColor(colorFunc.call(m_this, d, i));
        if (!quad.color || quad.color.r === undefined || quad.color.g === undefined || quad.color.b === undefined) {
          /* if we can't resolve the color, don't make a quad */
          return;
        }
        clrQuads.push(quad);
        quadinfo.clrquad = quad;
      }
      if (quadinfo.clrquad) {
        m_nextQuadId += 1;
        quadinfo.clrquad.quadId = m_nextQuadId;
      }
      if (quadinfo.imgquad) {
        m_nextQuadId += 1;
        quadinfo.imgquad.quadId = m_nextQuadId;
      }
      if (quadinfo.vidquad) {
        m_nextQuadId += 1;
        quadinfo.vidquad.quadId = m_nextQuadId;
      }
      if (m_cacheQuads !== false) {
        d._cachedQuad = quadinfo;
      }
    });
    m_this._objectListEnd(m_images);
    m_this._objectListEnd(m_videos);
    m_quads = {
      clrQuads: clrQuads,
      imgQuads: imgQuads,
      vidQuads: vidQuads,
      origin: new Float32Array(origin)
    };
    return m_quads;
  };

  /**
   * If the data has changed and caching has been used, update one or all data
   * items by clearing their caches and updating the modified flag.
   *
   * @param {number|object} [indexOrData] If not specified, clear all quad
   *    caches.  If a number, clear that index-numbered entry from the data
   *    array.  Otherwise, clear the matching entry in the data array.
   * @returns {this}
   */
  this.cacheUpdate = function (indexOrData) {
    if (indexOrData === undefined || indexOrData === null) {
      $.each(m_this.data(), function (idx, entry) {
        if (entry._cachedQuad) {
          delete entry._cachedQuad;
        }
      });
    } else {
      if (isFinite(indexOrData)) {
        indexOrData = m_this.data()[indexOrData];
      }
      if (indexOrData._cachedQuad) {
        delete indexOrData._cachedQuad;
      }
    }
    m_this.modified();
    return m_this;
  };

  /**
   * Get the HTML video element associated with a data item.
   *
   * @param {number|object} indexOrData If a number, use that entry in the data
   *    array, otherwise this must be a value in the data array.  If caching is
   *    used, this is much more efficient.
   * @returns {HTMLVideoElement|null}
   */
  this.video = function (indexOrData) {
    var video, index;

    if (isFinite(indexOrData)) {
      indexOrData = m_this.data()[indexOrData];
    }
    if (indexOrData._cachedQuad) {
      video = (indexOrData._cachedQuad.vidquad || {}).video;
    } else {
      if (!m_quads) {
        m_this._generateQuads();
      }
      index = m_this.data().indexOf(indexOrData);
      if (index >= 0) {
        /* If we don't cache the quad, we don't maintain a direct link between
         * a data element and the video (partly because videos could be shared
         * between multiple quads).  Instead, the video will be in the
         * last-used object list with a reference to the video value of the
         * data entry. */
        video = m_this._objectListGet(m_videos, m_this.style.get('video')(indexOrData, index));
      }
    }
    if (video instanceof HTMLVideoElement) {
      return video;
    }
    return null;
  };

  /**
   * Get/Set nearestPixel value.
   *
   * @param {boolean|number} [val] If not specified, return the current value.
   *    If true, image quads are rendered with near-neighbor sampling.  If
   *    false, with interpolated sampling.  If a number, interpolate at that
   *    zoom level or below and nearest neighbor at that zoom level or above.
   * @returns {boolean|number|this}
   */
  this.nearestPixel = function (val) {
    if (val === undefined) {
      return m_nearestPixel;
    } else {
      m_nearestPixel = val;
      m_this.modified();
    }
    return m_this;
  };

  /**
   * Initialize.
   *
   * @param {geo.quadFeature.spec} arg Options for the feature.
   */
  this._init = function (arg) {
    arg = arg || {};
    s_init.call(m_this, arg);

    m_cacheQuads = (arg.cacheQuads !== false);

    var style = $.extend(
      {},
      {
        color: { r: 1.0, g: 1, b: 1 },
        opacity: 1,
        depth: 0,
        drawOnAsyncResourceLoaded: true,
        previewColor: null,
        previewImage: null,
        image: function (d) { return d.image; },
        video: function (d) { return d.video; },
        position: util.identityFunction
      },
      arg.style === undefined ? {} : arg.style
    );

    if (arg.position !== undefined) {
      style.position = util.ensureFunction(arg.position);
    }
    m_this.style(style);
    m_this.dataTime().modified();
  };

  return m_this;
};

/**
 * Create a quadFeature from an object.
 *
 * @see {@link geo.feature.create}
 * @param {geo.layer} layer The layer to add the feature to.
 * @param {geo.quadFeature.spec} spec The object specification.
 * @returns {geo.quadFeature|null}
 */
quadFeature.create = function (layer, spec) {
  'use strict';

  spec = spec || {};
  spec.type = 'quad';
  return feature.create(layer, spec);
};

quadFeature.capabilities = {
  /* support for solid-colored quads */
  color: 'quad.color',
  /* support for parallelogram image quads */
  image: 'quad.image',
  /* support for cropping quad images */
  imageCrop: 'quad.imageCrop',
  /* support for fixed-scale quad images */
  imageFixedScale: 'quad.imageFixedScale',
  /* support for arbitrary quad images */
  imageFull: 'quad.imageFull',
  /* support for canvas elements as content in image quads */
  canvas: 'quad.canvas',
  /* support for parallelogram video quads */
  video: 'quad.video'
};

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