diff --git a/examples/heatmap/example.json b/examples/heatmap/example.json new file mode 100644 index 0000000000..e30d0064ef --- /dev/null +++ b/examples/heatmap/example.json @@ -0,0 +1,9 @@ +{ + "path": "heatmap", + "title": "Heatmap Feature", + "exampleCss": ["main.css"], + "exampleJs": ["main.js"], + "about": { + "text": "This example shows how to add a heatmap to a map." + } +} diff --git a/examples/heatmap/index.jade b/examples/heatmap/index.jade new file mode 100644 index 0000000000..a2432ab791 --- /dev/null +++ b/examples/heatmap/index.jade @@ -0,0 +1 @@ +extends ../common/templates/index.jade diff --git a/examples/heatmap/main.css b/examples/heatmap/main.css new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/heatmap/main.js b/examples/heatmap/main.js new file mode 100644 index 0000000000..8b5cd5945a --- /dev/null +++ b/examples/heatmap/main.js @@ -0,0 +1,49 @@ +// Run after the DOM loads +$(function () { + 'use strict'; + + var map = geo.map({ + node: '#map', + center: { + x: -98, + y: 39 + }, + zoom: 3 + }); + + $.ajax('https://s3.amazonaws.com/uploads.hipchat.com/446632/3114847/4dZfl0YfZpTfYzq/AdderallCities2015.csv', { + success: function (resp) { + var rows = resp.split(/\r\n|\n|\r/); + rows = rows.map( function (r) { + var fields = r.split(','); + return [fields[12], fields[24], fields[25]].map(parseFloat); + }); + rows.splice(0, 1); + + var layer = map.createLayer('feature', {renderer: 'canvas'}); + var heatmap = layer.createFeature('heatmap') + .data(rows) + .intensity(function (d) { + return d[0]; + }) + .position(function (d) { + return { + x: d[2], + y: d[1] + }; + }) + .style('radius', 10) + .style('blurRadius', 30) + .style('opacity', 1.0) + .style('color', + {0: {r: 0, g: 0, b: 0, a: 0.0}, + 0.25: {r: 0, g: 1, b: 0, a: 0.5}, + 0.5: {r: 1, g: 1, b: 0, a: 0.8}, + 1: {r: 1, g: 0, b: 0, a: 1.0}}); + map.draw(); + } + }); + + var base = map.createLayer('osm'); + map.draw(); +}); diff --git a/examples/heatmap/thumb.jpg b/examples/heatmap/thumb.jpg new file mode 100644 index 0000000000..be1d1ed54f Binary files /dev/null and b/examples/heatmap/thumb.jpg differ diff --git a/sources.json b/sources.json index fced1bb789..1cc5d38eec 100644 --- a/sources.json +++ b/sources.json @@ -41,7 +41,8 @@ "renderer.js", "osmLayer.js", "domRenderer.js", - "choroplethFeature.js" + "choroplethFeature.js", + "heatmapFeature.js" ] }, "geo.util": { @@ -77,7 +78,8 @@ "init.js", "quadFeature.js", "canvasRenderer.js", - "tileLayer.js" + "tileLayer.js", + "heatmapFeature.js" ] }, "geo.d3": { diff --git a/src/canvas/heatmapFeature.js b/src/canvas/heatmapFeature.js new file mode 100644 index 0000000000..54fc3476f9 --- /dev/null +++ b/src/canvas/heatmapFeature.js @@ -0,0 +1,207 @@ +var inherit = require('../inherit'); +var registerFeature = require('../registry').registerFeature; +var heatmapFeature = require('../heatmapFeature'); + +////////////////////////////////////////////////////////////////////////////// +/** + * Create a new instance of class heatmapFeature + * Inspired from + * https://github.com/mourner/simpleheat/blob/gh-pages/simpleheat.js + * + * @class geo.canvas.heatmapFeature + * @param {Object} arg Options object + * @extends geo.heatmapFeature + * @returns {canvas_heatmapFeature} + */ +////////////////////////////////////////////////////////////////////////////// +var canvas_heatmapFeature = function (arg) { + 'use strict'; + + if (!(this instanceof canvas_heatmapFeature)) { + return new canvas_heatmapFeature(arg); + } + heatmapFeature.call(this, arg); + + //////////////////////////////////////////////////////////////////////////// + /** + * @private + */ + //////////////////////////////////////////////////////////////////////////// + var m_this = this, + s_exit = this._exit, + s_init = this._init, + s_update = this._update; + + //////////////////////////////////////////////////////////////////////////// + /** + * Meta functions for converting from geojs styles to canvas. + * @private + */ + //////////////////////////////////////////////////////////////////////////// + this._convertColor = function (c) { + var color; + if (c.hasOwnProperty('r') && + c.hasOwnProperty('g') && + c.hasOwnProperty('b') && + c.hasOwnProperty('a')) { + color = 'rgba(' + 255 * c.r + ',' + 255 * c.g + ',' + + 255 * c.b + ',' + c.a + ')'; + } + return color; + }; + + //////////////////////////////////////////////////////////////////////////// + /** + * Compute gradient (color lookup table) + * @protected + */ + //////////////////////////////////////////////////////////////////////////// + this._computeGradient = function () { + var canvas, stop, context2d, gradient, colors; + + if (!m_this._grad) { + canvas = document.createElement('canvas'); + context2d = canvas.getContext('2d'); + gradient = context2d.createLinearGradient(0, 0, 0, 256); + colors = m_this.style('color'); + + canvas.width = 1; + canvas.height = 256; + + for (stop in colors) { + gradient.addColorStop(stop, m_this._convertColor(colors[stop])); + } + + context2d.fillStyle = gradient; + context2d.fillRect(0, 0, 1, 256); + m_this._grad = context2d.getImageData(0, 0, 1, 256).data; + } + + return m_this; + }; + + //////////////////////////////////////////////////////////////////////////// + /** + * Create circle for each data point + * @protected + */ + //////////////////////////////////////////////////////////////////////////// + this._createCircle = function () { + var circle, ctx, r, r2, blur; + if (!m_this._circle) { + circle = m_this._circle = document.createElement('canvas'); + ctx = circle.getContext('2d'); + r = m_this.style('radius'); + blur = m_this.style('blurRadius'); + + r2 = blur + r; + + circle.width = circle.height = r2 * 2; + ctx.shadowOffsetX = ctx.shadowOffsetY = r2 * 2; + ctx.shadowBlur = blur; + ctx.shadowColor = 'black'; + + ctx.beginPath(); + ctx.arc(-r2, -r2, r, 0, Math.PI * 2, true); + ctx.closePath(); + ctx.fill(); + m_this._circle = circle; + } + return m_this; + }; + + //////////////////////////////////////////////////////////////////////////// + /** + * Compute color for each pixel on the screen + * @protected + */ + //////////////////////////////////////////////////////////////////////////// + this._colorize = function (pixels, gradient) { + var i, j; + for (i = 0; i < pixels.length; i += 4) { + // Get opacity from the temporary canvas image, + // then multiply by 4 to get the color index on linear gradient + j = pixels[i + 3] * 4; + if (j) { + pixels[i] = gradient[j]; + pixels[i + 1] = gradient[j + 1]; + pixels[i + 2] = gradient[j + 2]; + pixels[i + 3] = m_this.style('opacity') * gradient[j + 3]; + } + } + }; + + //////////////////////////////////////////////////////////////////////////// + /** + * Render each data point on canvas + * @protected + */ + //////////////////////////////////////////////////////////////////////////// + this._renderOnCanvas = function (context2d, map) { + var data = m_this.data() || [], + radius = m_this.style('radius') + m_this.style('blurRadius'), + pos, intensity, canvas, pixelArray; + m_this._createCircle(); + m_this._computeGradient(); + data.forEach(function (d) { + pos = m_this.layer().map().gcsToDisplay(m_this.position()(d)); + intensity = (m_this.intensity()(d) - m_this.minIntensity()) / + (m_this.maxIntensity() - m_this.minIntensity()); + // Small values are not visible because globalAlpha < .01 + // cannot be read from imageData + context2d.globalAlpha = intensity < 0.01 ? 0.01 : intensity; + context2d.drawImage(m_this._circle, pos.x - radius, pos.y - radius); + }); + canvas = m_this.layer().canvas()[0]; + pixelArray = context2d.getImageData(0, 0, canvas.width, canvas.height); + m_this._colorize(pixelArray.data, m_this._grad); + context2d.putImageData(pixelArray, 0, 0); + return m_this; + }; + + //////////////////////////////////////////////////////////////////////////// + /** + * Initialize + * @protected + */ + //////////////////////////////////////////////////////////////////////////// + this._init = function () { + s_init.call(m_this, arg); + return m_this; + }; + + //////////////////////////////////////////////////////////////////////////// + /** + * Update + * @protected + */ + //////////////////////////////////////////////////////////////////////////// + this._update = function () { + s_update.call(m_this); + if (m_this.buildTime().getMTime() <= m_this.dataTime().getMTime() || + m_this.updateTime().getMTime() < m_this.getMTime()) { + m_this._build(); + } + m_this.updateTime().modified(); + return m_this; + }; + + //////////////////////////////////////////////////////////////////////////// + /** + * Destroy + * @protected + */ + //////////////////////////////////////////////////////////////////////////// + this._exit = function () { + s_exit.call(m_this); + }; + + m_this._init(arg); + return this; +}; + +inherit(canvas_heatmapFeature, heatmapFeature); + +// Now register it +registerFeature('canvas', 'heatmap', canvas_heatmapFeature); +module.exports = canvas_heatmapFeature; diff --git a/src/canvas/index.js b/src/canvas/index.js index 349a21f152..ad55e55bb2 100644 --- a/src/canvas/index.js +++ b/src/canvas/index.js @@ -4,5 +4,6 @@ module.exports = { canvasRenderer: require('./canvasRenderer'), quadFeature: require('./quadFeature'), + heatmapFeature: require('./heatmapFeature'), tileLayer: require('./tileLayer') }; diff --git a/src/heatmapFeature.js b/src/heatmapFeature.js new file mode 100644 index 0000000000..3c1f17e470 --- /dev/null +++ b/src/heatmapFeature.js @@ -0,0 +1,201 @@ +var $ = require('jquery'); +var inherit = require('./inherit'); +var feature = require('./feature'); + +////////////////////////////////////////////////////////////////////////////// +/** + * Create a new instance of class heatmapFeature + * + * @class + * @param {Object} arg Options object + * @extends geo.feature + * @param {Object|string|Function} [color] Color transfer function that. + * will be used to evaluate color of each pixel using normalized intensity + * as the look up value. + * @param {number|Function} [opacity=1] Homogeneous opacity for each pixel. + * @param {Object|Function} [radius=10] Radius of a point in terms of number + * of pixels. + * @param {Object|Function} [blurRadius=10] Gaussian blur radius for each + * point in terms of number of pixels. + * @param {Object|Function} [position] Position of the data. Default is + * (data). The position is an Object which specifies the location of the + * data in geo-spatial context. + * @param {boolean} [intensity] Scalar value of each data point. Scalar + * value must be a positive real number and will be used to compute + * the weight for each data point. + * @param {boolean} [maxIntensity=null] Maximum intensity of the data. Maximum + * intensity must be a positive real number and will be used to normalize all + * intensities with a dataset. If no value is given, then a it will + * be computed. + * @param {boolean} [minIntensity=null] Minimum intensity of the data. Minimum + * intensity must be a positive real number will be used to normalize all + * intensities with a dataset. If no value is given, then a it will + * be computed. + * @returns {geo.heatmapFeature} + */ +////////////////////////////////////////////////////////////////////////////// + +////////////////////////////////////////////////////////////////////////////// +var heatmapFeature = function (arg) { + 'use strict'; + if (!(this instanceof heatmapFeature)) { + return new heatmapFeature(arg); + } + arg = arg || {}; + feature.call(this, arg); + + //////////////////////////////////////////////////////////////////////////// + /** + * @private + */ + //////////////////////////////////////////////////////////////////////////// + var m_this = this, + m_position, + m_intensity, + m_maxIntensity, + m_minIntensity, + s_init = this._init; + + m_position = arg.position || function (d) { return d; }; + m_intensity = arg.intensity || function (d) { return 1; }; + m_maxIntensity = arg.maxIntensity || null; + m_minIntensity = arg.minIntensity ? arg.minIntensity : null; + + //////////////////////////////////////////////////////////////////////////// + /** + * Get/Set maxIntensity + * + * @returns {geo.heatmap} + */ + //////////////////////////////////////////////////////////////////////////// + this.maxIntensity = function (val) { + if (val === undefined) { + return m_maxIntensity; + } else { + m_maxIntensity = val; + m_this.dataTime().modified(); + m_this.modified(); + } + return m_this; + }; + + //////////////////////////////////////////////////////////////////////////// + /** + * Get/Set maxIntensity + * + * @returns {geo.heatmap} + */ + //////////////////////////////////////////////////////////////////////////// + this.minIntensity = function (val) { + if (val === undefined) { + return m_minIntensity; + } else { + m_minIntensity = val; + m_this.dataTime().modified(); + m_this.modified(); + } + return m_this; + }; + + //////////////////////////////////////////////////////////////////////////// + /** + * Get/Set position accessor + * + * @returns {geo.heatmap} + */ + //////////////////////////////////////////////////////////////////////////// + this.position = function (val) { + if (val === undefined) { + return m_position; + } else { + m_position = val; + m_this.dataTime().modified(); + m_this.modified(); + } + return m_this; + }; + + //////////////////////////////////////////////////////////////////////////// + /** + * Get/Set intensity + * + * @returns {geo.heatmap} + */ + //////////////////////////////////////////////////////////////////////////// + this.intensity = function (val) { + if (val === undefined) { + return m_intensity; + } else { + m_intensity = val; + m_this.dataTime().modified(); + m_this.modified(); + } + return m_this; + }; + + //////////////////////////////////////////////////////////////////////////// + /** + * Initialize + */ + //////////////////////////////////////////////////////////////////////////// + this._init = function (arg) { + s_init.call(m_this, arg); + + var defaultStyle = $.extend( + {}, + { + opacity: 0.1, + radius: 10, + blurRadius: 10, + color: {0: {r: 0, g: 0, b: 0.0, a: 0.0}, + 0.25: {r: 0, g: 0, b: 1, a: 0.5}, + 0.5: {r: 0, g: 1, b: 1, a: 0.6}, + 0.75: {r: 1, g: 1, b: 0, a: 0.7}, + 1: {r: 1, g: 0, b: 0, a: 0.8}} + }, + arg.style === undefined ? {} : arg.style + ); + + m_this.style(defaultStyle); + + if (m_position) { + m_this.dataTime().modified(); + } + }; + + //////////////////////////////////////////////////////////////////////////// + /** + * Build + * @override + */ + //////////////////////////////////////////////////////////////////////////// + this._build = function () { + var data = m_this.data(), + intensity = null; + + if (!m_maxIntensity || !m_minIntensity) { + data.forEach(function (d) { + intensity = m_this.intensity()(d); + if (!m_maxIntensity && !m_minIntensity) { + m_maxIntensity = m_minIntensity = intensity; + } else { + if (intensity > m_maxIntensity) { + m_maxIntensity = intensity; + } + if (intensity < m_minIntensity) { + m_minIntensity = intensity; + } + } + }); + } + + m_this.buildTime().modified(); + return m_this; + }; + + this._init(arg); + return this; +}; + +inherit(heatmapFeature, feature); +module.exports = heatmapFeature; diff --git a/src/index.js b/src/index.js index 2ffb0e1773..9a5e2282a6 100644 --- a/src/index.js +++ b/src/index.js @@ -52,6 +52,7 @@ module.exports = $.extend({ pointFeature: require('./pointFeature'), polygonFeature: require('./polygonFeature'), quadFeature: require('./quadFeature'), + heatmapFeature: require('./heatmapFeature'), renderer: require('./renderer'), sceneObject: require('./sceneObject'), tile: require('./tile'), diff --git a/tests/cases/heatmap.js b/tests/cases/heatmap.js new file mode 100644 index 0000000000..87cbf99ef1 --- /dev/null +++ b/tests/cases/heatmap.js @@ -0,0 +1,87 @@ +// Test geo.core.osmLayer +var geo = require('../test-utils').geo; +var $ = require('jquery'); + +beforeEach(function () { + $('
').appendTo('body') + .css({width: '500px', height: '400px'}); +}); + +afterEach(function () { + $('#map-canvas-heatmap-feature').remove(); +}); + +describe('canvas heatmap feature', function () { + 'use strict'; + + var mockAnimationFrame = require('../test-utils').mockAnimationFrame; + var stepAnimationFrame = require('../test-utils').stepAnimationFrame; + var unmockAnimationFrame = require('../test-utils').unmockAnimationFrame; + + var map, width = 800, height = 600, layer, feature1, + testData = [[0.6, 42.8584, -70.9301], + [0.233, 42.2776, -83.7409], + [0.2, 42.2776, -83.7409]]; + + it('Setup map', function () { + map = geo.map({node: '#map-canvas-heatmap-feature', center: [0, 0], zoom: 3}); + layer = map.createLayer('feature', {'renderer': 'canvas'}); + map.resize(0, 0, width, height); + }); + + it('Add features to a layer', function () { + feature1 = layer.createFeature('heatmap') + .data(testData) + .intensity(function (d) { + return d[0]; + }) + .position(function (d) { + return { + x: d[2], + y: d[1] + }; + }) + .style('radius', 5) + .style('blurRadius', 15) + .style('opacity', 1.0); + + mockAnimationFrame(); + map.draw(); + stepAnimationFrame(new Date().getTime()); + expect(layer.children().length).toBe(1); + unmockAnimationFrame(); + }); + + it('Validate selection API option', function () { + expect(feature1.selectionAPI()).toBe(false); + }); + + it('Validate position', function () { + expect(feature1.position()([0.6, 42.8584, -70.9301])) + .toEqual({x:-70.9301, y:42.8584}); + }); + + it('Validate maximum intensity', function () { + expect(feature1.maxIntensity()).toBe(0.6); + }); + + it('Validate minimum intensity', function () { + expect(feature1.minIntensity()).toBe(0.2); + }); + + it('Remove a feature from a layer', function () { + layer.deleteFeature(feature1).draw(); + expect(layer.children().length).toBe(0); + }); + + it('Compute gradient', function () { + feature1.style('color', {0: {r: 0, g: 0, b: 0.0, a: 0.0}, + 0.25: {r: 0, g: 0, b: 1, a: 0.5}, + 0.5: {r: 0, g: 1, b: 1, a: 0.6}, + 0.75: {r: 1, g: 1, b: 0, a: 0.7}, + 1: {r: 1, g: 0, b: 0, a: 0.1}}); + feature1._computeGradient(); + expect(layer.node()[0].children[0].getContext('2d') + .getImageData(1, 0, 1, 1).data.length).toBe(4); + }); +});