Skip to content

Commit

Permalink
Merge pull request #579 from OpenGeoscience/heatmap-binning
Browse files Browse the repository at this point in the history
Add an option to bin the data before generating a heatmap
  • Loading branch information
manthey committed May 23, 2016
2 parents e4599eb + 5ec0869 commit 06f2f01
Show file tree
Hide file tree
Showing 5 changed files with 278 additions and 18 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
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,
* 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

0 comments on commit 06f2f01

Please sign in to comment.