├── .gitignore ├── .rspec ├── .travis.yml ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── app ├── assets │ └── javascripts │ │ └── heatmap.js ├── controllers │ └── points_controller.rb └── models │ └── heat_map.rb ├── bin ├── console └── setup ├── config └── routes.rb ├── heatmap-rails.gemspec ├── lib ├── generators │ └── heatmap_rails │ │ ├── install_generator.rb │ │ └── templates │ │ └── initializer.rb └── heatmap │ ├── rails.rb │ └── rails │ ├── engine.rb │ ├── helper.rb │ └── version.rb └── spec ├── heatmap_rails_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | # rspec failure tracking 11 | .rspec_status 12 | 13 | .DS_Store 14 | app/.DS_Store 15 | app/controllers/.DS_Store 16 | app/models/.DS_Store 17 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: ruby 3 | rvm: 4 | - 2.4.1 5 | before_install: gem install bundler -v 1.16.0.pre.2 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } 4 | 5 | # Specify your gem's dependencies in heatmap-rails.gemspec 6 | gemspec 7 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | heatmap-rails (0.1.2) 5 | 6 | GEM 7 | remote: https://rubygems.org/ 8 | specs: 9 | diff-lcs (1.3) 10 | rake (10.5.0) 11 | rspec (3.7.0) 12 | rspec-core (~> 3.7.0) 13 | rspec-expectations (~> 3.7.0) 14 | rspec-mocks (~> 3.7.0) 15 | rspec-core (3.7.0) 16 | rspec-support (~> 3.7.0) 17 | rspec-expectations (3.7.0) 18 | diff-lcs (>= 1.2.0, < 2.0) 19 | rspec-support (~> 3.7.0) 20 | rspec-mocks (3.7.0) 21 | diff-lcs (>= 1.2.0, < 2.0) 22 | rspec-support (~> 3.7.0) 23 | rspec-support (3.7.0) 24 | 25 | PLATFORMS 26 | ruby 27 | 28 | DEPENDENCIES 29 | bundler (~> 1.16.a) 30 | heatmap-rails! 31 | rake (~> 10.0) 32 | rspec (~> 3.0) 33 | 34 | BUNDLED WITH 35 | 1.16.0.pre.3 36 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Hassan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Gem Version](https://badge.fury.io/rb/heatmap-rails.svg)](https://badge.fury.io/rb/heatmap-rails) 2 | 3 | # Heatmap-Rails 4 | 5 | Integrate heatmaps in your web application to see on which part the user spends most time on your web application. Where does users click on the page. 6 | Helping in gathering analytics to find out what works on the web, what attracts most of the users. 7 | View user interactions and make your application more amazing! :sparkles: 8 | 9 | [Try the demo](https://heatmap-rails.herokuapp.com/) 10 | 11 | Quick Demo of HeatMap Generation 12 | 13 | ![Demo1](https://github.com/hassanakram/heatmap-rails-demo/blob/master/heatmap__1.gif) 14 | 15 | Heatmap-Rails Works Perfectly in any Screen Size. 16 | 17 | ![Demo2](https://github.com/hassanakram/heatmap-rails-demo/blob/master/heatmap__2.gif) 18 | 19 | ## Installation 20 | 21 | Add this line to your application's Gemfile: 22 | 23 | ```ruby 24 | gem 'heatmap-rails' 25 | ``` 26 | 27 | And then execute: 28 | 29 | $ bundle 30 | 31 | Or install it yourself as: 32 | 33 | $ gem install heatmap-rails 34 | 35 | ## Usage 36 | 37 | 1. Install the gem 38 | 39 | 2. Run the command to generate a migration to save heatmaps data: 40 | ```console 41 | $ rails g heatmap_rails:install 42 | ``` 43 | 44 | 3. Migrate: 45 | ```console 46 | $ rake db:migrate 47 | ``` 48 | 49 | 4. Include the following helper on any page where you need to generate the heatmap: 50 | ```erb 51 | <%= save_heatmap %> 52 | ``` 53 | 54 | 5. Include where to show the heatmap: 55 | ```erb 56 | <%= show_heatmap %> 57 | ``` 58 | 6. Before adding headmap.js in the application install **jquery-rails** gem and require it in application.js file 59 | ```js 60 | //= require jquery 61 | ``` 62 | 63 | 64 | 7. In respective JS file, Require HeatMap.Js to show the heatmap: 65 | ```js 66 | //= require heatmap.js 67 | ``` 68 | ## Viewing Heat Maps 69 | Use the helper 70 | ```erb 71 | <%= show_heatmap %> 72 | ``` 73 | The argument is the path of current page. This way the helper will only display the respective heatmap. 74 | The viewing can be done in multiple ways, for example if you want only the admin users to view heatmap, you can do something like: 75 | 76 | ```erb 77 | <% if admin_user_signed_in? %> 78 | <%= show_heatmap %> 79 | <% end %> 80 | ``` 81 | 82 | Another way can be using some param in the URL. For example if you want to use URL like: 83 | 84 | ```url 85 | www.website.com/?see_heatmap 86 | ``` 87 | 88 | You can use: 89 | 90 | ```erb 91 | <% if request.query_parameters.include?("see_heatmap") %> 92 | <%= show_heatmap %> 93 | <% end %> 94 | ``` 95 | 96 | ### Options 97 | 98 | You can customize the max stack limits before the data is sent to server side via http request. We understand for different application the average user interactions time on a specific page varies. You can set these values w.r.t to your application's needs: 99 | ```erb 100 | <%= save_heatmap({click: 3, move: 50}) %> 101 | ``` 102 | The default values for clicks is `3`. For mouse movements tracking its `50`. 103 | 104 | ```erb 105 | <%= save_heatmap({click: 3, move: 50, html_element: 'body'}) %> 106 | ``` 107 | you can even restrict heatmap generation to a specific DOM element. Default value for DOM element is `body` element. This can be change to any `.class` or any '#id'. 108 | 109 | ## Development 110 | 111 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 112 | 113 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 114 | 115 | ## Credits 116 | heatmap-rails uses [HeatMap.Js](https://www.patrick-wied.at/static/heatmapjs/) to show generated data in form of heatmaps. 117 | 118 | ## Contributing :construction: 119 | 120 | 1. [Bug reports](https://github.com/Qbatch/heatmap-rails/issues) are always welcome. 121 | 2. [Pull Requests](https://github.com/Qbatch/heatmap-rails/pulls). Suggest or Update. 122 | 123 | ## License 124 | 125 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 126 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | task :default => :spec 3 | -------------------------------------------------------------------------------- /app/assets/javascripts/heatmap.js: -------------------------------------------------------------------------------- 1 | /* 2 | * heatmap.js v2.0.5 | JavaScript Heatmap Library 3 | * 4 | * Copyright 2008-2016 Patrick Wied - All rights reserved. 5 | * Dual licensed under MIT and Beerware license 6 | * 7 | * :: 2016-09-05 01:16 8 | */ 9 | ;(function (name, context, factory) { 10 | 11 | // Supports UMD. AMD, CommonJS/Node.js and browser context 12 | if (typeof module !== "undefined" && module.exports) { 13 | module.exports = factory(); 14 | } else if (typeof define === "function" && define.amd) { 15 | define(factory); 16 | } else { 17 | context[name] = factory(); 18 | } 19 | 20 | })("h337", this, function () { 21 | 22 | // Heatmap Config stores default values and will be merged with instance config 23 | var HeatmapConfig = { 24 | defaultRadius: 40, 25 | defaultRenderer: 'canvas2d', 26 | defaultGradient: { 0.25: "rgb(0,0,255)", 0.55: "rgb(0,255,0)", 0.85: "yellow", 1.0: "rgb(255,0,0)"}, 27 | defaultMaxOpacity: 1, 28 | defaultMinOpacity: 0, 29 | defaultBlur: .85, 30 | defaultXField: 'x', 31 | defaultYField: 'y', 32 | defaultValueField: 'value', 33 | plugins: {} 34 | }; 35 | var Store = (function StoreClosure() { 36 | 37 | var Store = function Store(config) { 38 | this._coordinator = {}; 39 | this._data = []; 40 | this._radi = []; 41 | this._min = 10; 42 | this._max = 1; 43 | this._xField = config['xField'] || config.defaultXField; 44 | this._yField = config['yField'] || config.defaultYField; 45 | this._valueField = config['valueField'] || config.defaultValueField; 46 | 47 | if (config["radius"]) { 48 | this._cfgRadius = config["radius"]; 49 | } 50 | }; 51 | 52 | var defaultRadius = HeatmapConfig.defaultRadius; 53 | 54 | Store.prototype = { 55 | // when forceRender = false -> called from setData, omits renderall event 56 | _organiseData: function(dataPoint, forceRender) { 57 | var x = dataPoint[this._xField]; 58 | var y = dataPoint[this._yField]; 59 | var radi = this._radi; 60 | var store = this._data; 61 | var max = this._max; 62 | var min = this._min; 63 | var value = dataPoint[this._valueField] || 1; 64 | var radius = dataPoint.radius || this._cfgRadius || defaultRadius; 65 | 66 | if (!store[x]) { 67 | store[x] = []; 68 | radi[x] = []; 69 | } 70 | 71 | if (!store[x][y]) { 72 | store[x][y] = value; 73 | radi[x][y] = radius; 74 | } else { 75 | store[x][y] += value; 76 | } 77 | var storedVal = store[x][y]; 78 | 79 | if (storedVal > max) { 80 | if (!forceRender) { 81 | this._max = storedVal; 82 | } else { 83 | this.setDataMax(storedVal); 84 | } 85 | return false; 86 | } else if (storedVal < min) { 87 | if (!forceRender) { 88 | this._min = storedVal; 89 | } else { 90 | this.setDataMin(storedVal); 91 | } 92 | return false; 93 | } else { 94 | return { 95 | x: x, 96 | y: y, 97 | value: value, 98 | radius: radius, 99 | min: min, 100 | max: max 101 | }; 102 | } 103 | }, 104 | _unOrganizeData: function() { 105 | var unorganizedData = []; 106 | var data = this._data; 107 | var radi = this._radi; 108 | 109 | for (var x in data) { 110 | for (var y in data[x]) { 111 | 112 | unorganizedData.push({ 113 | x: x, 114 | y: y, 115 | radius: radi[x][y], 116 | value: data[x][y] 117 | }); 118 | 119 | } 120 | } 121 | return { 122 | min: this._min, 123 | max: this._max, 124 | data: unorganizedData 125 | }; 126 | }, 127 | _onExtremaChange: function() { 128 | this._coordinator.emit('extremachange', { 129 | min: this._min, 130 | max: this._max 131 | }); 132 | }, 133 | addData: function() { 134 | if (arguments[0].length > 0) { 135 | var dataArr = arguments[0]; 136 | var dataLen = dataArr.length; 137 | while (dataLen--) { 138 | this.addData.call(this, dataArr[dataLen]); 139 | } 140 | } else { 141 | // add to store 142 | var organisedEntry = this._organiseData(arguments[0], true); 143 | if (organisedEntry) { 144 | // if it's the first datapoint initialize the extremas with it 145 | if (this._data.length === 0) { 146 | this._min = this._max = organisedEntry.value; 147 | } 148 | this._coordinator.emit('renderpartial', { 149 | min: this._min, 150 | max: this._max, 151 | data: [organisedEntry] 152 | }); 153 | } 154 | } 155 | return this; 156 | }, 157 | setData: function(data) { 158 | var dataPoints = data.data; 159 | var pointsLen = dataPoints.length; 160 | 161 | 162 | // reset data arrays 163 | this._data = []; 164 | this._radi = []; 165 | 166 | for(var i = 0; i < pointsLen; i++) { 167 | this._organiseData(dataPoints[i], false); 168 | } 169 | this._max = data.max; 170 | this._min = data.min || 0; 171 | 172 | this._onExtremaChange(); 173 | this._coordinator.emit('renderall', this._getInternalData()); 174 | return this; 175 | }, 176 | removeData: function() { 177 | // TODO: implement 178 | }, 179 | setDataMax: function(max) { 180 | this._max = max; 181 | this._onExtremaChange(); 182 | this._coordinator.emit('renderall', this._getInternalData()); 183 | return this; 184 | }, 185 | setDataMin: function(min) { 186 | this._min = min; 187 | this._onExtremaChange(); 188 | this._coordinator.emit('renderall', this._getInternalData()); 189 | return this; 190 | }, 191 | setCoordinator: function(coordinator) { 192 | this._coordinator = coordinator; 193 | }, 194 | _getInternalData: function() { 195 | return { 196 | max: this._max, 197 | min: this._min, 198 | data: this._data, 199 | radi: this._radi 200 | }; 201 | }, 202 | getData: function() { 203 | return this._unOrganizeData(); 204 | }/*, 205 | TODO: rethink. 206 | getValueAt: function(point) { 207 | var value; 208 | var radius = 100; 209 | var x = point.x; 210 | var y = point.y; 211 | var data = this._data; 212 | if (data[x] && data[x][y]) { 213 | return data[x][y]; 214 | } else { 215 | var values = []; 216 | // radial search for datapoints based on default radius 217 | for(var distance = 1; distance < radius; distance++) { 218 | var neighbors = distance * 2 +1; 219 | var startX = x - distance; 220 | var startY = y - distance; 221 | for(var i = 0; i < neighbors; i++) { 222 | for (var o = 0; o < neighbors; o++) { 223 | if ((i == 0 || i == neighbors-1) || (o == 0 || o == neighbors-1)) { 224 | if (data[startY+i] && data[startY+i][startX+o]) { 225 | values.push(data[startY+i][startX+o]); 226 | } 227 | } else { 228 | continue; 229 | } 230 | } 231 | } 232 | } 233 | if (values.length > 0) { 234 | return Math.max.apply(Math, values); 235 | } 236 | } 237 | return false; 238 | }*/ 239 | }; 240 | 241 | 242 | return Store; 243 | })(); 244 | 245 | var Canvas2dRenderer = (function Canvas2dRendererClosure() { 246 | 247 | var _getColorPalette = function(config) { 248 | var gradientConfig = config.gradient || config.defaultGradient; 249 | var paletteCanvas = document.createElement('canvas'); 250 | var paletteCtx = paletteCanvas.getContext('2d'); 251 | 252 | paletteCanvas.width = 256; 253 | paletteCanvas.height = 1; 254 | 255 | var gradient = paletteCtx.createLinearGradient(0, 0, 256, 1); 256 | for (var key in gradientConfig) { 257 | gradient.addColorStop(key, gradientConfig[key]); 258 | } 259 | 260 | paletteCtx.fillStyle = gradient; 261 | paletteCtx.fillRect(0, 0, 256, 1); 262 | 263 | return paletteCtx.getImageData(0, 0, 256, 1).data; 264 | }; 265 | 266 | var _getPointTemplate = function(radius, blurFactor) { 267 | var tplCanvas = document.createElement('canvas'); 268 | var tplCtx = tplCanvas.getContext('2d'); 269 | var x = radius; 270 | var y = radius; 271 | tplCanvas.width = tplCanvas.height = radius*2; 272 | 273 | if (blurFactor == 1) { 274 | tplCtx.beginPath(); 275 | tplCtx.arc(x, y, radius, 0, 2 * Math.PI, false); 276 | tplCtx.fillStyle = 'rgba(0,0,0,1)'; 277 | tplCtx.fill(); 278 | } else { 279 | var gradient = tplCtx.createRadialGradient(x, y, radius*blurFactor, x, y, radius); 280 | gradient.addColorStop(0, 'rgba(0,0,0,1)'); 281 | gradient.addColorStop(1, 'rgba(0,0,0,0)'); 282 | tplCtx.fillStyle = gradient; 283 | tplCtx.fillRect(0, 0, 2*radius, 2*radius); 284 | } 285 | 286 | 287 | 288 | return tplCanvas; 289 | }; 290 | 291 | var _prepareData = function(data) { 292 | var renderData = []; 293 | var min = data.min; 294 | var max = data.max; 295 | var radi = data.radi; 296 | var data = data.data; 297 | 298 | var xValues = Object.keys(data); 299 | var xValuesLen = xValues.length; 300 | 301 | while(xValuesLen--) { 302 | var xValue = xValues[xValuesLen]; 303 | var yValues = Object.keys(data[xValue]); 304 | var yValuesLen = yValues.length; 305 | while(yValuesLen--) { 306 | var yValue = yValues[yValuesLen]; 307 | var value = data[xValue][yValue]; 308 | var radius = radi[xValue][yValue]; 309 | renderData.push({ 310 | x: xValue, 311 | y: yValue, 312 | value: value, 313 | radius: radius 314 | }); 315 | } 316 | } 317 | 318 | return { 319 | min: min, 320 | max: max, 321 | data: renderData 322 | }; 323 | }; 324 | 325 | 326 | function Canvas2dRenderer(config) { 327 | var container = config.container; 328 | var shadowCanvas = this.shadowCanvas = document.createElement('canvas'); 329 | var canvas = this.canvas = config.canvas || document.createElement('canvas'); 330 | var renderBoundaries = this._renderBoundaries = [10000, 10000, 0, 0]; 331 | 332 | var computed = getComputedStyle(config.container) || {}; 333 | 334 | canvas.className = 'heatmap-canvas'; 335 | 336 | this._width = canvas.width = shadowCanvas.width = config.width || +(computed.width.replace(/px/,'')); 337 | this._height = canvas.height = shadowCanvas.height = config.height || +(computed.height.replace(/px/,'')); 338 | 339 | this.shadowCtx = shadowCanvas.getContext('2d'); 340 | this.ctx = canvas.getContext('2d'); 341 | 342 | // @TODO: 343 | // conditional wrapper 344 | 345 | canvas.style.cssText = shadowCanvas.style.cssText = 'position:absolute;left:0;top:0;'; 346 | 347 | container.style.position = 'relative'; 348 | container.appendChild(canvas); 349 | 350 | this._palette = _getColorPalette(config); 351 | this._templates = {}; 352 | 353 | this._setStyles(config); 354 | }; 355 | 356 | Canvas2dRenderer.prototype = { 357 | renderPartial: function(data) { 358 | if (data.data.length > 0) { 359 | this._drawAlpha(data); 360 | this._colorize(); 361 | } 362 | }, 363 | renderAll: function(data) { 364 | // reset render boundaries 365 | this._clear(); 366 | if (data.data.length > 0) { 367 | this._drawAlpha(_prepareData(data)); 368 | this._colorize(); 369 | } 370 | }, 371 | _updateGradient: function(config) { 372 | this._palette = _getColorPalette(config); 373 | }, 374 | updateConfig: function(config) { 375 | if (config['gradient']) { 376 | this._updateGradient(config); 377 | } 378 | this._setStyles(config); 379 | }, 380 | setDimensions: function(width, height) { 381 | this._width = width; 382 | this._height = height; 383 | this.canvas.width = this.shadowCanvas.width = width; 384 | this.canvas.height = this.shadowCanvas.height = height; 385 | }, 386 | _clear: function() { 387 | this.shadowCtx.clearRect(0, 0, this._width, this._height); 388 | this.ctx.clearRect(0, 0, this._width, this._height); 389 | }, 390 | _setStyles: function(config) { 391 | this._blur = (config.blur == 0)?0:(config.blur || config.defaultBlur); 392 | 393 | if (config.backgroundColor) { 394 | this.canvas.style.backgroundColor = config.backgroundColor; 395 | } 396 | 397 | this._width = this.canvas.width = this.shadowCanvas.width = config.width || this._width; 398 | this._height = this.canvas.height = this.shadowCanvas.height = config.height || this._height; 399 | 400 | 401 | this._opacity = (config.opacity || 0) * 255; 402 | this._maxOpacity = (config.maxOpacity || config.defaultMaxOpacity) * 255; 403 | this._minOpacity = (config.minOpacity || config.defaultMinOpacity) * 255; 404 | this._useGradientOpacity = !!config.useGradientOpacity; 405 | }, 406 | _drawAlpha: function(data) { 407 | var min = this._min = data.min; 408 | var max = this._max = data.max; 409 | var data = data.data || []; 410 | var dataLen = data.length; 411 | // on a point basis? 412 | var blur = 1 - this._blur; 413 | 414 | while(dataLen--) { 415 | 416 | var point = data[dataLen]; 417 | 418 | var x = point.x; 419 | var y = point.y; 420 | var radius = point.radius; 421 | // if value is bigger than max 422 | // use max as value 423 | var value = Math.min(point.value, max); 424 | var rectX = x - radius; 425 | var rectY = y - radius; 426 | var shadowCtx = this.shadowCtx; 427 | 428 | 429 | 430 | 431 | var tpl; 432 | if (!this._templates[radius]) { 433 | this._templates[radius] = tpl = _getPointTemplate(radius, blur); 434 | } else { 435 | tpl = this._templates[radius]; 436 | } 437 | // value from minimum / value range 438 | // => [0, 1] 439 | var templateAlpha = (value-min)/(max-min); 440 | // this fixes #176: small values are not visible because globalAlpha < .01 cannot be read from imageData 441 | shadowCtx.globalAlpha = templateAlpha < .01 ? .01 : templateAlpha; 442 | 443 | shadowCtx.drawImage(tpl, rectX, rectY); 444 | 445 | // update renderBoundaries 446 | if (rectX < this._renderBoundaries[0]) { 447 | this._renderBoundaries[0] = rectX; 448 | } 449 | if (rectY < this._renderBoundaries[1]) { 450 | this._renderBoundaries[1] = rectY; 451 | } 452 | if (rectX + 2*radius > this._renderBoundaries[2]) { 453 | this._renderBoundaries[2] = rectX + 2*radius; 454 | } 455 | if (rectY + 2*radius > this._renderBoundaries[3]) { 456 | this._renderBoundaries[3] = rectY + 2*radius; 457 | } 458 | 459 | } 460 | }, 461 | _colorize: function() { 462 | var x = this._renderBoundaries[0]; 463 | var y = this._renderBoundaries[1]; 464 | var width = this._renderBoundaries[2] - x; 465 | var height = this._renderBoundaries[3] - y; 466 | var maxWidth = this._width; 467 | var maxHeight = this._height; 468 | var opacity = this._opacity; 469 | var maxOpacity = this._maxOpacity; 470 | var minOpacity = this._minOpacity; 471 | var useGradientOpacity = this._useGradientOpacity; 472 | 473 | if (x < 0) { 474 | x = 0; 475 | } 476 | if (y < 0) { 477 | y = 0; 478 | } 479 | if (x + width > maxWidth) { 480 | width = maxWidth - x; 481 | } 482 | if (y + height > maxHeight) { 483 | height = maxHeight - y; 484 | } 485 | 486 | var img = this.shadowCtx.getImageData(x, y, width, height); 487 | var imgData = img.data; 488 | var len = imgData.length; 489 | var palette = this._palette; 490 | 491 | 492 | for (var i = 3; i < len; i+= 4) { 493 | var alpha = imgData[i]; 494 | var offset = alpha * 4; 495 | 496 | 497 | if (!offset) { 498 | continue; 499 | } 500 | 501 | var finalAlpha; 502 | if (opacity > 0) { 503 | finalAlpha = opacity; 504 | } else { 505 | if (alpha < maxOpacity) { 506 | if (alpha < minOpacity) { 507 | finalAlpha = minOpacity; 508 | } else { 509 | finalAlpha = alpha; 510 | } 511 | } else { 512 | finalAlpha = maxOpacity; 513 | } 514 | } 515 | 516 | imgData[i-3] = palette[offset]; 517 | imgData[i-2] = palette[offset + 1]; 518 | imgData[i-1] = palette[offset + 2]; 519 | imgData[i] = useGradientOpacity ? palette[offset + 3] : finalAlpha; 520 | 521 | } 522 | 523 | img.data = imgData; 524 | this.ctx.putImageData(img, x, y); 525 | 526 | this._renderBoundaries = [1000, 1000, 0, 0]; 527 | 528 | }, 529 | getValueAt: function(point) { 530 | var value; 531 | var shadowCtx = this.shadowCtx; 532 | var img = shadowCtx.getImageData(point.x, point.y, 1, 1); 533 | var data = img.data[3]; 534 | var max = this._max; 535 | var min = this._min; 536 | 537 | value = (Math.abs(max-min) * (data/255)) >> 0; 538 | 539 | return value; 540 | }, 541 | getDataURL: function() { 542 | return this.canvas.toDataURL(); 543 | } 544 | }; 545 | 546 | 547 | return Canvas2dRenderer; 548 | })(); 549 | 550 | 551 | var Renderer = (function RendererClosure() { 552 | 553 | var rendererFn = false; 554 | 555 | if (HeatmapConfig['defaultRenderer'] === 'canvas2d') { 556 | rendererFn = Canvas2dRenderer; 557 | } 558 | 559 | return rendererFn; 560 | })(); 561 | 562 | 563 | var Util = { 564 | merge: function() { 565 | var merged = {}; 566 | var argsLen = arguments.length; 567 | for (var i = 0; i < argsLen; i++) { 568 | var obj = arguments[i] 569 | for (var key in obj) { 570 | merged[key] = obj[key]; 571 | } 572 | } 573 | return merged; 574 | } 575 | }; 576 | // Heatmap Constructor 577 | var Heatmap = (function HeatmapClosure() { 578 | 579 | var Coordinator = (function CoordinatorClosure() { 580 | 581 | function Coordinator() { 582 | this.cStore = {}; 583 | }; 584 | 585 | Coordinator.prototype = { 586 | on: function(evtName, callback, scope) { 587 | var cStore = this.cStore; 588 | 589 | if (!cStore[evtName]) { 590 | cStore[evtName] = []; 591 | } 592 | cStore[evtName].push((function(data) { 593 | return callback.call(scope, data); 594 | })); 595 | }, 596 | emit: function(evtName, data) { 597 | var cStore = this.cStore; 598 | if (cStore[evtName]) { 599 | var len = cStore[evtName].length; 600 | for (var i=0; i 0 7 | for i in 0..params[:total_clicks].to_i-1 8 | HeatMap.create(path: params[:click_data]["#{i}"][:path], click_type: 'click',xpath: params[:click_data]["#{i}"][:xpath], offset_x: params[:click_data]["#{i}"][:offset_x], offset_y: params[:click_data]["#{i}"][:offset_y]) 9 | end 10 | end 11 | if params[:move_data].present? && params[:total_moves].to_i > 0 12 | for i in 0..params[:total_moves].to_i-1 13 | HeatMap.create(path: params[:move_data]["#{i}"][:path], click_type: 'move',xpath: params[:move_data]["#{i}"][:xpath], offset_x: params[:move_data]["#{i}"][:offset_x], offset_y: params[:move_data]["#{i}"][:offset_y]) 14 | end 15 | end 16 | if params[:scroll_data].present? && params[:total_scrolls].to_i > 0 17 | for i in 0..params[:total_scrolls].to_i-1 18 | HeatMap.create(path: params[:scroll_data]["#{i}"][:path], click_type: 'scroll',xpath: params[:scroll_data]["#{i}"][:xpath], offset_x: params[:scroll_data]["#{i}"][:offset_x], offset_y: params[:scroll_data]["#{i}"][:offset_y]) 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /app/models/heat_map.rb: -------------------------------------------------------------------------------- 1 | class HeatMap < ActiveRecord::Base 2 | end 3 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "heatmap/rails" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start(__FILE__) 15 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | resources :points 3 | end 4 | -------------------------------------------------------------------------------- /heatmap-rails.gemspec: -------------------------------------------------------------------------------- 1 | 2 | lib = File.expand_path("../lib", __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require "heatmap/rails/version" 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "heatmap-rails" 8 | spec.version = Heatmap::Rails::VERSION 9 | spec.authors = ["Hassan"] 10 | spec.email = ["hassan@qbatch.com"] 11 | 12 | spec.summary = %q{HeatMap Gem} 13 | spec.description = %q{Get And Display HeatMap Coordinates} 14 | spec.homepage = "https://github.com/Qbatch/heatmap-rails" 15 | spec.license = "MIT" 16 | 17 | # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host' 18 | # to allow pushing to a single host or delete this section to allow pushing to any host. 19 | # if spec.respond_to?(:metadata) 20 | # spec.metadata["allowed_push_host"] = "'TODO: Set to 'http://mygemserver.com'" 21 | # else 22 | # raise "RubyGems 2.0 or newer is required to protect against " \ 23 | # "public gem pushes." 24 | # end 25 | 26 | spec.files = `git ls-files -z`.split("\x0") 27 | spec.bindir = "exe" 28 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 29 | spec.require_paths = ["lib"] 30 | 31 | spec.add_development_dependency "bundler", "~> 1.16.a" 32 | spec.add_development_dependency "rake", "~> 10.0" 33 | spec.add_development_dependency "rspec", "~> 3.0" 34 | end 35 | -------------------------------------------------------------------------------- /lib/generators/heatmap_rails/install_generator.rb: -------------------------------------------------------------------------------- 1 | require 'rails/generators' 2 | require 'rails/generators/migration' 3 | require 'rails/generators/base' 4 | 5 | module HeatmapRails 6 | class InstallGenerator < Rails::Generators::Base 7 | include Rails::Generators::Migration 8 | source_root File.expand_path('../templates', __FILE__) 9 | 10 | def self.next_migration_number(path) 11 | Time.now.utc.strftime("%Y%m%d%H%M%S") 12 | end 13 | 14 | def create_model_file 15 | migration_template "initializer.rb", "db/migrate/create_heat_maps.rb" 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/generators/heatmap_rails/templates/initializer.rb: -------------------------------------------------------------------------------- 1 | class CreateHeatMaps < ActiveRecord::Migration[5.1] 2 | def change 3 | create_table :heat_maps do |t| 4 | t.string :path 5 | t.string :click_type 6 | t.float :offset_x 7 | t.float :offset_y 8 | t.text :xpath 9 | 10 | t.timestamps 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/heatmap/rails.rb: -------------------------------------------------------------------------------- 1 | require "heatmap/rails/version" 2 | require "heatmap/rails/engine" 3 | require "heatmap/rails/helper" 4 | 5 | module Heatmap 6 | module Rails 7 | class << self 8 | attr_accessor :options 9 | end 10 | self.options = {click: 3, move: 10, html_element: 'body', scroll: 10} 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/heatmap/rails/engine.rb: -------------------------------------------------------------------------------- 1 | module Heatmap 2 | module Rails 3 | class Engine < ::Rails::Engine 4 | initializer "helper" do 5 | ActiveSupport.on_load(:action_view) do 6 | include Helper 7 | end 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/heatmap/rails/helper.rb: -------------------------------------------------------------------------------- 1 | require "erb" 2 | 3 | module Heatmap 4 | module Helper 5 | 6 | def exact_route 7 | "#{params[:controller]}/#{params[:action]}" 8 | end 9 | 10 | def save_heatmap(options = {}) 11 | click = options[:click] || Heatmap::Rails.options[:click] 12 | move = options[:move] || Heatmap::Rails.options[:move] 13 | scroll = options[:scroll] || Heatmap::Rails.options[:scroll] 14 | html_element = options[:html_element] || Heatmap::Rails.options[:html_element] 15 | html = "" 16 | 17 | js = < 19 | $( document ).ready(function() { 20 | var move_array = []; 21 | var scroll_array = []; 22 | 23 | (function() { 24 | document.onwheel = handleWheelMove; 25 | function handleWheelMove(event) { 26 | var dot, eventDoc, doc, body, offsetX, offsetY; 27 | event = event || window.event; 28 | if (event.offsetX == null && event.clientX != null) { 29 | eventDoc = (event.target && event.target.ownerDocument) || document; 30 | doc = eventDoc.documentElement; 31 | body = eventDoc.body; 32 | event.offsetX = event.clientX + 33 | (doc && doc.scrollLeft || body && body.scrollLeft || 0) - 34 | (doc && doc.clientLeft || body && body.clientLeft || 0); 35 | event.offsetY = event.clientY + 36 | (doc && doc.scrollTop || body && body.scrollTop || 0) - 37 | (doc && doc.clientTop || body && body.clientTop || 0 ); 38 | } 39 | var xpath_element = xpathstring(event); 40 | var element_width = event.target.getBoundingClientRect().width; 41 | var element_height= event.target.getBoundingClientRect().height; 42 | offset_x_element = event.offsetX / element_width; 43 | offset_y_element = event.offsetY / element_height; 44 | var pageCoords = { path: "#{exact_route}", type: 'scroll', xpath: xpath_element, offset_x: offset_x_element , offset_y: offset_y_element, }; 45 | 46 | scroll_array.push(pageCoords); 47 | if (scroll_array.length >= parseInt(#{scroll})) { 48 | var coordinates = scroll_array; 49 | sendRequest({'scroll_data': coordinates, 'total_scrolls': #{scroll} }); 50 | scroll_array = []; 51 | } 52 | } 53 | })(); 54 | 55 | document.querySelector('#{html_element}').onmousemove = function(ev) { 56 | var xpath_element = xpathstring(ev); 57 | var element_width = ev.target.getBoundingClientRect().width; 58 | var element_height= ev.target.getBoundingClientRect().height; 59 | offset_x_element = ev.offsetX / element_width; 60 | offset_y_element = ev.offsetY / element_height; 61 | var pageCoords = { path: "#{exact_route}", type: 'move', xpath: xpath_element, offset_x: offset_x_element , offset_y: offset_y_element, }; 62 | 63 | var obj = move_array.find(function (obj) { return obj.xpath === xpath_element; }); 64 | if (obj == null){ 65 | move_array.push(pageCoords); 66 | } 67 | if (move_array.length >= parseInt(#{move})) 68 | { 69 | var coordinates = move_array; 70 | sendRequest({'move_data': coordinates,'total_moves': #{move} }); 71 | move_array = []; 72 | } 73 | 74 | 75 | }; 76 | var click_array = []; 77 | document.querySelector('#{html_element}').onclick = function(ev) { 78 | 79 | var xpath_element= xpathstring(ev); 80 | var element_width = ev.target.getBoundingClientRect().width; 81 | var element_height= ev.target.getBoundingClientRect().height; 82 | offset_x_element = ev.offsetX / element_width; 83 | offset_y_element = ev.offsetY / element_height; 84 | var pageCoords = { path: "#{exact_route}", type: 'click', xpath: xpath_element, offset_x: offset_x_element , offset_y: offset_y_element, }; 85 | click_array.push(pageCoords); 86 | if (click_array.length >= parseInt(#{click})) 87 | { 88 | var coordinates = click_array; 89 | sendRequest({'click_data': coordinates, 'total_clicks': #{click} }); 90 | click_array = []; 91 | 92 | } 93 | }; 94 | function sendRequest(coordinates_data){ 95 | $.ajax({ 96 | method: "POST", 97 | url: '/points', 98 | data: coordinates_data, 99 | dataType: 'application/json' 100 | }); 101 | } 102 | }); 103 | 104 | function xpathstring(event) { 105 | var e = event.srcElement || event.originalTarget, 106 | path = xpath(e, ''); 107 | return path 108 | } 109 | function xpath(element, suffix) { 110 | var parent, child_index, node_name; 111 | parent = element.parentElement; 112 | if (parent) { 113 | node_name = element.nodeName.toLowerCase(); 114 | child_index = nodeindex(element, parent.children) + 1; 115 | return xpath(parent, '/' + node_name + '[' + child_index + ']' + suffix); 116 | } else { 117 | return '//html[1]' + suffix; 118 | } 119 | } 120 | function nodeindex(element, array) { 121 | var i, 122 | found = -1, 123 | element_name = element.nodeName.toLowerCase(), 124 | matched 125 | ; 126 | 127 | for (i = 0; i != array.length; ++i) { 128 | matched = array[i]; 129 | if (matched.nodeName.toLowerCase() === element_name) { 130 | ++found; 131 | 132 | 133 | if (matched === element) { 134 | return found; 135 | } 136 | } 137 | } 138 | 139 | return -1; 140 | } 141 | function getOffset( path ) { 142 | el = document.evaluate(path, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; 143 | var _x = 0; 144 | var _y = 0; 145 | while( el && !isNaN( el.offsetLeft ) && !isNaN( el.offsetTop ) ) { 146 | _x += el.offsetLeft - el.scrollLeft; 147 | _y += el.offsetTop - el.scrollTop; 148 | el = el.offsetParent; 149 | } 150 | return { y: _y, x: _x }; 151 | } 152 | 153 | JS 154 | 155 | html += js 156 | html.respond_to?(:html_safe) ? html.html_safe : html 157 | end 158 | 159 | def show_heatmap(type = false) 160 | if type 161 | heatmap = HeatMap.where(path: exact_route.to_s , click_type: type) 162 | heatmap_count = HeatMap.where(path: exact_route.to_s , click_type: type).count 163 | type = type + 's' 164 | else 165 | heatmap = HeatMap.where(path: exact_route.to_s) 166 | heatmap_count = HeatMap.where(path: exact_route.to_s).count 167 | type = 'heatmaps' 168 | end 169 | @data_points = [] 170 | @data_xpaths = [] 171 | @scroll_data = [] 172 | heatmap.each do |coordinate| 173 | if (coordinate.click_type == "scroll") 174 | @scroll_data.push({xpath: coordinate.xpath, offset_x: coordinate.offset_x, offset_y:coordinate.offset_y}) 175 | else 176 | @data_xpaths.push({xpath: coordinate.xpath, offset_x: coordinate.offset_x, offset_y:coordinate.offset_y, value: 100}) 177 | end 178 | end 179 | html = "" 180 | js = < 182 | var heatmapInstance = h337.create({ 183 | container: document.querySelector('body'), 184 | radius: 40 185 | }); 186 | window.onload = function() { 187 | var parent_div = document.createElement("div"); 188 | var text_div = document.createElement("span"); 189 | parent_div.style.padding= "14px"; 190 | parent_div.style.position = "absolute"; 191 | parent_div.style.top = "0"; 192 | parent_div.style.right = "0"; 193 | parent_div.style.background ="rgba(0, 0, 0, 0.7)"; 194 | parent_div.style.color ="white"; 195 | parent_div.style.textAlign ="center"; 196 | var text_node = document.createTextNode("#{type.capitalize} Recorded"); 197 | text_div.appendChild(text_node); 198 | parent_div.appendChild(text_div); 199 | var numeric_div = document.createElement("h1"); 200 | var numeric_node = document.createTextNode("#{heatmap_count}"); 201 | numeric_div.appendChild(numeric_node); 202 | parent_div.appendChild(numeric_div); 203 | document.body.appendChild(parent_div); 204 | } 205 | function getOffset( path ) { 206 | el = document.evaluate(path, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; 207 | var _x = 0; 208 | var _y = 0; 209 | while( el && !isNaN( el.offsetLeft ) && !isNaN( el.offsetTop ) ) { 210 | _x += el.offsetLeft - el.scrollLeft; 211 | _y += el.offsetTop - el.scrollTop; 212 | el = el.offsetParent; 213 | } 214 | return { y: _y, x: _x }; 215 | } 216 | function getElement(xpath){ 217 | return document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; 218 | } 219 | var xpath_current = JSON.parse('#{raw(@data_xpaths.to_json.html_safe)}'); 220 | var data_xpath = xpath_current.map(function(path){ 221 | if (path != null) { 222 | element = getElement(path.xpath); 223 | if (element != null){ 224 | width = element.getBoundingClientRect().width; 225 | height = element.getBoundingClientRect().height; 226 | var x_coord = getOffset(path.xpath).x+ (width * path.offset_x); 227 | var y_coord = getOffset(path.xpath).y+ (height * path.offset_y); 228 | delete path["xpath_current"]; 229 | delete path["offset_x"]; 230 | delete path["offset_y"]; 231 | path.x = Math.ceil(parseFloat(x_coord)); 232 | path.y = Math.ceil(parseFloat(y_coord)); 233 | return path; 234 | } 235 | } 236 | }); 237 | // Removed: Null Xpath(s) 238 | var data_xpath = data_xpath.filter(function(val){ return val!==undefined; }); 239 | 240 | heatmapInstance.addData(data_xpath); 241 | var scroll = JSON.parse('#{raw(@scroll_data.to_json.html_safe)}'); 242 | var scroll_data = scroll.map(function(element){ 243 | width = getElement(element.xpath).getBoundingClientRect().width; 244 | height = getElement(element.xpath).getBoundingClientRect().height; 245 | dot = document.createElement('div'); 246 | dot.className = "dot"; 247 | dot.style.left = (getOffset(element.xpath).x+ (width * element.offset_x)) + "px"; 248 | dot.style.top = (getOffset(element.xpath).y+ (height * element.offset_y) )+ "px"; 249 | delete element["xpath"]; 250 | delete element["offset_x"]; 251 | delete element["offset_y"]; 252 | dot.style.backgroundColor ="white"; 253 | dot.style.position ="absolute"; 254 | dot.style.borderWidth ="8px"; 255 | dot.style.borderStyle ="solid"; 256 | var colors = Array('#ee3e32', '#f68838' ,'#fbb021', '#1b8a5a','#1d4877'); 257 | var color = colors[Math.floor(Math.random()*colors.length)]; 258 | dot.style.borderColor = color; 259 | dot.style.borderRadius ="50%"; 260 | dot.style.opacity ="0.7"; 261 | var arrow_node = document.createTextNode("\u21C5"); 262 | dot.appendChild(arrow_node); 263 | document.body.appendChild(dot); 264 | }); 265 | 266 | scroll_data = scroll_data.filter(function(val){ return val!==undefined; }); 267 | 268 | JS 269 | 270 | html += js 271 | html.respond_to?(:html_safe) ? html.html_safe : html 272 | end 273 | end 274 | end 275 | -------------------------------------------------------------------------------- /lib/heatmap/rails/version.rb: -------------------------------------------------------------------------------- 1 | module Heatmap 2 | module Rails 3 | VERSION = "0.2.0" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/heatmap_rails_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'heatmap/rails/helper' 3 | 4 | RSpec.describe Heatmap do 5 | it "has a version number" do 6 | expect(Heatmap::Rails::VERSION).not_to be nil 7 | end 8 | end 9 | 10 | RSpec.describe Heatmap::Helper do 11 | def run_save_heatmap 12 | Heatmap::Helper.save_heatmap({move: 10, click: 2, html_element: '#test'}) 13 | end 14 | let(:processed_message) { run_save_heatmap() } 15 | end 16 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "heatmap/rails/helper" 3 | 4 | RSpec.configure do |config| 5 | # Enable flags like --only-failures and --next-failure 6 | config.example_status_persistence_file_path = ".rspec_status" 7 | 8 | # Disable RSpec exposing methods globally on `Module` and `main` 9 | config.disable_monkey_patching! 10 | 11 | config.expect_with :rspec do |c| 12 | c.syntax = :expect 13 | end 14 | end 15 | --------------------------------------------------------------------------------