-
Notifications
You must be signed in to change notification settings - Fork 75
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
Changes from 2 commits
1817896
c517afd
9b203d9
5ec0869
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @manthey why +2 for maxx and maxy? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We use an offset based on the first valid data point to prevent the data from shifting from bin to bin (ensuring that each data point will always be the same bin as the same scale). We need to ensure that we calculate any bin that might affect the screen. We have to add the radius to each side (in this case |
||
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); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @manthey could we make it (binned) false by default? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree -- either would be fine as the default. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||
|
@@ -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; | ||
|
||
//////////////////////////////////////////////////////////////////////////// | ||
|
@@ -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 | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit-pick: binned seems confusing as as we used binner in the args which makes me think that it can take boolean values as well. Can we change it binSize or something similar please?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure.