pointFeature.js

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

/**
 * Object specification for a point feature.
 *
 * @typedef {geo.feature.spec} geo.pointFeature.spec
 * @extends geo.feature.spec
 * @property {geo.geoPosition|function} [position] Position of the data.
 *   Default is (data).
 * @property {geo.pointFeature.styleSpec} [style] Style object with default
 *   style options.
 * @property {boolean|geo.pointFeature.clusteringSpec} [clustering=false]
 *   Enable point clustering.
 * @property {string} [primitiveShape='auto'] For the webgl renderer, select
 *   the primitive shape.  This is one of `pointFeature.primitiveShapes`:
 *   `'auto'`, `'sprite'`, `'triangle'`, or `'square'`.  `sprite` uses the
 *   least memory but has a maximum size dependent on the GPU, `triangle` is
 *   fastest if the vertex shader is the bottleneck, and `square` is fastest if
 *   the fragment shader is the bottleneck.  `auto` will use `sprite` unless
 *   the largest point exceeds the size that can be rendered via GL points, and
 *   then it will switch to `triangle`.  The computation for `auto` uses some
 *   time, so using a specific primitive could be faster.
 * @property {boolean} [dynamicDraw=false] For the webgl renderer, if this is
 *   truthy, webgl source buffers can be modified and updated directly.
 *   truthy, webgl source buffers can be modified and updated directly.  This
 *   is not strictly necessary, as it is just a recommendation for the GPU.
 */

/**
 * Style specification for a point feature.
 *
 * @typedef {geo.feature.styleSpec} geo.pointFeature.styleSpec
 * @extends geo.feature.styleSpec
 * @property {number|function} [radius=5] Radius of each point in pixels.  This
 *   is the fill radius inside of the stroke.
 * @property {boolean|function} [stroke=true] True to stroke point.
 * @property {geo.geoColor|function} [strokeColor] Color to stroke each point.
 * @property {number|function} [strokeOpacity=1] Opacity for each point's
 *   stroke.  Opacity is on a [0-1] scale.
 * @property {number|function} [strokeWidth=1.25] The weight of the point's
 *   stroke in pixels.
 * @property {boolean|function} [fill=true] True to fill point.
 * @property {geo.geoColor|function} [fillColor] Color to fill each point.
 * @property {number|function} [fillOpacity=1] Opacity for each point.  Opacity
 *   is on a [0-1] scale.
 * @property {number[]|function} [origin] Origin in map gcs coordinates used
 *   for to ensure high precision drawing in this location.  When called as a
 *   function, this is passed the point positions as a single continuous array
 *   in map gcs coordinates.  It defaults to the first point's position.
 */

/**
 * Point clustering specification.
 *
 * @typedef {object} geo.pointFeature.clusteringSpec
 * @property {number} [radius=10] This is size in pixels that determines how
 *   close points need to be to each other to be clustered.
 * @property {number} [maxZoom=18] Never cluster above this zoom level.
 */

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

  var $ = require('jquery');
  var timestamp = require('./timestamp');
  var ClusterGroup = require('./util/clustering');
  var geo_event = require('./event');
  var util = require('./util');
  var KDBush = require('kdbush');
  KDBush = KDBush.__esModule ? KDBush.default : KDBush;

  /**
   * @private
   */
  var m_this = this,
      s_init = this._init,
      m_rangeTree = null,
      m_rangeTreeTime = timestamp(),
      s_data = this.data,
      m_maxRadius = 0,
      m_clustering = arg.clustering,
      m_clusterTree = null,
      m_allData = [],
      m_lastZoom = null,
      m_ignoreData = false; // flag to ignore data() calls made locally

  this.featureType = 'point';

  /**
   * Get/Set clustering option.
   *
   * @param {boolean|geo.pointFeature.clusteringSpec} [val] If not specified,
   *   return the current value.  If specified and falsy, turn off clustering.
   *   If `true`, use a default clustering with `radius` set to `0.01`.
   *   Otherwise, turn on clustering with these options.
   * @returns {geo.pointFeature.clusteringSpec|boolean|this}
   */
  this.clustering = function (val) {
    if (val === undefined) {
      return m_clustering;
    }
    if (m_clustering && !val) {
      // Throw out the cluster tree and reset the data
      m_clusterTree = null;
      m_clustering = false;
      s_data(m_allData);
    } else if (val && m_clustering !== val) {
      // Generate the cluster tree
      m_clustering = val;
      m_this._clusterData();
    }
    return m_this;
  };

  /**
   * Generate the clustering tree from positions.  This might be async in the
   * future.
   */
  this._clusterData = function () {
    if (!m_clustering) {
      // clustering is not enabled, so this is a no-op
      return;
    }

    // set clustering options to default if an options argument wasn't supplied
    var opts = m_clustering === true ? {radius: 10} : m_clustering;

    // generate the cluster tree from the raw data
    var position = m_this.position();

    var map = m_this.layer().map(),
        scrCenter = map.gcsToDisplay(map.center(undefined, null), null),
        center = map.displayToGcs(scrCenter, m_this.gcs()),
        offset = map.displayToGcs({x: scrCenter.x + opts.radius, y: scrCenter.y}, m_this.gcs()),
        radiusInGcsAtZoom = Math.pow(Math.pow(offset.y - center.y, 2) + Math.pow(offset.x - center.x, 2), 0.5),
        zoom = map.zoom(),
        radiusInGcsAtZoom0 = radiusInGcsAtZoom * Math.pow(2, zoom);
    opts = $.extend({}, opts, {radius: radiusInGcsAtZoom0});
    m_clusterTree = new ClusterGroup(opts);

    m_allData.forEach(function (d, i) {

      // for each point in the data set normalize the coordinate
      // representation and add the point to the cluster treee
      var pt = util.normalizeCoordinates(position(d, i));
      pt.index = i;
      m_clusterTree.addPoint(pt);
    });

    // reset the last zoom state and trigger a redraw at the current zoom level
    m_lastZoom = null;
    m_this._handleZoom(map.zoom());
  };

  /**
   * Handle zoom events for clustering.  This keeps track of the last
   * clustering level, and only regenerates the displayed points when the
   * zoom level changes.
   *
   * @param {number} zoom The new zoom level.
   */
  this._handleZoom = function (zoom) {
    // get the current zoom level rounded down
    var z = Math.floor(zoom);

    if (!m_clustering || z === m_lastZoom) {
      // short cut when there is nothing to do
      return;
    }

    // store the current zoom level privately
    m_lastZoom = z;

    // get the raw data elements for the points at the current level
    var data = m_clusterTree.points(z).map(function (d) {
      return m_allData[d.index];
    });

    // append the clusters at the current level
    m_clusterTree.clusters(z).forEach(function (d) {
      // mark the datum as a cluster for accessor methods
      d.__cluster = true;

      // store all of the data objects for each point in the cluster as __data
      d.__data = [];
      d.obj.each(function (e) {
        d.__data.push(m_allData[e.index]);
      });
      data.push(d);
    });

    // prevent recomputing the clustering and set the new data array
    m_ignoreData = true;
    m_this.data(data);
  };

  /**
   * Get/Set position.
   *
   * @param {function|geo.geoPosition} [val] If not specified, return the
   *    position accessor, which is guaranteed to be a function.  If specified,
   *    wrap the value in an function that handles clustering if it is enabled
   *    and set the position accessor to that function.
   * @returns {this|function}
   */
  this.position = function (val) {
    if (val === undefined) {
      return m_this.style('position');
    } else {
      var isFunc = util.isFunction(val);
      m_this.style('position', function (d, i) {
        if (d !== null && d !== undefined && d.__cluster) {
          return d;
        } else if (isFunc) {
          return val(d, i);
        } else {
          return val;
        }
      });
      m_this.dataTime().modified();
      m_this.modified();
    }
    return m_this;
  };

  /**
   * Update the current range tree object.  Should be called whenever the
   * data changes.
   */
  this._updateRangeTree = function () {
    if (m_rangeTreeTime.timestamp() >= m_this.dataTime().timestamp() && m_rangeTreeTime.timestamp() >= m_this.timestamp()) {
      return;
    }
    var pts, position,
        radius = m_this.style.get('radius'),
        stroke = m_this.style.get('stroke'),
        strokeWidth = m_this.style.get('strokeWidth');

    position = m_this.position();

    m_maxRadius = 0;

    // create an array of positions in geo coordinates
    pts = m_this.data().map(function (d, i) {
      var pt = position(d, i);

      // store the maximum point radius
      m_maxRadius = Math.max(
        m_maxRadius,
        radius(d, i) + (stroke(d, i) ? strokeWidth(d, i) : 0)
      );

      return [pt.x, pt.y];
    });

    m_rangeTree = new KDBush(pts.length);
    for (const [x, y] of pts) {
      m_rangeTree.add(x, y);
    }
    m_rangeTree.finish();
    m_rangeTreeTime.modified();
  };

  /**
   * Returns an array of datum indices that contain the given point.
   * Largely adapted from wigglemaps pointQuerier:
   * https://github.com/dotskapes/wigglemaps/blob/cf5bed3fbfe2c3e48d31799462a80c564be1fb60/src/query/PointQuerier.js
   * This does not take into account clustering.
   *
   * @param {geo.geoPosition} p point to search for.
   * @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 point indices, and
   *    `found`: a list of points that contain the specified coordinate.
   */
  this.pointSearch = function (p, gcs) {
    var min, max, data, idx = [], found = [], ifound = [], map, pt,
        fgcs = m_this.gcs(), // this feature's gcs
        corners,
        fill = m_this.style.get('fill'),
        stroke = m_this.style.get('stroke'),
        strokeWidth = m_this.style.get('strokeWidth'),
        radius = m_this.style.get('radius');

    data = m_this.data();
    if (!data || !data.length) {
      return {
        found: [],
        index: []
      };
    }

    // We need to do this before we find corners, since the max radius is
    // determined then
    m_this._updateRangeTree();

    map = m_this.layer().map();
    pt = map.gcsToDisplay(p, gcs);
    // check all corners to make sure we handle rotations
    corners = [
      map.displayToGcs({x: pt.x - m_maxRadius, y: pt.y - m_maxRadius}, fgcs),
      map.displayToGcs({x: pt.x + m_maxRadius, y: pt.y - m_maxRadius}, fgcs),
      map.displayToGcs({x: pt.x - m_maxRadius, y: pt.y + m_maxRadius}, fgcs),
      map.displayToGcs({x: pt.x + m_maxRadius, y: pt.y + m_maxRadius}, fgcs)
    ];
    min = {
      x: Math.min(corners[0].x, corners[1].x, corners[2].x, corners[3].x),
      y: Math.min(corners[0].y, corners[1].y, corners[2].y, corners[3].y)
    };
    max = {
      x: Math.max(corners[0].x, corners[1].x, corners[2].x, corners[3].x),
      y: Math.max(corners[0].y, corners[1].y, corners[2].y, corners[3].y)
    };

    // Find points inside the bounding box
    idx = m_rangeTree.range(min.x, min.y, max.x, max.y);

    idx.sort((a, b) => a - b);
    // Filter by circular region
    idx.forEach(function (i) {
      var d = data[i],
          hasstroke = stroke(data[i], i);
      if (!hasstroke && !fill(data[i], i)) {
        return;
      }
      var p = m_this.position()(d, i),
          dx, dy, rad, rad2;
      rad = radius(data[i], i);
      rad += hasstroke ? strokeWidth(data[i], i) : 0;
      if (rad) {
        rad2 = rad * rad;
        p = map.gcsToDisplay(p, fgcs);
        dx = p.x - pt.x;
        dy = p.y - pt.y;
        if (dx * dx + dy * dy <= rad2) {
          found.push(d);
          ifound.push(i);
        }
      }
    });

    return {
      found: found,
      index: ifound
    };
  };

  /**
   * Returns an array of datum indices that are contained in the given polygon.
   * This does not take clustering into account.
   *
   * @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 points that are
   *    partially in the polygon, otherwise only include points that are fully
   *    within the region.  If 'center', only points whose centers are inside
   *    the polygon are returned.
   * @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 point indices,
   *    `found`: a list of points within the polygon, and `extra`: an object
   *    with index keys containing an object with a `partial` key and a boolean
   *    value to indicate if the point is on the polygon's border and a
   *    `distance` key to indicate how far within the polygon the point is
   *    located.
   */
  this.polygonSearch = function (poly, opts, gcs) {
    var fgcs = m_this.gcs(), // this feature's gcs
        found = [],
        ifound = [],
        extra = {},
        map = m_this.layer().map(),
        data = m_this.data(),
        fill = m_this.style.get('fill'),
        stroke = m_this.style.get('stroke'),
        strokeWidth = m_this.style.get('strokeWidth'),
        radius = m_this.style.get('radius'),
        idx, min, max, corners;
    if (!poly.outer) {
      poly = {outer: poly, inner: []};
    }
    if (poly.outer.length < 3 || !data || !data.length) {
      return {
        found: [],
        index: [],
        extra: {}
      };
    }
    opts = opts || {};
    opts.partial = opts.partial || false;
    poly = {outer: map.gcsToDisplay(poly.outer, gcs), inner: (poly.inner || []).map(inner => map.gcsToDisplay(inner, gcs))};
    poly.outer.forEach(p => {
      if (!min) {
        min = {x: p.x, y: p.y};
        max = {x: p.x, y: p.y};
      }
      if (p.x < min.x) { min.x = p.x; }
      if (p.x > max.x) { max.x = p.x; }
      if (p.y < min.y) { min.y = p.y; }
      if (p.y > max.y) { max.y = p.y; }
    });
    // We need to do this before we find corners, since the max radius is
    // determined then
    m_this._updateRangeTree();
    corners = [
      map.displayToGcs({x: min.x - m_maxRadius, y: min.y - m_maxRadius}, fgcs),
      map.displayToGcs({x: max.x + m_maxRadius, y: min.y - m_maxRadius}, fgcs),
      map.displayToGcs({x: max.x + m_maxRadius, y: max.y + m_maxRadius}, fgcs),
      map.displayToGcs({x: min.x - m_maxRadius, y: max.y + m_maxRadius}, fgcs)
    ];
    min = {
      x: Math.min(corners[0].x, corners[1].x, corners[2].x, corners[3].x),
      y: Math.min(corners[0].y, corners[1].y, corners[2].y, corners[3].y)
    };
    max = {
      x: Math.max(corners[0].x, corners[1].x, corners[2].x, corners[3].x),
      y: Math.max(corners[0].y, corners[1].y, corners[2].y, corners[3].y)
    };
    // Find points inside the bounding box.  Only these could be in the polygon
    idx = m_rangeTree.range(min.x, min.y, max.x, max.y);
    // sort by index
    idx.sort((a, b) => a - b);
    // filter points within the polygon
    idx.forEach(function (i) {
      var d = data[i],
          hasstroke = stroke(data[i], i);
      if (!hasstroke && !fill(data[i], i)) {
        return;
      }
      let p = m_this.position()(d, i);
      let rad = radius(data[i], i);
      rad += hasstroke ? strokeWidth(data[i], i) : 0;
      if (rad) {
        p = map.gcsToDisplay(p, fgcs);
        const dist = util.distanceToPolygon2d(p, poly);
        if (dist >= rad || (dist >= 0 && opts.partial === 'center') || (dist >= -rad && opts.partial && opts.partial !== 'center')) {
          found.push(d);
          ifound.push(i);
          extra[i] = {partial: dist < rad, distance: dist};
        }
      }
    });
    return {
      found: found,
      index: ifound,
      extra: extra
    };
  };

  /**
   * Overloaded data method that updates the internal range tree on write.
   * Get/Set the data array for the feature.
   *
   * @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 s_data();
    }
    if (!m_ignoreData) {
      m_allData = data;
    }
    if (m_clustering && !m_ignoreData) {
      m_this._clusterData();
    } else {
      s_data(data);
    }
    m_ignoreData = false;
    return m_this;
  };

  /**
   * Initialize.
   *
   * @param {geo.pointFeature.spec} arg The feature specification.
   * @returns {this}
   */
  this._init = function (arg) {
    arg = arg || {};
    s_init.call(m_this, arg);

    var defaultStyle = $.extend(
      true,
      {},
      {
        radius: 5.0,
        stroke: true,
        strokeColor: { r: 0.851, g: 0.604, b: 0.0 },
        strokeWidth: 1.25,
        strokeOpacity: 1.0,
        fillColor: { r: 1.0, g: 0.839, b: 0.439 },
        fill: true,
        fillOpacity: 0.8,
        position: (d) => Array.isArray(d) ? {x: d[0], y: d[1], z: d[2] || 0} : d,
        origin: (p) => (p.length >= 3 ? p.slice(0, 3) : [0, 0, 0])
      },
      arg.style === undefined ? {} : arg.style
    );

    if (arg.position !== undefined) {
      defaultStyle.position = arg.position;
    }

    m_this.style(defaultStyle);
    if (defaultStyle.position) {
      m_this.position(defaultStyle.position);
    }
    m_this.dataTime().modified();

    // bind to the zoom handler for point clustering
    m_this.geoOn(geo_event.zoom, function (evt) {
      m_this._handleZoom(evt.zoomLevel);
    });
    return m_this;
  };

  return m_this;
};

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

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

pointFeature.capabilities = {
  /* core feature name -- support in any manner */
  feature: 'point',
  /* support for stroke properties */
  stroke: 'point.stroke'
};

/**
 * Support primitive shapes
 * @enum {string}
 */
pointFeature.primitiveShapes = {
  auto: 'auto',
  sprite: 'sprite',
  triangle: 'triangle',
  square: 'square'
};

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