/* * heatmap.js 1.0 - JavaScript Heatmap Library * * Copyright (c) 2011, Patrick Wied (http://www.patrick-wied.at) * Dual-licensed under the MIT (http://www.opensource.org/licenses/mit-license.php) * and the Beerware (http://en.wikipedia.org/wiki/Beerware) license. */ (function(w){ // the heatmapFactory creates heatmap instances var heatmapFactory = (function(){ // store object constructor // a heatmap contains a store // the store has to know about the heatmap in order to trigger heatmap updates when datapoints get added var store = function store(hmap){ var _ = { // data is a two dimensional array // a datapoint gets saved as data[point-x-value][point-y-value] // the value at [point-x-value][point-y-value] is the occurrence of the datapoint data: [], // tight coupling of the heatmap object heatmap: hmap }; // the max occurrence - the heatmaps radial gradient alpha transition is based on it this.max = 1; this.get = function(key){ return _[key]; }; this.set = function(key, value){ _[key] = value; }; } store.prototype = { // function for adding datapoints to the store // datapoints are usually defined by x and y but could also contain a third parameter which represents the occurrence addDataPoint: function(x, y){ if(x < 0 || y < 0) return; var me = this, heatmap = me.get("heatmap"), data = me.get("data"); if(!data[x]) data[x] = []; if(!data[x][y]) data[x][y] = 0; // if count parameter is set increment by count otherwise by 1 data[x][y]+=(arguments.length<3)?1:arguments[2]; me.set("data", data); // do we have a new maximum? if(me.max < data[x][y]){ // max changed, we need to redraw all existing(lower) datapoints heatmap.get("actx").clearRect(0,0,heatmap.get("width"),heatmap.get("height")); me.setDataSet({ max: data[x][y], data: data }, true); return; } heatmap.drawAlpha(x, y, data[x][y], true); }, setDataSet: function(obj, internal){ var me = this, heatmap = me.get("heatmap"), data = [], d = obj.data, dlen = d.length; // clear the heatmap before the data set gets drawn heatmap.clear(); this.max = obj.max; // if a legend is set, update it heatmap.get("legend") && heatmap.get("legend").update(obj.max); if(internal != null && internal){ for(var one in d){ // jump over undefined indexes if(one === undefined) continue; for(var two in d[one]){ if(two === undefined) continue; // if both indexes are defined, push the values into the array heatmap.drawAlpha(one, two, d[one][two], false); } } }else{ while(dlen--){ var point = d[dlen]; heatmap.drawAlpha(point.x, point.y, point.count, false); if(!data[point.x]) data[point.x] = []; if(!data[point.x][point.y]) data[point.x][point.y] = 0; data[point.x][point.y] = point.count; } } heatmap.colorize(); this.set("data", d); }, exportDataSet: function(){ var me = this, data = me.get("data"), exportData = []; for(var one in data){ // jump over undefined indexes if(one === undefined) continue; for(var two in data[one]){ if(two === undefined) continue; // if both indexes are defined, push the values into the array exportData.push({x: parseInt(one, 10), y: parseInt(two, 10), count: data[one][two]}); } } return { max: me.max, data: exportData }; }, generateRandomDataSet: function(points){ var heatmap = this.get("heatmap"), w = heatmap.get("width"), h = heatmap.get("height"); var randomset = {}, max = Math.floor(Math.random()*1000+1); randomset.max = max; var data = []; while(points--){ data.push({x: Math.floor(Math.random()*w+1), y: Math.floor(Math.random()*h+1), count: Math.floor(Math.random()*max+1)}); } randomset.data = data; this.setDataSet(randomset); } }; var legend = function legend(config){ this.config = config; var _ = { element: null, labelsEl: null, gradientCfg: null, ctx: null }; this.get = function(key){ return _[key]; }; this.set = function(key, value){ _[key] = value; }; this.init(); }; legend.prototype = { init: function(){ var me = this, config = me.config, title = config.title || "Legend", position = config.position, offset = config.offset || 10, gconfig = config.gradient, labelsEl = document.createElement("ul"), labelsHtml = "", grad, element, gradient, positionCss = ""; me.processGradientObject(); // Positioning // top or bottom if(position.indexOf('t') > -1){ positionCss += 'top:'+offset+'px;'; }else{ positionCss += 'bottom:'+offset+'px;'; } // left or right if(position.indexOf('l') > -1){ positionCss += 'left:'+offset+'px;'; }else{ positionCss += 'right:'+offset+'px;'; } element = document.createElement("div"); element.style.cssText = "border-radius:5px;position:absolute;"+positionCss+"font-family:Helvetica; width:256px;z-index:10000000000; background:rgba(255,255,255,1);padding:10px;border:1px solid black;margin:0;"; element.innerHTML = "

"+title+"

"; // create gradient in canvas labelsEl.style.cssText = "position:relative;font-size:12px;display:block;list-style:none;list-style-type:none;margin:0;height:15px;"; // create gradient element gradient = document.createElement("div"); gradient.style.cssText = ["position:relative;display:block;width:256px;height:15px;border-bottom:1px solid black; background-image:url(",me.createGradientImage(),");"].join(""); element.appendChild(labelsEl); element.appendChild(gradient); me.set("element", element); me.set("labelsEl", labelsEl); me.update(1); }, processGradientObject: function(){ // create array and sort it var me = this, gradientConfig = this.config.gradient, gradientArr = []; for(var key in gradientConfig){ if(gradientConfig.hasOwnProperty(key)){ gradientArr.push({ stop: key, value: gradientConfig[key] }); } } gradientArr.sort(function(a, b){ return (a.stop - b.stop); }); gradientArr.unshift({ stop: 0, value: 'rgba(0,0,0,0)' }); me.set("gradientArr", gradientArr); }, createGradientImage: function(){ var me = this, gradArr = me.get("gradientArr"), length = gradArr.length, canvas = document.createElement("canvas"), ctx = canvas.getContext("2d"), grad; // the gradient in the legend including the ticks will be 256x15px canvas.width = "256"; canvas.height = "15"; grad = ctx.createLinearGradient(0,5,256,10); for(var i = 0; i < length; i++){ grad.addColorStop(1/(length-1) * i, gradArr[i].value); } ctx.fillStyle = grad; ctx.fillRect(0,5,256,10); ctx.strokeStyle = "black"; ctx.beginPath(); for(var i = 0; i < length; i++){ ctx.moveTo(((1/(length-1)*i*256) >> 0)+.5, 0); ctx.lineTo(((1/(length-1)*i*256) >> 0)+.5, (i==0)?15:5); } ctx.moveTo(255.5, 0); ctx.lineTo(255.5, 15); ctx.moveTo(255.5, 4.5); ctx.lineTo(0, 4.5); ctx.stroke(); // we re-use the context for measuring the legends label widths me.set("ctx", ctx); return canvas.toDataURL(); }, getElement: function(){ return this.get("element"); }, update: function(max){ var me = this, gradient = me.get("gradientArr"), ctx = me.get("ctx"), labels = me.get("labelsEl"), labelText, labelsHtml = "", offset; for(var i = 0; i < gradient.length; i++){ labelText = max*gradient[i].stop >> 0; offset = (ctx.measureText(labelText).width/2) >> 0; if(i == 0){ offset = 0; } if(i == gradient.length-1){ offset *= 2; } labelsHtml += '
  • '+labelText+'
  • '; } labels.innerHTML = labelsHtml; } }; // heatmap object constructor var heatmap = function heatmap(config){ // private variables var _ = { radius : 40, element : {}, canvas : {}, acanvas: {}, ctx : {}, actx : {}, legend: null, visible : true, width : 0, height : 0, max : false, gradient : false, opacity: 180, premultiplyAlpha: false, bounds: { l: 1000, r: 0, t: 1000, b: 0 }, debug: false }; // heatmap store containing the datapoints and information about the maximum // accessible via instance.store this.store = new store(this); this.get = function(key){ return _[key]; }; this.set = function(key, value){ _[key] = value; }; // configure the heatmap when an instance gets created this.configure(config); // and initialize it this.init(); }; // public functions heatmap.prototype = { configure: function(config){ var me = this, rout, rin; me.set("radius", config["radius"] || 40); me.set("element", (config.element instanceof Object)?config.element:document.getElementById(config.element)); me.set("visible", (config.visible != null)?config.visible:true); me.set("max", config.max || false); me.set("gradient", config.gradient || { 0.45: "rgb(0,0,255)", 0.55: "rgb(0,255,255)", 0.65: "rgb(0,255,0)", 0.95: "yellow", 1.0: "rgb(255,0,0)"}); // default is the common blue to red gradient me.set("opacity", parseInt(255/(100/config.opacity), 10) || 180); me.set("width", config.width || 0); me.set("height", config.height || 0); me.set("debug", config.debug); if(config.legend){ var legendCfg = config.legend; legendCfg.gradient = me.get("gradient"); me.set("legend", new legend(legendCfg)); } }, resize: function () { var me = this, element = me.get("element"), canvas = me.get("canvas"), acanvas = me.get("acanvas"); canvas.width = acanvas.width = me.get("width") || element.style.width.replace(/px/, "") || me.getWidth(element); this.set("width", canvas.width); canvas.height = acanvas.height = me.get("height") || element.style.height.replace(/px/, "") || me.getHeight(element); this.set("height", canvas.height); }, init: function(){ var me = this, canvas = document.createElement("canvas"), acanvas = document.createElement("canvas"), ctx = canvas.getContext("2d"), actx = acanvas.getContext("2d"), element = me.get("element"); me.initColorPalette(); me.set("canvas", canvas); me.set("ctx", ctx); me.set("acanvas", acanvas); me.set("actx", actx); me.resize(); canvas.style.cssText = acanvas.style.cssText = "position:absolute;top:0;left:0;z-index:10000000;"; if(!me.get("visible")) canvas.style.display = "none"; element.appendChild(canvas); if(me.get("legend")){ element.appendChild(me.get("legend").getElement()); } // debugging purposes only if(me.get("debug")) document.body.appendChild(acanvas); actx.shadowOffsetX = 15000; actx.shadowOffsetY = 15000; actx.shadowBlur = 15; }, initColorPalette: function(){ var me = this, canvas = document.createElement("canvas"), gradient = me.get("gradient"), ctx, grad, testData; canvas.width = "1"; canvas.height = "256"; ctx = canvas.getContext("2d"); grad = ctx.createLinearGradient(0,0,1,256); // Test how the browser renders alpha by setting a partially transparent pixel // and reading the result. A good browser will return a value reasonably close // to what was set. Some browsers (e.g. on Android) will return a ridiculously wrong value. testData = ctx.getImageData(0,0,1,1); testData.data[0] = testData.data[3] = 64; // 25% red & alpha testData.data[1] = testData.data[2] = 0; // 0% blue & green ctx.putImageData(testData, 0, 0); testData = ctx.getImageData(0,0,1,1); me.set("premultiplyAlpha", (testData.data[0] < 60 || testData.data[0] > 70)); for(var x in gradient){ grad.addColorStop(x, gradient[x]); } ctx.fillStyle = grad; ctx.fillRect(0,0,1,256); me.set("gradient", ctx.getImageData(0,0,1,256).data); }, getWidth: function(element){ var width = element.offsetWidth; if(element.style.paddingLeft){ width+=element.style.paddingLeft; } if(element.style.paddingRight){ width+=element.style.paddingRight; } return width; }, getHeight: function(element){ var height = element.offsetHeight; if(element.style.paddingTop){ height+=element.style.paddingTop; } if(element.style.paddingBottom){ height+=element.style.paddingBottom; } return height; }, colorize: function(x, y){ // get the private variables var me = this, width = me.get("width"), radius = me.get("radius"), height = me.get("height"), actx = me.get("actx"), ctx = me.get("ctx"), x2 = radius * 3, premultiplyAlpha = me.get("premultiplyAlpha"), palette = me.get("gradient"), opacity = me.get("opacity"), bounds = me.get("bounds"), left, top, bottom, right, image, imageData, length, alpha, offset, finalAlpha; if(x != null && y != null){ if(x+x2>width){ x=width-x2; } if(x<0){ x=0; } if(y<0){ y=0; } if(y+x2>height){ y=height-x2; } left = x; top = y; right = x + x2; bottom = y + x2; }else{ if(bounds['l'] < 0){ left = 0; }else{ left = bounds['l']; } if(bounds['r'] > width){ right = width; }else{ right = bounds['r']; } if(bounds['t'] < 0){ top = 0; }else{ top = bounds['t']; } if(bounds['b'] > height){ bottom = height; }else{ bottom = bounds['b']; } } image = actx.getImageData(left, top, right-left, bottom-top); imageData = image.data; length = imageData.length; // loop thru the area for(var i=3; i < length; i+=4){ // [0] -> r, [1] -> g, [2] -> b, [3] -> alpha alpha = imageData[i], offset = alpha*4; if(!offset) continue; // we ve started with i=3 // set the new r, g and b values finalAlpha = (alpha < opacity)?alpha:opacity; imageData[i-3]=palette[offset]; imageData[i-2]=palette[offset+1]; imageData[i-1]=palette[offset+2]; if (premultiplyAlpha) { // To fix browsers that premultiply incorrectly, we'll pass in a value scaled // appropriately so when the multiplication happens the correct value will result. imageData[i-3] /= 255/finalAlpha; imageData[i-2] /= 255/finalAlpha; imageData[i-1] /= 255/finalAlpha; } // we want the heatmap to have a gradient from transparent to the colors // as long as alpha is lower than the defined opacity (maximum), we'll use the alpha value imageData[i] = finalAlpha; } // the rgb data manipulation didn't affect the ImageData object(defined on the top) // after the manipulation process we have to set the manipulated data to the ImageData object image.data = imageData; ctx.putImageData(image, left, top); }, drawAlpha: function(x, y, count, colorize){ // storing the variables because they will be often used var me = this, radius = me.get("radius"), ctx = me.get("actx"), max = me.get("max"), bounds = me.get("bounds"), xb = x - (1.5 * radius) >> 0, yb = y - (1.5 * radius) >> 0, xc = x + (1.5 * radius) >> 0, yc = y + (1.5 * radius) >> 0; ctx.shadowColor = ('rgba(0,0,0,'+((count)?(count/me.store.max):'0.1')+')'); ctx.shadowOffsetX = 15000; ctx.shadowOffsetY = 15000; ctx.shadowBlur = 15; ctx.beginPath(); ctx.arc(x - 15000, y - 15000, radius, 0, Math.PI * 2, true); ctx.closePath(); ctx.fill(); if(colorize){ // finally colorize the area me.colorize(xb,yb); }else{ // or update the boundaries for the area that then should be colorized if(xb < bounds["l"]){ bounds["l"] = xb; } if(yb < bounds["t"]){ bounds["t"] = yb; } if(xc > bounds['r']){ bounds['r'] = xc; } if(yc > bounds['b']){ bounds['b'] = yc; } } }, toggleDisplay: function(){ var me = this, visible = me.get("visible"), canvas = me.get("canvas"); if(!visible) canvas.style.display = "block"; else canvas.style.display = "none"; me.set("visible", !visible); }, // dataURL export getImageData: function(){ return this.get("canvas").toDataURL(); }, clear: function(){ var me = this, w = me.get("width"), h = me.get("height"); me.store.set("data",[]); // @TODO: reset stores max to 1 //me.store.max = 1; me.get("ctx").clearRect(0,0,w,h); me.get("actx").clearRect(0,0,w,h); }, cleanup: function(){ var me = this; me.get("element").removeChild(me.get("canvas")); } }; return { create: function(config){ return new heatmap(config); }, util: { mousePosition: function(ev){ // this doesn't work right // rather use /* // this = element to observe var x = ev.pageX - this.offsetLeft; var y = ev.pageY - this.offsetTop; */ var x, y; if (ev.layerX) { // Firefox x = ev.layerX; y = ev.layerY; } else if (ev.offsetX) { // Opera x = ev.offsetX; y = ev.offsetY; } if(typeof(x)=='undefined') return; return [x,y]; } } }; })(); w.h337 = w.heatmapFactory = heatmapFactory; })(window);