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 bin the data before generating a heatmap #579

Merged
merged 4 commits into from
May 23, 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
12 changes: 12 additions & 0 deletions examples/heatmap/index.jade
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@ block append mainContent
.form-group(title="Delay between movement and heatmap recalculation in milliseconds.")
label(for="updateDelay") Update Delay (ms)
input#updateDelay(type="number" placeholder="50" min=0)
.form-group(title="Binning the data is faster for large sets and slower for small ones. Binning tends to make dense data look somewhat sparser. Smaller bins are closer in appearance to unbinned data but take longer to compute.")
label(for="binned") Bin Data
select#binned(placeholder="auto")
option(value="auto" title="Bin data if there are more points than the number of bins that would be used by default") Auto
option(value="false" title="Do not bin data.") Never
option(value="true" title="Always bin data using the default bin size (1/8th of the total radius).") Always
option(value="3" title="Always bin data using a 3 pixel bin size.") 3 pixel bin size
option(value="5" title="Always bin data using a 5 pixel bin size.") 5 pixel bin size
option(value="10" title="Always bin data using a 10 pixel bin size.") 10 pixel bin size
option(value="15" title="Always bin data using a 15 pixel bin size.") 15 pixel bin size
option(value="20" title="Always bin data using a 20 pixel bin size.") 20 pixel bin size
option(value="25" title="Always bin data using a 25 pixel bin size.") 25 pixel bin size
.form-group(title="Opacity of heatmap layer (0 to 1).")
label(for="opacity") Opacity
input#opacity(type="number" placeholder="0.75" min=0 max=1 step=0.05)
Expand Down
10 changes: 9 additions & 1 deletion examples/heatmap/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ $(function () {
opacity: 0.75
};
var heatmapOptions = {
// binned: 'auto',
binned: 'auto',
minIntensity: null,
maxIntensity: null,
style: {
Expand Down Expand Up @@ -47,6 +47,9 @@ $(function () {
$.each(query, function (key, value) {
var ctlvalue, ctlkey = key;
switch (key) {
case 'binned':
ctlvalue = value ? value : 'auto';
break;
case 'dataset':
ctlvalue = value ? value : 'adderall';
break;
Expand Down Expand Up @@ -207,6 +210,11 @@ $(function () {
param = 'gradient';
}
switch (param) {
case 'binned':
heatmapOptions[param] = value;
heatmap[param](value);
map.draw();
break;
case 'blurRadius': case 'radius':
processedValue = value.length ? parseFloat(value) : undefined;
if (isNaN(processedValue) || processedValue === undefined ||
Expand Down
172 changes: 156 additions & 16 deletions src/canvas/heatmapFeature.js
Original file line number Diff line number Diff line change
Expand Up @@ -181,39 +181,179 @@ var canvas_heatmapFeature = function (arg) {

////////////////////////////////////////////////////////////////////////////
/**
* Render each data point on canvas
* Render individual data points on the canvas.
* @protected
* @param {object} context2d the canvas context to draw in.
* @param {object} 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]) - 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.
* @protected
* @param {object} context2d the canvas context to draw in.
* @param {object} 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,
viewport = map.camera()._viewport,
bins = [],
rw = Math.ceil(radius / binSize),
maxx = Math.ceil(viewport.width / binSize) + rw * 2 + 2,
maxy = Math.ceil(viewport.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]) - 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.
* @protected
* @param {object} context2d the canvas context to draw in.
* @param {object} map the parent map object.
*/
////////////////////////////////////////////////////////////////////////////
this._renderOnCanvas = function (context2d, map) {

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,
binned = m_this.binned(),
canvas, pixelArray,
layer = m_this.layer(),
viewport = map.camera()._viewport;

/* Determine if we should bin the data */
if (binned === true || binned === 'auto') {
binned = Math.max(Math.floor(radius / 8), 3);
if (m_this.binned() === 'auto') {
var numbins = (Math.ceil((viewport.width + radius * 2) / binned) *
Math.ceil((viewport.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, 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);
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);
Expand Down
35 changes: 35 additions & 0 deletions src/heatmapFeature.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ 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 {boolean|number|'auto'} [binned='auto'] If true or a number,
Copy link
Member

Choose a reason for hiding this comment

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

@manthey could we make it (binned) false by default?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes.

I thought auto would be a better default, since based on the rest of our default values, binning probably won't occur until you have ~200,000 data points (depending on screen size), at which point the speed improvement is useful.

Copy link
Member

Choose a reason for hiding this comment

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

Sure. I can see both sides. What happens if a user has dynamic update and let's say their number of points changes from 100,000 to 200,000 in that case now they will see a different behavior. Binning introduces / increases the visualization error (lack of a better word), that's why I thought that it should be false by default but I am with auto as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I agree -- either would be fine as the default. auto definitely introduces a discontinuity in the visualization as the number of points crosses the threshold (resizing the window could also do this). On the other hand, if you have a million data points, binning will be much faster. Your call on which should be the default.

Copy link
Member

Choose a reason for hiding this comment

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

let's go with auto 👍 we can always change it later depending on user's feedback.

* spatially bin data as part of producing the heatpmap. If false, each
* datapoint stands on its own. If 'auto', bin data if there are more data
* points than there would be bins. Using true or auto uses bins that are
* max(Math.floor((radius + blurRadius) / 8), 3).
* @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.
Expand Down Expand Up @@ -62,13 +67,15 @@ var heatmapFeature = function (arg) {
m_maxIntensity,
m_minIntensity,
m_updateDelay,
m_binned,
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 !== undefined ? arg.maxIntensity : null;
m_minIntensity = arg.minIntensity !== undefined ? arg.minIntensity : null;
m_binned = arg.binned !== undefined ? arg.binned : 'auto';
m_updateDelay = arg.updateDelay ? parseInt(arg.updateDelay, 10) : 1000;

////////////////////////////////////////////////////////////////////////////
Expand Down Expand Up @@ -123,6 +130,34 @@ var heatmapFeature = function (arg) {
return m_this;
};

////////////////////////////////////////////////////////////////////////////
/**
* Get/Set binned
*
* @returns {geo.heatmap}
*/
////////////////////////////////////////////////////////////////////////////
this.binned = function (val) {
if (val === undefined) {
return m_binned;
} else {
if (val === 'true') {
val = true;
} else if (val === 'false') {
val = false;
} else if (val !== 'auto' && val !== true && val !== false) {
val = parseInt(val, 10);
if (val <= 0 || isNaN(val)) {
val = false;
}
}
m_binned = val;
m_this.dataTime().modified();
m_this.modified();
}
return m_this;
};

////////////////////////////////////////////////////////////////////////////
/**
* Get/Set position accessor
Expand Down
67 changes: 66 additions & 1 deletion tests/cases/heatmap.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,11 +127,56 @@ describe('canvas heatmap feature', function () {
expect(feature1._circle.blurRadius).toBe(0);
expect(feature1._circle.width).toBe(20);
expect(feature1._circle.height).toBe(20);
unmockAnimationFrame();
});
it('binned', function () {
// animation frames are already mocked
// ensure there is some data that will be off the map when we zoom in
var viewport = map.camera()._viewport;
var r = 80,
data = [[1, 80, 0], [1, 0, 180]],
numpoints = ((viewport.width + r * 2) / (r / 8) *
(viewport.height + r * 2) / (r / 8)),
idx;
feature1.style({radius: r, blurRadius: 0});
map.draw();
stepAnimationFrame(new Date().getTime());
expect(feature1._binned).toBe(false);
feature1.binned(true);
map.draw();
stepAnimationFrame(new Date().getTime());
expect(feature1._binned).toBe(r / 8);
feature1.binned(2);
map.draw();
stepAnimationFrame(new Date().getTime());
expect(feature1._binned).toBe(2);
feature1.binned(20);
map.draw();
stepAnimationFrame(new Date().getTime());
expect(feature1._binned).toBe(20);
for (idx = data.length; idx < numpoints + 1; idx += 1) {
data.push([Math.random(), (Math.random() - 0.5) * 190, (
Math.random() - 0.5) * 360]);
}
feature1.data(data);
feature1.binned('auto');
map.draw();
stepAnimationFrame(new Date().getTime());
expect(feature1._binned).toBe(r / 8);
data.splice(numpoints);
feature1.data(data);
map.draw();
stepAnimationFrame(new Date().getTime());
expect(feature1._binned).toBe(false);
feature1.binned(true);
map.zoom(10);
stepAnimationFrame(new Date().getTime());
expect(feature1._binned).toBe(r / 8);
});
it('Remove a feature from a layer', function () {
layer.deleteFeature(feature1).draw();
expect(layer.children().length).toBe(0);
// stop mocking animation frames
unmockAnimationFrame();
});
});

Expand Down Expand Up @@ -173,6 +218,26 @@ describe('core.heatmapFeature', function () {
heatmap = heatmapFeature({layer: layer, updateDelay: 50});
expect(heatmap.updateDelay()).toBe(50);
});
it('binned', function () {
var heatmap = heatmapFeature({layer: layer});
expect(heatmap.binned()).toBe('auto');
expect(heatmap.binned(true)).toBe(heatmap);
expect(heatmap.binned()).toBe(true);
heatmap = heatmapFeature({layer: layer, binned: 5});
expect(heatmap.binned()).toBe(5);
heatmap.binned('true');
expect(heatmap.binned()).toBe(true);
heatmap.binned('false');
expect(heatmap.binned()).toBe(false);
heatmap.binned('auto');
expect(heatmap.binned()).toBe('auto');
heatmap.binned(5.3);
expect(heatmap.binned()).toBe(5);
heatmap.binned(-3);
expect(heatmap.binned()).toBe(false);
heatmap.binned('not a number');
expect(heatmap.binned()).toBe(false);
});
it('position', function () {
var heatmap = heatmapFeature({layer: layer});
expect(heatmap.position()('abc')).toBe('abc');
Expand Down