canvas/heatmapFeature.js

var inherit = require('../inherit');
var registerFeature = require('../registry').registerFeature;
var heatmapFeature = require('../heatmapFeature');
var timestamp = require('../timestamp');
var util = require('../util');

/**
 * Create a new instance of class canvas.heatmapFeature.
 * Inspired from
 *    https://github.com/mourner/simpleheat/blob/gh-pages/simpleheat.js .
 *
 * @class
 * @alias geo.canvas.heatmapFeature
 * @param {geo.heatmapFeature.spec} arg
 * @extends geo.heatmapFeature
 * @returns {canvas_heatmapFeature}
 */
var canvas_heatmapFeature = function (arg) {
  'use strict';

  if (!(this instanceof canvas_heatmapFeature)) {
    return new canvas_heatmapFeature(arg);
  }
  heatmapFeature.call(this, arg);
  var object = require('./object');

  object.call(this);

  /**
   * @private
   */
  var geo_event = require('../event');

  var m_this = this,
      m_typedBuffer,
      m_typedClampedBuffer,
      m_typedBufferData,
      m_heatMapPosition,
      m_heatMapTransform,
      s_init = this._init,
      s_update = this._update,
      m_lastRenderDuration,
      m_renderTime = timestamp();

  /**
   * Compute gradient.  This creates a color lookup table.
   *
   * @returns {this}
   */
  this._computeGradient = function () {
    var canvas, stop, context2d, gradient, colors;

    colors = m_this.style('color');
    if (!m_this._grad || m_this._gradColors !== colors) {
      canvas = document.createElement('canvas');
      context2d = canvas.getContext('2d');
      gradient = context2d.createLinearGradient(0, 0, 0, 256);

      canvas.width = 1;
      canvas.height = 256;

      for (stop in colors) {
        gradient.addColorStop(stop, util.convertColorToRGBA(colors[stop]));
      }

      context2d.fillStyle = gradient;
      context2d.fillRect(0, 0, 1, 256);
      m_this._grad = context2d.getImageData(0, 0, 1, 256).data;
      m_this._gradColors = colors;
    }

    return m_this;
  };

  /**
   * Create a circle to render at each data point.
   *
   * @returns {this}
   */
  this._createCircle = function () {
    var circle, ctx, r, r2, blur, gaussian, scale;
    r = m_this.style('radius');
    blur = m_this.style('blurRadius');
    gaussian = m_this.style('gaussian');
    scale = m_this.style('scaleWithZoom');
    if (scale) {
      const zoom = this.layer().map().zoom();
      scale = Math.pow(2, zoom);
      r *= scale;
      blur *= scale;
    }
    if (!m_this._circle || m_this._circle.gaussian !== gaussian ||
        m_this._circle.radius !== r || m_this._circle.blurRadius !== blur) {
      circle = m_this._circle = document.createElement('canvas');
      ctx = circle.getContext('2d');
      r2 = blur + r;
      circle.width = circle.height = r2 * 2;
      if (!gaussian) {
        ctx.shadowOffsetX = ctx.shadowOffsetY = r2 * 2;
        ctx.shadowBlur = blur;
        ctx.shadowColor = 'black';
        ctx.beginPath();
        ctx.arc(-r2, -r2, r, 0, Math.PI * 2, true);
        ctx.closePath();
        ctx.fill();
      } else {
        /* This approximates a gaussian distribution by using a 10-step
         * piecewise linear radial gradient.  Strictly, it should not stop at
         * the radius, but should be attenuated further.  The scale has been
         * selected such that the values at the radius are around 1/256th of
         * the maximum, and therefore would not be visible using an 8-bit alpha
         * channel for the summation.  The values for opacity were generated by
         * the python expression:
         *   from scipy.stats import norm
         *   for r in [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1]:
         *     opacity = norm.pdf(r, scale=0.3) / norm.pdf(0, scale=0.3)
         * Using a 10-interval approximation is accurate to within 0.5% of the
         * actual Gaussian magnitude.  Switching to a 20-interval approximation
         * would get within 0.1%, at which point there is more error from using
         * a Gaussian truncated at the radius than from the approximation.
         */
        var grad = ctx.createRadialGradient(r2, r2, 0, r2, r2, r2);
        grad.addColorStop(0.0, 'rgba(255,255,255,1)');
        grad.addColorStop(0.1, 'rgba(255,255,255,0.946)');
        grad.addColorStop(0.2, 'rgba(255,255,255,0.801)');
        grad.addColorStop(0.3, 'rgba(255,255,255,0.607)');
        grad.addColorStop(0.4, 'rgba(255,255,255,0.411)');
        grad.addColorStop(0.5, 'rgba(255,255,255,0.249)');
        grad.addColorStop(0.6, 'rgba(255,255,255,0.135)');
        grad.addColorStop(0.7, 'rgba(255,255,255,0.066)');
        grad.addColorStop(0.8, 'rgba(255,255,255,0.029)');
        grad.addColorStop(0.9, 'rgba(255,255,255,0.011)');
        grad.addColorStop(1.0, 'rgba(255,255,255,0)');
        ctx.fillStyle = grad;
        ctx.fillRect(0, 0, r2 * 2, r2 * 2);
      }
      circle.radius = r;
      circle.blurRadius = blur;
      circle.gaussian = gaussian;
      m_this._circle = circle;
    }
    return m_this;
  };

  /**
   * Compute color for each pixel on the screen.
   *
   * @param {Uint8ClampedArray} pixels A 2D canvas `getImageData` buffer.
   * @param {Uint8ClampedArray} gradient A 2D canvas with 256 pixels that
   *    contain a color gradient.
   * @protected
   */
  this._colorize = function (pixels, gradient) {
    var grad = new Uint32Array(gradient.buffer),
        pixlen = pixels.length,
        i, j, k;
    if (!m_typedBuffer || m_typedBuffer.length !== pixlen) {
      m_typedBuffer = new ArrayBuffer(pixlen);
      m_typedClampedBuffer = new Uint8ClampedArray(m_typedBuffer);
      m_typedBufferData = new Uint32Array(m_typedBuffer);
    }
    for (i = 3, k = 0; i < pixlen; i += 4, k += 1) {
      // Get opacity from the temporary canvas image and look up the final
      // value from gradient
      j = pixels[i];
      if (j) {
        m_typedBufferData[k] = grad[j];
      }
    }
    pixels.set(m_typedClampedBuffer);
  };

  /**
   * Render individual data points on the canvas.
   *
   * @param {RenderingContext} context2d The canvas context to draw in.
   * @param {geo.map} map The parent map object.
   * @param {array} data The main data array.
   * @param {number} radius The sum of `radius` and `blurRadius`.
   */
  this._renderPoints = function (context2d, map, data, radius) {
    var position = m_this.gcsPosition(),
        intensityFunc = m_this.intensity(),
        minIntensity = m_this.minIntensity(),
        rangeIntensity = (m_this.maxIntensity() - minIntensity) || 1,
        idx, pos, intensity;

    for (idx = data.length - 1; idx >= 0; idx -= 1) {
      pos = map.worldToDisplay(position[idx]);
      intensity = (intensityFunc(data[idx], idx) - minIntensity) / rangeIntensity;
      if (intensity <= 0) {
        continue;
      }
      // Small values are not visible because globalAlpha < .01
      // cannot be read from imageData
      context2d.globalAlpha = intensity < 0.01 ? 0.01 : (intensity > 1 ? 1 : intensity);
      context2d.drawImage(m_this._circle, pos.x - radius, pos.y - radius);
    }
  };

  /**
   * Render data points on the canvas by binning.
   *
   * @param {RenderingContext} context2d The canvas context to draw in.
   * @param {geo.map} map The parent map object.
   * @param {array} data The main data array.
   * @param {number} radius The sum of `radius` and `blurRadius`.
   * @param {number} binSize Size of the bins in pixels.
   */
  this._renderBinnedData = function (context2d, map, data, radius, binSize) {
    var position = m_this.gcsPosition(),
        intensityFunc = m_this.intensity(),
        minIntensity = m_this.minIntensity(),
        rangeIntensity = (m_this.maxIntensity() - minIntensity) || 1,
        mapSize = map.size(),
        bins = [],
        rw = Math.ceil(radius / binSize),
        maxx = Math.ceil(mapSize.width / binSize) + rw * 2 + 2,
        maxy = Math.ceil(mapSize.height / binSize) + rw * 2 + 2,
        datalen = data.length,
        idx, pos, intensity, x, y, binrow, offsetx, offsety;

    /* We create bins of size (binSize) pixels on a side.  We only track bins
     * that are on the viewport or within the radius of it, plus one extra bin
     * width. */
    for (idx = 0; idx < datalen; idx += 1) {
      pos = map.worldToDisplay(position[idx]);
      /* To make the results look more stable, we use the first data point as a
       * hard-reference to where the bins should line up.  Otherwise, as we pan
       * points would shift which bin they are in and the display would ripple
       * oddly. */
      if (isNaN(pos.x) || isNaN(pos.y)) {
        continue;
      }
      if (offsetx === undefined) {
        offsetx = ((pos.x % binSize) + binSize) % binSize;
        offsety = ((pos.y % binSize) + binSize) % binSize;
      }
      /* We handle points that are in the viewport, plus the radius on either
       * side, as they will add into the visual effect, plus one additional bin
       * to account for the offset alignment. */
      x = Math.floor((pos.x - offsetx) / binSize) + rw + 1;
      if (x < 0 || x >= maxx) {
        continue;
      }
      y = Math.floor((pos.y - offsety) / binSize) + rw + 1;
      if (y < 0 || y >= maxy) {
        continue;
      }
      intensity = (intensityFunc(data[idx], idx) - minIntensity) / rangeIntensity;
      if (intensity <= 0) {
        continue;
      }
      if (intensity > 1) {
        intensity = 1;
      }
      /* bins is an array of arrays.  The subarrays would be conceptually
       * better represented as an array of dicts, but having a sparse array is
       * uses much less memory and is faster.  Each bin uses four array entries
       * that are (weight, intensity, x, y).  The weight is the sum of the
       * intensities for all points in the bin.  The intensity is the geometric
       * sum of the intensities to approximate what happens to the unbinned
       * data on the alpha channel of the canvas.  The x and y coordinates are
       * weighted by the intensity of each point. */
      bins[y] = bins[y] || [];
      x *= 4;
      binrow = bins[y];
      if (!binrow[x]) {
        binrow[x] = binrow[x + 1] = intensity;
        binrow[x + 2] = pos.x * intensity;
        binrow[x + 3] = pos.y * intensity;
      } else {
        binrow[x] += intensity;  // weight
        binrow[x + 1] += (1 - binrow[x + 1]) * intensity;
        binrow[x + 2] += pos.x * intensity;
        binrow[x + 3] += pos.y * intensity;
      }
    }
    /* For each bin, render a point on the canvas. */
    for (y = bins.length - 1; y >= 0; y -= 1) {
      binrow = bins[y];
      if (binrow) {
        for (x = binrow.length - 4; x >= 0; x -= 4) {
          if (binrow[x]) {
            intensity = binrow[x + 1];
            context2d.globalAlpha = intensity < 0.01 ? 0.01 : (intensity > 1 ? 1 : intensity);
            /* The position is eighted by the intensities, so we have to divide
             * it to get the necessary position */
            context2d.drawImage(
              m_this._circle,
              binrow[x + 2] / binrow[x] - radius,
              binrow[x + 3] / binrow[x] - radius);
          }
        }
      }
    }
  };

  /**
   * Render the data on the canvas, then colorize the resulting opacity map.
   *
   * @param {RenderingContext} context2d The canvas context to draw in.
   * @param {geo.map} map The parent map object.
   * @returns {this}
   */
  this._renderOnCanvas = function (context2d, map) {

    if (m_renderTime.timestamp() < m_this.buildTime().timestamp()) {
      const starttime = Date.now();
      var data = m_this.data() || [],
          radius = m_this.style('radius') + m_this.style('blurRadius'),
          binned = m_this.binned(),
          canvas, pixelArray,
          layer = m_this.layer(),
          mapSize = map.size();

      if (m_this.style('scaleWithZoom')) {
        radius *= Math.pow(2, map.zoom());
      }
      /* Determine if we should bin the data */
      if (binned === true || binned === 'auto') {
        binned = Math.max(Math.floor(radius / 8), Math.max(1.5, Math.min(3, radius / 2.5)));
        if (m_this.binned() === 'auto') {
          var numbins = (Math.ceil((mapSize.width + radius * 2) / binned) *
                         Math.ceil((mapSize.height + radius * 2) / binned));
          if (numbins >= data.length) {
            binned = 0;
          }
        }
      }
      if (binned < 1 || isNaN(binned)) {
        binned = false;
      }
      /* Store what we did, in case this is ever useful elsewhere */
      m_this._binned = binned;

      context2d.setTransform(1, 0, 0, 1, 0, 0);
      context2d.clearRect(0, 0, mapSize.width, mapSize.height);
      m_heatMapTransform = '';
      if (radius > 0.5 && radius < 8192) {
        map.scheduleAnimationFrame(m_this._setTransform, false);
        layer.canvas().css({transform: ''});

        m_this._createCircle();
        m_this._computeGradient();
        if (!binned) {
          m_this._renderPoints(context2d, map, data, radius);
        } else {
          m_this._renderBinnedData(context2d, map, data, radius, binned);
        }
        canvas = layer.canvas()[0];
        pixelArray = context2d.getImageData(0, 0, canvas.width, canvas.height);
        m_this._colorize(pixelArray.data, m_this._grad);
        context2d.putImageData(pixelArray, 0, 0);
      }

      m_heatMapPosition = {
        zoom: map.zoom(),
        gcsOrigin: map.displayToGcs({x: 0, y: 0}, null),
        rotation: map.rotation(),
        lastScale: undefined,
        lastOrigin: {x: 0, y: 0},
        lastRotation: undefined
      };
      m_renderTime.modified();
      layer.renderer().clearCanvas(false);
      m_lastRenderDuration = Date.now() - starttime;
    }

    return m_this;
  };

  /**
   * Initialize.
   *
   * @returns {this}
   */
  this._init = function () {
    s_init.call(m_this, arg);

    m_this.geoOn(geo_event.pan, m_this._animatePan);

    return m_this;
  };

  /**
   * Update the feature.
   *
   * @returns {this}
   */
  this._update = function () {
    s_update.call(m_this);
    if (m_this.buildTime().timestamp() <= m_this.dataTime().timestamp() ||
        m_this.updateTime().timestamp() < m_this.timestamp()) {
      m_this._build();
    }
    m_this.updateTime().modified();
    return m_this;
  };

  /**
   * Update the css transform for the layer as part of an animation frame.
   * This allows an existing rendered version of the heatmap to appear with a
   * transform until a new version can be computed.
   */
  this._setTransform = function () {
    if (m_this.layer() && m_this.layer().canvas() && m_this.layer().canvas()[0]) {
      m_this.layer().canvas()[0].style.transform = m_heatMapTransform;
    }
  };

  /**
   * Animate pan and zoom.
   */
  this._animatePan = function () {

    if (!m_heatMapPosition) {
      return;
    }
    var map = m_this.layer().map(),
        zoom = map.zoom(),
        scale = Math.pow(2, (zoom - m_heatMapPosition.zoom)),
        origin = map.gcsToDisplay(m_heatMapPosition.gcsOrigin, null),
        rotation = map.rotation();

    if (m_heatMapPosition.lastScale === scale &&
        m_heatMapPosition.lastOrigin.x === origin.x &&
        m_heatMapPosition.lastOrigin.y === origin.y &&
        m_heatMapPosition.lastRotation === rotation) {
      return;
    }

    var transform = '' +
        ' translate(' + origin.x + 'px' + ',' + origin.y + 'px' + ')' +
        ' scale(' + scale + ')' +
        ' rotate(' + ((rotation - m_heatMapPosition.rotation) * 180 / Math.PI) + 'deg)';

    map.scheduleAnimationFrame(m_this._setTransform);

    m_heatMapTransform = transform;
    m_heatMapPosition.lastScale = scale;
    m_heatMapPosition.lastOrigin.x = origin.x;
    m_heatMapPosition.lastOrigin.y = origin.y;
    m_heatMapPosition.lastRotation = rotation;

    if (m_heatMapPosition.timeout) {
      window.clearTimeout(m_heatMapPosition.timeout);
      m_heatMapPosition.timeout = undefined;
    }
    /* This conditional can change if we compute the heatmap beyond the visible
     * viewport so that we don't have to update on pans as often.  If we are
     * close to where the heatmap was originally computed, don't bother
     * updating it. */
    if (parseFloat(scale.toFixed(4)) !== 1 ||
        parseFloat((rotation - m_heatMapPosition.rotation).toFixed(4)) !== 0 ||
        parseFloat(origin.x.toFixed(1)) !== 0 ||
        parseFloat(origin.y.toFixed(1)) !== 0) {
      let delay = m_this.updateDelay();
      if (delay < 0 && m_lastRenderDuration) {
        delay = m_lastRenderDuration - Math.floor(1000 / 60 * delay);
      } else if (m_lastRenderDuration) {
        delay = m_lastRenderDuration * 2;
      } else {
        delay = 100;
      }
      m_heatMapPosition.timeout = window.setTimeout(function () {
        m_heatMapPosition.timeout = undefined;
        m_this.buildTime().modified();
        m_this.layer().draw();
      }, m_this.updateDelay());
    }
  };

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

inherit(canvas_heatmapFeature, heatmapFeature);

// Now register it
registerFeature('canvas', 'heatmap', canvas_heatmapFeature);
module.exports = canvas_heatmapFeature;