Skip to content

Commit

Permalink
Add an option to bin the data before generating a heatmap.
Browse files Browse the repository at this point in the history
This is faster for large data sets, as fewer images are drawn to the canvas.  It is slower for small data sets, as the binning takes some time.  The default setting 'auto' will only bin if there are fewer bins than data points.  The bin size may be auto-calculated (1/8 of the point radius) or set explicitly.  Setting it explicitly to larger values will be faster but more and more wrong.
  • Loading branch information
manthey committed May 16, 2016
1 parent ae41fa2 commit 1817896
Show file tree
Hide file tree
Showing 5 changed files with 275 additions and 17 deletions.
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
8 changes: 8 additions & 0 deletions examples/heatmap/main.js
Original file line number Diff line number Diff line change
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} binned size of the bins in pixels.
*/
////////////////////////////////////////////////////////////////////////////
this._renderBinnedData = function (context2d, map, data, radius, binned) {
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 / binned),
maxx = Math.ceil(viewport.width / binned) + rw * 2 + 2,
maxy = Math.ceil(viewport.height / binned) + rw * 2 + 2,
datalen = data.length,
idx, pos, intensity, x, y, binrow, offsetx, offsety;

/* We create bins of size (binned) 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 % binned) + binned) % binned;
offsety = ((pos.y % binned) + binned) % binned;
}
/* 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) / binned) + rw + 1;
if (x < 0 || x >= maxx) {
continue;
}
y = Math.floor((pos.y - offsety) / binned) + 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,
* 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
65 changes: 64 additions & 1 deletion tests/cases/heatmap.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,11 +127,54 @@ 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 r = 80,
data = [[1, 80, 0], [1, 0, 180]],
numpoints = (800 + r * 2) / (r / 8) * (600 + 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 +216,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

0 comments on commit 1817896

Please sign in to comment.