Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add an option to have approximate Gaussian points instead of solid points with a blur. #578

Merged
merged 2 commits into from
May 18, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions examples/heatmap/index.jade
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 14 additions & 5 deletions examples/heatmap/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
57 changes: 44 additions & 13 deletions src/canvas/heatmapFeature.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@manthey why apply scale to it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@aashish24, so that it has a sensible radius and fall-off. By using a scale of 0.3, the intensity at radius=1 is approximately 1/256th of the central intensity. This means that all of the pixels in the rendered circle have some impact on the display. If we use a a larger number (such as the default), the circle will still have significant magnitude at the edges of our rendered area. If we use a smaller number, then we won't use the whole area of our circle.

Simplistically, instead of using a scale, we could have scaled the radius. The mathematics are equivalent (we could have used norm.pdf(r/0.3) / norm.pdf(0)).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the detailed explanation and comment.

* 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;
Expand Down
20 changes: 13 additions & 7 deletions src/heatmapFeature.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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}
*/
//////////////////////////////////////////////////////////////////////////////
Expand Down Expand Up @@ -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},
Expand Down
31 changes: 28 additions & 3 deletions tests/cases/heatmap.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 () {
Expand Down Expand Up @@ -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 () {
Expand Down