diff --git a/src/canvas/canvasRenderer.js b/src/canvas/canvasRenderer.js index c24338799e..2d0e8090ba 100644 --- a/src/canvas/canvasRenderer.js +++ b/src/canvas/canvasRenderer.js @@ -25,9 +25,14 @@ var canvasRenderer = function (arg) { var m_this = this, m_renderAnimFrameRef = null, + m_clearCanvas = true, s_init = this._init, s_exit = this._exit; + this.clearCanvas = function (arg) { + m_clearCanvas = arg; + }; + //////////////////////////////////////////////////////////////////////////// /** * Get API used by the renderer @@ -91,9 +96,12 @@ var canvasRenderer = function (arg) { map = layer.map(), camera = map.camera(), viewport = camera._viewport; + // Clear the canvas. - m_this.context2d.setTransform(1, 0, 0, 1, 0, 0); - m_this.context2d.clearRect(0, 0, viewport.width, viewport.height); + if (m_clearCanvas) { + m_this.context2d.setTransform(1, 0, 0, 1, 0, 0); + m_this.context2d.clearRect(0, 0, viewport.width, viewport.height); + } var features = layer.features(); for (var i = 0; i < features.length; i += 1) { diff --git a/src/canvas/heatmapFeature.js b/src/canvas/heatmapFeature.js index 54fc3476f9..db80c6ef49 100644 --- a/src/canvas/heatmapFeature.js +++ b/src/canvas/heatmapFeature.js @@ -1,6 +1,7 @@ var inherit = require('../inherit'); var registerFeature = require('../registry').registerFeature; var heatmapFeature = require('../heatmapFeature'); +var timestamp = require('../timestamp'); ////////////////////////////////////////////////////////////////////////////// /** @@ -27,10 +28,17 @@ var canvas_heatmapFeature = function (arg) { * @private */ //////////////////////////////////////////////////////////////////////////// + var geo_event = require('../event'); + var m_this = this, + m_typedBuffer, + m_typedClampedBuffer, + m_typedBufferData, + m_heatMapPosition, s_exit = this._exit, s_init = this._init, - s_update = this._update; + s_update = this._update, + m_renderTime = timestamp(); //////////////////////////////////////////////////////////////////////////// /** @@ -117,18 +125,23 @@ var canvas_heatmapFeature = function (arg) { */ //////////////////////////////////////////////////////////////////////////// this._colorize = function (pixels, gradient) { - var i, j; - for (i = 0; i < pixels.length; i += 4) { - // Get opacity from the temporary canvas image, - // then multiply by 4 to get the color index on linear gradient - j = pixels[i + 3] * 4; + 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) { - pixels[i] = gradient[j]; - pixels[i + 1] = gradient[j + 1]; - pixels[i + 2] = gradient[j + 2]; - pixels[i + 3] = m_this.style('opacity') * gradient[j + 3]; + m_typedBufferData[k] = grad[j]; } } + pixels.set(m_typedClampedBuffer); }; //////////////////////////////////////////////////////////////////////////// @@ -138,24 +151,52 @@ var canvas_heatmapFeature = function (arg) { */ //////////////////////////////////////////////////////////////////////////// this._renderOnCanvas = function (context2d, map) { - var data = m_this.data() || [], - radius = m_this.style('radius') + m_this.style('blurRadius'), - pos, intensity, canvas, pixelArray; - m_this._createCircle(); - m_this._computeGradient(); - data.forEach(function (d) { - pos = m_this.layer().map().gcsToDisplay(m_this.position()(d)); - intensity = (m_this.intensity()(d) - m_this.minIntensity()) / - (m_this.maxIntensity() - m_this.minIntensity()); - // Small values are not visible because globalAlpha < .01 - // cannot be read from imageData - context2d.globalAlpha = intensity < 0.01 ? 0.01 : intensity; - context2d.drawImage(m_this._circle, pos.x - radius, pos.y - radius); - }); - canvas = m_this.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); + + if (m_renderTime.getMTime() < m_this.buildTime().getMTime()) { + var data = m_this.data() || [], + radius = m_this.style('radius') + m_this.style('blurRadius'), + pos, intensity, canvas, pixelArray, + layer = m_this.layer(), + viewport = map.camera()._viewport; + + context2d.setTransform(1, 0, 0, 1, 0, 0); + context2d.clearRect(0, 0, viewport.width, viewport.height); + layer.canvas().css({transform: '', 'transform-origin': '0px 0px'}); + + m_this._createCircle(); + m_this._computeGradient(); + var position = m_this.gcsPosition(), + intensityFunc = m_this.intensity(), + minIntensity = m_this.minIntensity(), + rangeIntensity = (m_this.maxIntensity() - minIntensity) || 1; + for (var idx = data.length - 1; idx >= 0; idx -= 1) { + pos = map.worldToDisplay(position[idx]); + intensity = (intensityFunc(data[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); + } + 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); + } + return m_this; }; @@ -167,6 +208,9 @@ var canvas_heatmapFeature = function (arg) { //////////////////////////////////////////////////////////////////////////// this._init = function () { s_init.call(m_this, arg); + + m_this.geoOn(geo_event.pan, m_this._animatePan); + return m_this; }; @@ -186,6 +230,59 @@ var canvas_heatmapFeature = function (arg) { return m_this; }; + //////////////////////////////////////////////////////////////////////////// + /** + * Animate pan (and zoom) + * @protected + */ + //////////////////////////////////////////////////////////////////////////// + this._animatePan = function (e) { + + 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)'; + + m_this.layer().canvas()[0].style.transform = 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 visable + * 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) { + m_heatMapPosition.timeout = window.setTimeout(function () { + m_heatMapPosition.timeout = undefined; + m_this.buildTime().modified(); + m_this.layer().draw(); + }, m_this.updateDelay()); + } + }; + //////////////////////////////////////////////////////////////////////////// /** * Destroy diff --git a/src/heatmapFeature.js b/src/heatmapFeature.js index 3c1f17e470..e40c10fc67 100644 --- a/src/heatmapFeature.js +++ b/src/heatmapFeature.js @@ -1,6 +1,7 @@ var $ = require('jquery'); var inherit = require('./inherit'); var feature = require('./feature'); +var transform = require('./transform'); ////////////////////////////////////////////////////////////////////////////// /** @@ -12,7 +13,6 @@ var feature = require('./feature'); * @param {Object|string|Function} [color] Color transfer function that. * will be used to evaluate color of each pixel using normalized intensity * as the look up value. - * @param {number|Function} [opacity=1] Homogeneous opacity for each pixel. * @param {Object|Function} [radius=10] Radius of a point in terms of number * of pixels. * @param {Object|Function} [blurRadius=10] Gaussian blur radius for each @@ -20,17 +20,19 @@ var feature = require('./feature'); * @param {Object|Function} [position] Position of the data. Default is * (data). The position is an Object which specifies the location of the * data in geo-spatial context. - * @param {boolean} [intensity] Scalar value of each data point. Scalar + * @param {Object|Function} [intensity] Scalar value of each data point. Scalar * value must be a positive real number and will be used to compute * the weight for each data point. - * @param {boolean} [maxIntensity=null] Maximum intensity of the data. Maximum + * @param {number} [maxIntensity=null] Maximum intensity of the data. Maximum * intensity must be a positive real number and will be used to normalize all * intensities with a dataset. If no value is given, then a it will * be computed. - * @param {boolean} [minIntensity=null] Minimum intensity of the data. Minimum + * @param {number} [minIntensity=null] Minimum intensity of the data. Minimum * intensity must be a positive real number will be used to normalize all * intensities with a dataset. If no value is given, then a it will * be computed. + * @param {number} [updateDelay=1000] Delay in milliseconds after a zoom, + * rotate, or pan event before recomputing the heatmap. * @returns {geo.heatmapFeature} */ ////////////////////////////////////////////////////////////////////////////// @@ -54,12 +56,15 @@ var heatmapFeature = function (arg) { m_intensity, m_maxIntensity, m_minIntensity, + m_updateDelay, + m_gcsPosition, s_init = this._init; m_position = arg.position || function (d) { return d; }; m_intensity = arg.intensity || function (d) { return 1; }; - m_maxIntensity = arg.maxIntensity || null; - m_minIntensity = arg.minIntensity ? arg.minIntensity : null; + m_maxIntensity = arg.maxIntensity !== undefined ? arg.maxIntensity : null; + m_minIntensity = arg.minIntensity !== undefined ? arg.minIntensity : null; + m_updateDelay = arg.updateDelay ? parseInt(arg.updateDelay, 10) : 1000; //////////////////////////////////////////////////////////////////////////// /** @@ -97,6 +102,22 @@ var heatmapFeature = function (arg) { return m_this; }; + //////////////////////////////////////////////////////////////////////////// + /** + * Get/Set updateDelay + * + * @returns {geo.heatmap} + */ + //////////////////////////////////////////////////////////////////////////// + this.updateDelay = function (val) { + if (val === undefined) { + return m_updateDelay; + } else { + m_updateDelay = parseInt(val, 10); + } + return m_this; + }; + //////////////////////////////////////////////////////////////////////////// /** * Get/Set position accessor @@ -115,6 +136,18 @@ var heatmapFeature = function (arg) { return m_this; }; + //////////////////////////////////////////////////////////////////////////// + /** + * Get pre-computed gcs position accessor + * + * @returns {geo.heatmap} + */ + //////////////////////////////////////////////////////////////////////////// + this.gcsPosition = function () { + this._update(); + return m_gcsPosition; + }; + //////////////////////////////////////////////////////////////////////////// /** * Get/Set intensity @@ -144,7 +177,6 @@ var heatmapFeature = function (arg) { var defaultStyle = $.extend( {}, { - opacity: 0.1, radius: 10, blurRadius: 10, color: {0: {r: 0, g: 0, b: 0.0, a: 0.0}, @@ -171,23 +203,35 @@ var heatmapFeature = function (arg) { //////////////////////////////////////////////////////////////////////////// this._build = function () { var data = m_this.data(), - intensity = null; - - if (!m_maxIntensity || !m_minIntensity) { - data.forEach(function (d) { + intensity = null, + position = [], + setMax = (m_maxIntensity === null || m_maxIntensity === undefined), + setMin = (m_minIntensity === null || m_minIntensity === undefined); + + data.forEach(function (d) { + position.push(m_this.position()(d)); + if (setMax || setMin) { intensity = m_this.intensity()(d); - if (!m_maxIntensity && !m_minIntensity) { - m_maxIntensity = m_minIntensity = intensity; - } else { - if (intensity > m_maxIntensity) { - m_maxIntensity = intensity; - } - if (intensity < m_minIntensity) { - m_minIntensity = intensity; - } + if (m_maxIntensity === null || m_maxIntensity === undefined) { + m_maxIntensity = intensity; + } + if (m_minIntensity === null || m_minIntensity === undefined) { + m_minIntensity = intensity; } - }); + if (setMax && intensity > m_maxIntensity) { + m_maxIntensity = intensity; + } + if (setMin && intensity < m_minIntensity) { + m_minIntensity = intensity; + } + + } + }); + if (setMin && setMax && m_minIntensity === m_maxIntensity) { + m_minIntensity -= 1; } + m_gcsPosition = transform.transformCoordinates( + m_this.gcs(), m_this.layer().map().gcs(), position); m_this.buildTime().modified(); return m_this; diff --git a/src/transform.js b/src/transform.js index 0aa0e30352..e32f7021c1 100644 --- a/src/transform.js +++ b/src/transform.js @@ -414,6 +414,9 @@ transform.transformCoordinatesArray = function (trans, coordinates, numberOfComp output.length = coordinates.length; count = coordinates.length; + if (!coordinates.length) { + return output; + } if (coordinates[0] instanceof Array || coordinates[0] instanceof Object) { offset = 1; diff --git a/tests/cases/heatmap.js b/tests/cases/heatmap.js index 87cbf99ef1..2070cca082 100644 --- a/tests/cases/heatmap.js +++ b/tests/cases/heatmap.js @@ -22,6 +22,13 @@ describe('canvas heatmap feature', function () { testData = [[0.6, 42.8584, -70.9301], [0.233, 42.2776, -83.7409], [0.2, 42.2776, -83.7409]]; + var clock; + beforeEach(function () { + clock = sinon.useFakeTimers(); + }); + afterEach(function () { + clock.restore(); + }); it('Setup map', function () { map = geo.map({node: '#map-canvas-heatmap-feature', center: [0, 0], zoom: 3}); @@ -42,8 +49,7 @@ describe('canvas heatmap feature', function () { }; }) .style('radius', 5) - .style('blurRadius', 15) - .style('opacity', 1.0); + .style('blurRadius', 15); mockAnimationFrame(); map.draw(); @@ -69,11 +75,6 @@ describe('canvas heatmap feature', function () { expect(feature1.minIntensity()).toBe(0.2); }); - it('Remove a feature from a layer', function () { - layer.deleteFeature(feature1).draw(); - expect(layer.children().length).toBe(0); - }); - it('Compute gradient', function () { feature1.style('color', {0: {r: 0, g: 0, b: 0.0, a: 0.0}, 0.25: {r: 0, g: 0, b: 1, a: 0.5}, @@ -84,4 +85,126 @@ describe('canvas heatmap feature', function () { expect(layer.node()[0].children[0].getContext('2d') .getImageData(1, 0, 1, 1).data.length).toBe(4); }); + it('_animatePan', function () { + map.draw(); + var buildTime = feature1.buildTime().getMTime(); + map.pan({x: 10, y: 0}); + expect(feature1.buildTime().getMTime()).toBe(buildTime); + clock.tick(800); + map.pan({x: 10, y: 0}); + expect(feature1.buildTime().getMTime()).toBe(buildTime); + clock.tick(800); + expect(feature1.buildTime().getMTime()).toBe(buildTime); + clock.tick(800); + expect(feature1.buildTime().getMTime()).not.toBe(buildTime); + buildTime = feature1.buildTime().getMTime(); + map.pan({x: 0, y: 0}); + expect(feature1.buildTime().getMTime()).toBe(buildTime); + clock.tick(2000); + expect(feature1.buildTime().getMTime()).toBe(buildTime); + }); + it('Remove a feature from a layer', function () { + layer.deleteFeature(feature1).draw(); + expect(layer.children().length).toBe(0); + }); + +}); + +describe('core.heatmapFeature', function () { + var map, layer; + var heatmapFeature = require('../../src/heatmapFeature'); + var data = []; + + it('Setup map', function () { + map = geo.map({node: '#map-canvas-heatmap-feature', center: [0, 0], zoom: 3}); + layer = map.createLayer('feature', {'renderer': 'canvas'}); + for (var i = 0; i < 100; i += 1) { + data.push({a: i % 10, b: i % 9, c: i % 8}); + } + }); + + describe('class accessors', function () { + it('maxIntensity', function () { + var heatmap = heatmapFeature({layer: layer}); + expect(heatmap.maxIntensity()).toBe(null); + expect(heatmap.maxIntensity(7)).toBe(heatmap); + expect(heatmap.maxIntensity()).toBe(7); + heatmap = heatmapFeature({layer: layer, maxIntensity: 8}); + expect(heatmap.maxIntensity()).toBe(8); + }); + it('minIntensity', function () { + var heatmap = heatmapFeature({layer: layer}); + expect(heatmap.minIntensity()).toBe(null); + expect(heatmap.minIntensity(2)).toBe(heatmap); + expect(heatmap.minIntensity()).toBe(2); + heatmap = heatmapFeature({layer: layer, minIntensity: 3}); + expect(heatmap.minIntensity()).toBe(3); + }); + it('updateDelay', function () { + var heatmap = heatmapFeature({layer: layer}); + expect(heatmap.updateDelay()).toBe(1000); + expect(heatmap.updateDelay(40)).toBe(heatmap); + expect(heatmap.updateDelay()).toBe(40); + heatmap = heatmapFeature({layer: layer, updateDelay: 50}); + expect(heatmap.updateDelay()).toBe(50); + }); + it('position', function () { + var heatmap = heatmapFeature({layer: layer}); + expect(heatmap.position()('abc')).toBe('abc'); + expect(heatmap.position(function (d) { + return {x: d.a, y: d.b}; + })).toBe(heatmap); + expect(heatmap.position()(data[0])).toEqual({x: 0, y: 0}); + expect(heatmap.position()(data[84])).toEqual({x: 4, y: 3}); + heatmap = heatmapFeature({layer: layer, position: function (d) { + return {x: d.b, y: d.c}; + }}); + expect(heatmap.position()(data[0])).toEqual({x: 0, y: 0}); + expect(heatmap.position()(data[87])).toEqual({x: 6, y: 7}); + }); + it('intensity', function () { + var heatmap = heatmapFeature({layer: layer}); + expect(heatmap.intensity()('abc')).toBe(1); + expect(heatmap.intensity(function (d) { + return d.c; + })).toBe(heatmap); + expect(heatmap.intensity()(data[0])).toEqual(0); + expect(heatmap.intensity()(data[67])).toEqual(3); + heatmap = heatmapFeature({layer: layer, intensity: function (d) { + return d.a; + }}); + expect(heatmap.intensity()(data[0])).toEqual(0); + expect(heatmap.intensity()(data[67])).toEqual(7); + }); + }); + describe('_build', function () { + it('intensity ranges', function () { + var heatmap = heatmapFeature({layer: layer, position: function (d) { + return {x: d.a, y: d.b}; + }, intensity: function (d) { + return d.c; + }}).data(data); + heatmap.gcs('EPSG:3857'); + heatmap._build(); + expect(heatmap.minIntensity()).toBe(0); + expect(heatmap.maxIntensity()).toBe(7); + heatmap.intensity(function () { return 2; }); + heatmap.maxIntensity(null).minIntensity(null); + heatmap._build(); + expect(heatmap.minIntensity()).toBe(1); + expect(heatmap.maxIntensity()).toBe(2); + }); + it('gcsPosition', function () { + var heatmap = heatmapFeature({layer: layer, position: function (d) { + return {x: d.a, y: d.b}; + }}).data(data); + heatmap.gcs('EPSG:3857'); + // we have to call build since we didn't attach this to the layer in the + // normal way + heatmap._build(); + var pos = heatmap.gcsPosition(); + expect(pos[0]).toEqual({x: 0, y: 0}); + expect(pos[84]).toEqual({x: 4, y: 3}); + }); + }); }); diff --git a/tests/cases/transform.js b/tests/cases/transform.js index e75348b73b..5f499b6a4e 100644 --- a/tests/cases/transform.js +++ b/tests/cases/transform.js @@ -230,6 +230,11 @@ describe('geo.transform', function () { expect(closeToEqual(geo.transform.transformCoordinates(source, target, {x: 1, y: 2}), {x: 1, y: -2})).toBe(true); expect(closeToEqual(geo.transform.transformCoordinates(source, target, {x: 3, y: 4, z: 5}), {x: 3, y: -4, z: 5})).toBe(true); }); + it('empty array', function () { + var res = geo.transform.transformCoordinates(source, target, []); + expect(res instanceof Array).toBe(true); + expect(res.length).toBe(0); + }); it('coordinate format - array with single object', function () { var res; res = geo.transform.transformCoordinates(source, target, [{x: 1, y: 2}]);