canvas/heatmapFeature.js

  1. var inherit = require('../inherit');
  2. var registerFeature = require('../registry').registerFeature;
  3. var heatmapFeature = require('../heatmapFeature');
  4. var timestamp = require('../timestamp');
  5. var util = require('../util');
  6. /**
  7. * Create a new instance of class canvas.heatmapFeature.
  8. * Inspired from
  9. * https://github.com/mourner/simpleheat/blob/gh-pages/simpleheat.js .
  10. *
  11. * @class
  12. * @alias geo.canvas.heatmapFeature
  13. * @param {geo.heatmapFeature.spec} arg
  14. * @extends geo.heatmapFeature
  15. * @returns {canvas_heatmapFeature}
  16. */
  17. var canvas_heatmapFeature = function (arg) {
  18. 'use strict';
  19. if (!(this instanceof canvas_heatmapFeature)) {
  20. return new canvas_heatmapFeature(arg);
  21. }
  22. heatmapFeature.call(this, arg);
  23. var object = require('./object');
  24. object.call(this);
  25. /**
  26. * @private
  27. */
  28. var geo_event = require('../event');
  29. var m_this = this,
  30. m_typedBuffer,
  31. m_typedClampedBuffer,
  32. m_typedBufferData,
  33. m_heatMapPosition,
  34. m_heatMapTransform,
  35. s_init = this._init,
  36. s_update = this._update,
  37. m_lastRenderDuration,
  38. m_renderTime = timestamp();
  39. /**
  40. * Compute gradient. This creates a color lookup table.
  41. *
  42. * @returns {this}
  43. */
  44. this._computeGradient = function () {
  45. var canvas, stop, context2d, gradient, colors;
  46. colors = m_this.style('color');
  47. if (!m_this._grad || m_this._gradColors !== colors) {
  48. canvas = document.createElement('canvas');
  49. context2d = canvas.getContext('2d');
  50. gradient = context2d.createLinearGradient(0, 0, 0, 256);
  51. canvas.width = 1;
  52. canvas.height = 256;
  53. for (stop in colors) {
  54. gradient.addColorStop(stop, util.convertColorToRGBA(colors[stop]));
  55. }
  56. context2d.fillStyle = gradient;
  57. context2d.fillRect(0, 0, 1, 256);
  58. m_this._grad = context2d.getImageData(0, 0, 1, 256).data;
  59. m_this._gradColors = colors;
  60. }
  61. return m_this;
  62. };
  63. /**
  64. * Create a circle to render at each data point.
  65. *
  66. * @returns {this}
  67. */
  68. this._createCircle = function () {
  69. var circle, ctx, r, r2, blur, gaussian, scale;
  70. r = m_this.style('radius');
  71. blur = m_this.style('blurRadius');
  72. gaussian = m_this.style('gaussian');
  73. scale = m_this.style('scaleWithZoom');
  74. if (scale) {
  75. const zoom = this.layer().map().zoom();
  76. scale = Math.pow(2, zoom);
  77. r *= scale;
  78. blur *= scale;
  79. }
  80. if (!m_this._circle || m_this._circle.gaussian !== gaussian ||
  81. m_this._circle.radius !== r || m_this._circle.blurRadius !== blur) {
  82. circle = m_this._circle = document.createElement('canvas');
  83. ctx = circle.getContext('2d');
  84. r2 = blur + r;
  85. circle.width = circle.height = r2 * 2;
  86. if (!gaussian) {
  87. ctx.shadowOffsetX = ctx.shadowOffsetY = r2 * 2;
  88. ctx.shadowBlur = blur;
  89. ctx.shadowColor = 'black';
  90. ctx.beginPath();
  91. ctx.arc(-r2, -r2, r, 0, Math.PI * 2, true);
  92. ctx.closePath();
  93. ctx.fill();
  94. } else {
  95. /* This approximates a gaussian distribution by using a 10-step
  96. * piecewise linear radial gradient. Strictly, it should not stop at
  97. * the radius, but should be attenuated further. The scale has been
  98. * selected such that the values at the radius are around 1/256th of
  99. * the maximum, and therefore would not be visible using an 8-bit alpha
  100. * channel for the summation. The values for opacity were generated by
  101. * the python expression:
  102. * from scipy.stats import norm
  103. * for r in [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1]:
  104. * opacity = norm.pdf(r, scale=0.3) / norm.pdf(0, scale=0.3)
  105. * Using a 10-interval approximation is accurate to within 0.5% of the
  106. * actual Gaussian magnitude. Switching to a 20-interval approximation
  107. * would get within 0.1%, at which point there is more error from using
  108. * a Gaussian truncated at the radius than from the approximation.
  109. */
  110. var grad = ctx.createRadialGradient(r2, r2, 0, r2, r2, r2);
  111. grad.addColorStop(0.0, 'rgba(255,255,255,1)');
  112. grad.addColorStop(0.1, 'rgba(255,255,255,0.946)');
  113. grad.addColorStop(0.2, 'rgba(255,255,255,0.801)');
  114. grad.addColorStop(0.3, 'rgba(255,255,255,0.607)');
  115. grad.addColorStop(0.4, 'rgba(255,255,255,0.411)');
  116. grad.addColorStop(0.5, 'rgba(255,255,255,0.249)');
  117. grad.addColorStop(0.6, 'rgba(255,255,255,0.135)');
  118. grad.addColorStop(0.7, 'rgba(255,255,255,0.066)');
  119. grad.addColorStop(0.8, 'rgba(255,255,255,0.029)');
  120. grad.addColorStop(0.9, 'rgba(255,255,255,0.011)');
  121. grad.addColorStop(1.0, 'rgba(255,255,255,0)');
  122. ctx.fillStyle = grad;
  123. ctx.fillRect(0, 0, r2 * 2, r2 * 2);
  124. }
  125. circle.radius = r;
  126. circle.blurRadius = blur;
  127. circle.gaussian = gaussian;
  128. m_this._circle = circle;
  129. }
  130. return m_this;
  131. };
  132. /**
  133. * Compute color for each pixel on the screen.
  134. *
  135. * @param {Uint8ClampedArray} pixels A 2D canvas `getImageData` buffer.
  136. * @param {Uint8ClampedArray} gradient A 2D canvas with 256 pixels that
  137. * contain a color gradient.
  138. * @protected
  139. */
  140. this._colorize = function (pixels, gradient) {
  141. var grad = new Uint32Array(gradient.buffer),
  142. pixlen = pixels.length,
  143. i, j, k;
  144. if (!m_typedBuffer || m_typedBuffer.length !== pixlen) {
  145. m_typedBuffer = new ArrayBuffer(pixlen);
  146. m_typedClampedBuffer = new Uint8ClampedArray(m_typedBuffer);
  147. m_typedBufferData = new Uint32Array(m_typedBuffer);
  148. }
  149. for (i = 3, k = 0; i < pixlen; i += 4, k += 1) {
  150. // Get opacity from the temporary canvas image and look up the final
  151. // value from gradient
  152. j = pixels[i];
  153. if (j) {
  154. m_typedBufferData[k] = grad[j];
  155. }
  156. }
  157. pixels.set(m_typedClampedBuffer);
  158. };
  159. /**
  160. * Render individual data points on the canvas.
  161. *
  162. * @param {RenderingContext} context2d The canvas context to draw in.
  163. * @param {geo.map} map The parent map object.
  164. * @param {array} data The main data array.
  165. * @param {number} radius The sum of `radius` and `blurRadius`.
  166. */
  167. this._renderPoints = function (context2d, map, data, radius) {
  168. var position = m_this.gcsPosition(),
  169. intensityFunc = m_this.intensity(),
  170. minIntensity = m_this.minIntensity(),
  171. rangeIntensity = (m_this.maxIntensity() - minIntensity) || 1,
  172. idx, pos, intensity;
  173. for (idx = data.length - 1; idx >= 0; idx -= 1) {
  174. pos = map.worldToDisplay(position[idx]);
  175. intensity = (intensityFunc(data[idx], idx) - minIntensity) / rangeIntensity;
  176. if (intensity <= 0) {
  177. continue;
  178. }
  179. // Small values are not visible because globalAlpha < .01
  180. // cannot be read from imageData
  181. context2d.globalAlpha = intensity < 0.01 ? 0.01 : (intensity > 1 ? 1 : intensity);
  182. context2d.drawImage(m_this._circle, pos.x - radius, pos.y - radius);
  183. }
  184. };
  185. /**
  186. * Render data points on the canvas by binning.
  187. *
  188. * @param {RenderingContext} context2d The canvas context to draw in.
  189. * @param {geo.map} map The parent map object.
  190. * @param {array} data The main data array.
  191. * @param {number} radius The sum of `radius` and `blurRadius`.
  192. * @param {number} binSize Size of the bins in pixels.
  193. */
  194. this._renderBinnedData = function (context2d, map, data, radius, binSize) {
  195. var position = m_this.gcsPosition(),
  196. intensityFunc = m_this.intensity(),
  197. minIntensity = m_this.minIntensity(),
  198. rangeIntensity = (m_this.maxIntensity() - minIntensity) || 1,
  199. mapSize = map.size(),
  200. bins = [],
  201. rw = Math.ceil(radius / binSize),
  202. maxx = Math.ceil(mapSize.width / binSize) + rw * 2 + 2,
  203. maxy = Math.ceil(mapSize.height / binSize) + rw * 2 + 2,
  204. datalen = data.length,
  205. idx, pos, intensity, x, y, binrow, offsetx, offsety;
  206. /* We create bins of size (binSize) pixels on a side. We only track bins
  207. * that are on the viewport or within the radius of it, plus one extra bin
  208. * width. */
  209. for (idx = 0; idx < datalen; idx += 1) {
  210. pos = map.worldToDisplay(position[idx]);
  211. /* To make the results look more stable, we use the first data point as a
  212. * hard-reference to where the bins should line up. Otherwise, as we pan
  213. * points would shift which bin they are in and the display would ripple
  214. * oddly. */
  215. if (isNaN(pos.x) || isNaN(pos.y)) {
  216. continue;
  217. }
  218. if (offsetx === undefined) {
  219. offsetx = ((pos.x % binSize) + binSize) % binSize;
  220. offsety = ((pos.y % binSize) + binSize) % binSize;
  221. }
  222. /* We handle points that are in the viewport, plus the radius on either
  223. * side, as they will add into the visual effect, plus one additional bin
  224. * to account for the offset alignment. */
  225. x = Math.floor((pos.x - offsetx) / binSize) + rw + 1;
  226. if (x < 0 || x >= maxx) {
  227. continue;
  228. }
  229. y = Math.floor((pos.y - offsety) / binSize) + rw + 1;
  230. if (y < 0 || y >= maxy) {
  231. continue;
  232. }
  233. intensity = (intensityFunc(data[idx], idx) - minIntensity) / rangeIntensity;
  234. if (intensity <= 0) {
  235. continue;
  236. }
  237. if (intensity > 1) {
  238. intensity = 1;
  239. }
  240. /* bins is an array of arrays. The subarrays would be conceptually
  241. * better represented as an array of dicts, but having a sparse array is
  242. * uses much less memory and is faster. Each bin uses four array entries
  243. * that are (weight, intensity, x, y). The weight is the sum of the
  244. * intensities for all points in the bin. The intensity is the geometric
  245. * sum of the intensities to approximate what happens to the unbinned
  246. * data on the alpha channel of the canvas. The x and y coordinates are
  247. * weighted by the intensity of each point. */
  248. bins[y] = bins[y] || [];
  249. x *= 4;
  250. binrow = bins[y];
  251. if (!binrow[x]) {
  252. binrow[x] = binrow[x + 1] = intensity;
  253. binrow[x + 2] = pos.x * intensity;
  254. binrow[x + 3] = pos.y * intensity;
  255. } else {
  256. binrow[x] += intensity; // weight
  257. binrow[x + 1] += (1 - binrow[x + 1]) * intensity;
  258. binrow[x + 2] += pos.x * intensity;
  259. binrow[x + 3] += pos.y * intensity;
  260. }
  261. }
  262. /* For each bin, render a point on the canvas. */
  263. for (y = bins.length - 1; y >= 0; y -= 1) {
  264. binrow = bins[y];
  265. if (binrow) {
  266. for (x = binrow.length - 4; x >= 0; x -= 4) {
  267. if (binrow[x]) {
  268. intensity = binrow[x + 1];
  269. context2d.globalAlpha = intensity < 0.01 ? 0.01 : (intensity > 1 ? 1 : intensity);
  270. /* The position is eighted by the intensities, so we have to divide
  271. * it to get the necessary position */
  272. context2d.drawImage(
  273. m_this._circle,
  274. binrow[x + 2] / binrow[x] - radius,
  275. binrow[x + 3] / binrow[x] - radius);
  276. }
  277. }
  278. }
  279. }
  280. };
  281. /**
  282. * Render the data on the canvas, then colorize the resulting opacity map.
  283. *
  284. * @param {RenderingContext} context2d The canvas context to draw in.
  285. * @param {geo.map} map The parent map object.
  286. * @returns {this}
  287. */
  288. this._renderOnCanvas = function (context2d, map) {
  289. if (m_renderTime.timestamp() < m_this.buildTime().timestamp()) {
  290. const starttime = Date.now();
  291. var data = m_this.data() || [],
  292. radius = m_this.style('radius') + m_this.style('blurRadius'),
  293. binned = m_this.binned(),
  294. canvas, pixelArray,
  295. layer = m_this.layer(),
  296. mapSize = map.size();
  297. if (m_this.style('scaleWithZoom')) {
  298. radius *= Math.pow(2, map.zoom());
  299. }
  300. /* Determine if we should bin the data */
  301. if (binned === true || binned === 'auto') {
  302. binned = Math.max(Math.floor(radius / 8), Math.max(1.5, Math.min(3, radius / 2.5)));
  303. if (m_this.binned() === 'auto') {
  304. var numbins = (Math.ceil((mapSize.width + radius * 2) / binned) *
  305. Math.ceil((mapSize.height + radius * 2) / binned));
  306. if (numbins >= data.length) {
  307. binned = 0;
  308. }
  309. }
  310. }
  311. if (binned < 1 || isNaN(binned)) {
  312. binned = false;
  313. }
  314. /* Store what we did, in case this is ever useful elsewhere */
  315. m_this._binned = binned;
  316. context2d.setTransform(1, 0, 0, 1, 0, 0);
  317. context2d.clearRect(0, 0, mapSize.width, mapSize.height);
  318. m_heatMapTransform = '';
  319. if (radius > 0.5 && radius < 8192) {
  320. map.scheduleAnimationFrame(m_this._setTransform, false);
  321. layer.canvas().css({transform: ''});
  322. m_this._createCircle();
  323. m_this._computeGradient();
  324. if (!binned) {
  325. m_this._renderPoints(context2d, map, data, radius);
  326. } else {
  327. m_this._renderBinnedData(context2d, map, data, radius, binned);
  328. }
  329. canvas = layer.canvas()[0];
  330. pixelArray = context2d.getImageData(0, 0, canvas.width, canvas.height);
  331. m_this._colorize(pixelArray.data, m_this._grad);
  332. context2d.putImageData(pixelArray, 0, 0);
  333. }
  334. m_heatMapPosition = {
  335. zoom: map.zoom(),
  336. gcsOrigin: map.displayToGcs({x: 0, y: 0}, null),
  337. rotation: map.rotation(),
  338. lastScale: undefined,
  339. lastOrigin: {x: 0, y: 0},
  340. lastRotation: undefined
  341. };
  342. m_renderTime.modified();
  343. layer.renderer().clearCanvas(false);
  344. m_lastRenderDuration = Date.now() - starttime;
  345. }
  346. return m_this;
  347. };
  348. /**
  349. * Initialize.
  350. *
  351. * @returns {this}
  352. */
  353. this._init = function () {
  354. s_init.call(m_this, arg);
  355. m_this.geoOn(geo_event.pan, m_this._animatePan);
  356. return m_this;
  357. };
  358. /**
  359. * Update the feature.
  360. *
  361. * @returns {this}
  362. */
  363. this._update = function () {
  364. s_update.call(m_this);
  365. if (m_this.buildTime().timestamp() <= m_this.dataTime().timestamp() ||
  366. m_this.updateTime().timestamp() < m_this.timestamp()) {
  367. m_this._build();
  368. }
  369. m_this.updateTime().modified();
  370. return m_this;
  371. };
  372. /**
  373. * Update the css transform for the layer as part of an animation frame.
  374. * This allows an existing rendered version of the heatmap to appear with a
  375. * transform until a new version can be computed.
  376. */
  377. this._setTransform = function () {
  378. if (m_this.layer() && m_this.layer().canvas() && m_this.layer().canvas()[0]) {
  379. m_this.layer().canvas()[0].style.transform = m_heatMapTransform;
  380. }
  381. };
  382. /**
  383. * Animate pan and zoom.
  384. */
  385. this._animatePan = function () {
  386. if (!m_heatMapPosition) {
  387. return;
  388. }
  389. var map = m_this.layer().map(),
  390. zoom = map.zoom(),
  391. scale = Math.pow(2, (zoom - m_heatMapPosition.zoom)),
  392. origin = map.gcsToDisplay(m_heatMapPosition.gcsOrigin, null),
  393. rotation = map.rotation();
  394. if (m_heatMapPosition.lastScale === scale &&
  395. m_heatMapPosition.lastOrigin.x === origin.x &&
  396. m_heatMapPosition.lastOrigin.y === origin.y &&
  397. m_heatMapPosition.lastRotation === rotation) {
  398. return;
  399. }
  400. var transform = '' +
  401. ' translate(' + origin.x + 'px' + ',' + origin.y + 'px' + ')' +
  402. ' scale(' + scale + ')' +
  403. ' rotate(' + ((rotation - m_heatMapPosition.rotation) * 180 / Math.PI) + 'deg)';
  404. map.scheduleAnimationFrame(m_this._setTransform);
  405. m_heatMapTransform = transform;
  406. m_heatMapPosition.lastScale = scale;
  407. m_heatMapPosition.lastOrigin.x = origin.x;
  408. m_heatMapPosition.lastOrigin.y = origin.y;
  409. m_heatMapPosition.lastRotation = rotation;
  410. if (m_heatMapPosition.timeout) {
  411. window.clearTimeout(m_heatMapPosition.timeout);
  412. m_heatMapPosition.timeout = undefined;
  413. }
  414. /* This conditional can change if we compute the heatmap beyond the visible
  415. * viewport so that we don't have to update on pans as often. If we are
  416. * close to where the heatmap was originally computed, don't bother
  417. * updating it. */
  418. if (parseFloat(scale.toFixed(4)) !== 1 ||
  419. parseFloat((rotation - m_heatMapPosition.rotation).toFixed(4)) !== 0 ||
  420. parseFloat(origin.x.toFixed(1)) !== 0 ||
  421. parseFloat(origin.y.toFixed(1)) !== 0) {
  422. let delay = m_this.updateDelay();
  423. if (delay < 0 && m_lastRenderDuration) {
  424. delay = m_lastRenderDuration - Math.floor(1000 / 60 * delay);
  425. } else if (m_lastRenderDuration) {
  426. delay = m_lastRenderDuration * 2;
  427. } else {
  428. delay = 100;
  429. }
  430. m_heatMapPosition.timeout = window.setTimeout(function () {
  431. m_heatMapPosition.timeout = undefined;
  432. m_this.buildTime().modified();
  433. m_this.layer().draw();
  434. }, m_this.updateDelay());
  435. }
  436. };
  437. m_this._init(arg);
  438. return this;
  439. };
  440. inherit(canvas_heatmapFeature, heatmapFeature);
  441. // Now register it
  442. registerFeature('canvas', 'heatmap', canvas_heatmapFeature);
  443. module.exports = canvas_heatmapFeature;