From ae41fa22634920db155b7e14e1e0542794104e99 Mon Sep 17 00:00:00 2001 From: David Manthey Date: Mon, 16 May 2016 10:20:20 -0400 Subject: [PATCH] Add an option to have approximate Gaussian points instead of solid points with a blur. --- examples/heatmap/index.jade | 3 ++ examples/heatmap/main.js | 19 ++++++++---- src/canvas/heatmapFeature.js | 57 ++++++++++++++++++++++++++++-------- src/heatmapFeature.js | 20 ++++++++----- tests/cases/heatmap.js | 31 ++++++++++++++++++-- 5 files changed, 102 insertions(+), 28 deletions(-) diff --git a/examples/heatmap/index.jade b/examples/heatmap/index.jade index 4243e2b192..2783934b10 100644 --- a/examples/heatmap/index.jade +++ b/examples/heatmap/index.jade @@ -31,6 +31,9 @@ block append mainContent .form-group(title="Radius of blur around points in pixels.") label(for="blurRadius") Blur Radius input#blurRadius(type="number" placeholder="15" min=0) + .form-group(title="Use either a Gaussian distribution or a solid circle with a blurred edge for each point. If a Guassian is used, the total radius is the sume of the radius and blur radius values.") + label(for="gaussian") Gaussian Points + input#gaussian(type="checkbox", placeholder="true", checked="checked") .form-group(title="Color Gradient. Entries with intensities of 0 and 1 are needed to form a valid color gradient.") label Color Gradient table.gradient diff --git a/examples/heatmap/main.js b/examples/heatmap/main.js index 9a7209af7d..c84a2441a7 100644 --- a/examples/heatmap/main.js +++ b/examples/heatmap/main.js @@ -50,6 +50,10 @@ $(function () { case 'dataset': ctlvalue = value ? value : 'adderall'; break; + case 'gaussian': + ctlvalue = value === 'true'; + heatmapOptions.style[key] = value; + break; case 'gradient': var parts = value.split(',').map(parseFloat); if (parts.length >= 5) { @@ -82,11 +86,6 @@ $(function () { points = ctlvalue = parseInt(value, 10); } break; - case 'updateDelay': - if (value.length) { - heatmapOptions[key] = ctlvalue = parseInt(value, 10); - } - break; case 'radius': case 'blurRadius': if (value.length) { value = parseFloat(value); @@ -95,6 +94,11 @@ $(function () { } } break; + case 'updateDelay': + if (value.length) { + heatmapOptions[key] = ctlvalue = parseInt(value, 10); + } + break; // add gaussian and binning when they are added as features } if (ctlvalue !== undefined) { @@ -216,6 +220,11 @@ $(function () { case 'dataset': fetch_data(); break; + case 'gaussian': + heatmapOptions.style[param] = processedValue; + heatmap.style(param, processedValue); + map.draw(); + break; case 'gradient': var gradient = {}; for (var idx = 1; idx <= 6; idx += 1) { diff --git a/src/canvas/heatmapFeature.js b/src/canvas/heatmapFeature.js index 310376ce89..118585b067 100644 --- a/src/canvas/heatmapFeature.js +++ b/src/canvas/heatmapFeature.js @@ -96,27 +96,58 @@ var canvas_heatmapFeature = function (arg) { */ //////////////////////////////////////////////////////////////////////////// this._createCircle = function () { - var circle, ctx, r, r2, blur; + var circle, ctx, r, r2, blur, gaussian; r = m_this.style('radius'); blur = m_this.style('blurRadius'); - if (!m_this._circle || m_this._circle.radius !== r || - m_this._circle.blurRadius !== blur) { + gaussian = m_this.style('gaussian'); + 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; - 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(); + 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) + * Usng 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; diff --git a/src/heatmapFeature.js b/src/heatmapFeature.js index e40c10fc67..413d19a07a 100644 --- a/src/heatmapFeature.js +++ b/src/heatmapFeature.js @@ -10,13 +10,6 @@ var transform = require('./transform'); * @class * @param {Object} arg Options object * @extends geo.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 {Object|Function} [radius=10] Radius of a point in terms of number - * of pixels. - * @param {Object|Function} [blurRadius=10] Gaussian blur radius for each - * point in terms of number of pixels. * @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. @@ -33,6 +26,18 @@ var transform = require('./transform'); * be computed. * @param {number} [updateDelay=1000] Delay in milliseconds after a zoom, * rotate, or pan event before recomputing the heatmap. + * @param {Object|string|Function} [style.color] Color transfer function that. + * will be used to evaluate color of each pixel using normalized intensity + * as the look up value. + * @param {Object|Function} [style.radius=10] Radius of a point in terms of + * number of pixels. + * @param {Object|Function} [style.blurRadius=10] Blur radius for each point in + * terms of number of pixels. + * @param {boolean} [style.gaussian=true] If true, appoximate a gaussian + * distribution for each point using a multi-segment linear radial + * appoximation. The total weight of the gaussian area is approximately the + * 9/16 r^2. The sum of radius + blurRadius is used as the radius for the + * gaussian distribution. * @returns {geo.heatmapFeature} */ ////////////////////////////////////////////////////////////////////////////// @@ -179,6 +184,7 @@ var heatmapFeature = function (arg) { { radius: 10, blurRadius: 10, + gaussian: true, color: {0: {r: 0, g: 0, b: 0.0, a: 0.0}, 0.25: {r: 0, g: 0, b: 1, a: 0.5}, 0.5: {r: 0, g: 1, b: 1, a: 0.6}, diff --git a/tests/cases/heatmap.js b/tests/cases/heatmap.js index 2070cca082..b29da2ffc3 100644 --- a/tests/cases/heatmap.js +++ b/tests/cases/heatmap.js @@ -36,7 +36,7 @@ describe('canvas heatmap feature', function () { map.resize(0, 0, width, height); }); - it('Add features to a layer', function () { + it('Add feature to a layer', function () { feature1 = layer.createFeature('heatmap') .data(testData) .intensity(function (d) { @@ -55,7 +55,7 @@ describe('canvas heatmap feature', function () { map.draw(); stepAnimationFrame(new Date().getTime()); expect(layer.children().length).toBe(1); - unmockAnimationFrame(); + // leave animation frames mocked for later tests. }); it('Validate selection API option', function () { @@ -103,11 +103,36 @@ describe('canvas heatmap feature', function () { clock.tick(2000); expect(feature1.buildTime().getMTime()).toBe(buildTime); }); + it('radius, blurRadius, and gaussian', function () { + // animation frames are already mocked + expect(feature1._circle.radius).toBe(5); + expect(feature1._circle.blurRadius).toBe(15); + expect(feature1._circle.gaussian).toBe(true); + expect(feature1._circle.width).toBe(40); + expect(feature1._circle.height).toBe(40); + feature1.style('gaussian', false); + map.draw(); + stepAnimationFrame(new Date().getTime()); + expect(feature1._circle.gaussian).toBe(false); + feature1.style('radius', 10); + expect(feature1._circle.radius).toBe(5); + map.draw(); + stepAnimationFrame(new Date().getTime()); + expect(feature1._circle.radius).toBe(10); + expect(feature1._circle.width).toBe(50); + expect(feature1._circle.height).toBe(50); + feature1.style('blurRadius', 0); + map.draw(); + stepAnimationFrame(new Date().getTime()); + expect(feature1._circle.blurRadius).toBe(0); + expect(feature1._circle.width).toBe(20); + expect(feature1._circle.height).toBe(20); + unmockAnimationFrame(); + }); it('Remove a feature from a layer', function () { layer.deleteFeature(feature1).draw(); expect(layer.children().length).toBe(0); }); - }); describe('core.heatmapFeature', function () {