diff --git a/examples/heatmap/index.jade b/examples/heatmap/index.jade index 2783934b10..bf404f0a14 100644 --- a/examples/heatmap/index.jade +++ b/examples/heatmap/index.jade @@ -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) diff --git a/examples/heatmap/main.js b/examples/heatmap/main.js index c84a2441a7..fb0f376082 100644 --- a/examples/heatmap/main.js +++ b/examples/heatmap/main.js @@ -19,7 +19,7 @@ $(function () { opacity: 0.75 }; var heatmapOptions = { - // binned: 'auto', + binned: 'auto', minIntensity: null, maxIntensity: null, style: { @@ -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; @@ -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 || diff --git a/src/canvas/heatmapFeature.js b/src/canvas/heatmapFeature.js index 118585b067..40f096cc59 100644 --- a/src/canvas/heatmapFeature.js +++ b/src/canvas/heatmapFeature.js @@ -181,8 +181,140 @@ 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) { @@ -190,30 +322,38 @@ var canvas_heatmapFeature = function (arg) { 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); diff --git a/src/heatmapFeature.js b/src/heatmapFeature.js index 413d19a07a..8b13675cef 100644 --- a/src/heatmapFeature.js +++ b/src/heatmapFeature.js @@ -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. @@ -62,6 +67,7 @@ var heatmapFeature = function (arg) { m_maxIntensity, m_minIntensity, m_updateDelay, + m_binned, m_gcsPosition, s_init = this._init; @@ -69,6 +75,7 @@ var heatmapFeature = function (arg) { 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 diff --git a/tests/cases/heatmap.js b/tests/cases/heatmap.js index b29da2ffc3..5ac67a4858 100644 --- a/tests/cases/heatmap.js +++ b/tests/cases/heatmap.js @@ -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(); }); }); @@ -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');