heatmap.js(热力图)源码解读

5,734 阅读4分钟

前端阅读室
heatmap.js (github.com/pa7/heatmap…)可以很方便地绘制热力图,官网首页www.patrick-wied.at/static/heat…) 给出的代码示例:

var heatmap = h337.create({
  container: domElement
});

heatmap.setData({
  max: 5,
  data: [{ x: 10, y: 15, value: 5}, ...]
});

那么它是如何实现热力图绘制的呢?本文将为你全面解读heatmap.js源码。

热力图原理

点模板

点模板对应热力图数据点。它是一个圆点,根据可配置的模糊因子(blurFactor,默认.85),可使圆点带有模糊效果(借助createRadialGradient)。

var _getPointTemplate = function(radius, blurFactor) {
	var tplCanvas = document.createElement('canvas');
	var tplCtx = tplCanvas.getContext('2d');
	var x = radius;
	var y = radius;
	tplCanvas.width = tplCanvas.height = radius*2;

	if (blurFactor == 1) {
		tplCtx.beginPath();
		tplCtx.arc(x, y, radius, 0, 2 * Math.PI, false);
		tplCtx.fillStyle = 'rgba(0,0,0,1)';
		tplCtx.fill();
	} else {
		var gradient = tplCtx.createRadialGradient(x, y, radius*blurFactor, x, y, radius);
		gradient.addColorStop(0, 'rgba(0,0,0,1)');
		gradient.addColorStop(1, 'rgba(0,0,0,0)');
		tplCtx.fillStyle = gradient;
		tplCtx.fillRect(0, 0, 2*radius, 2*radius);
	}



	return tplCanvas;
};

灰度(透明度)叠加

这个热力图的"灵魂"。rgb通道是无法线性叠加呈现效果的,但是透明度是近似线性的。var templateAlpha = (value-min)/(max-min);,根据数据点的比率,对应于透明度的值alpha,我们在canvas上(shadowCtx)绘制一个数据点。它们的透明度是可以叠加的,值越大,越"不透明"。

_drawAlpha: function(data) {
	var min = this._min = data.min;
	var max = this._max = data.max;
	var data = data.data || [];
	var dataLen = data.length;
	// on a point basis?
	var blur = 1 - this._blur;

	while(dataLen--) {

		var point = data[dataLen];

		var x = point.x;
		var y = point.y;
		var radius = point.radius;
		// if value is bigger than max
		// use max as value
		var value = Math.min(point.value, max);
		var rectX = x - radius;
		var rectY = y - radius;
		var shadowCtx = this.shadowCtx;




		var tpl;
		if (!this._templates[radius]) {
			this._templates[radius] = tpl = _getPointTemplate(radius, blur);
		} else {
			tpl = this._templates[radius];
		}
		// value from minimum / value range
		// => [0, 1]
		var templateAlpha = (value-min)/(max-min);
		// this fixes #176: small values are not visible because globalAlpha < .01 cannot be read from imageData
		shadowCtx.globalAlpha = templateAlpha < .01 ? .01 : templateAlpha;

		shadowCtx.drawImage(tpl, rectX, rectY);

		// update renderBoundaries
		if (rectX < this._renderBoundaries[0]) {
				this._renderBoundaries[0] = rectX;
			}
			if (rectY < this._renderBoundaries[1]) {
				this._renderBoundaries[1] = rectY;
			}
			if (rectX + 2*radius > this._renderBoundaries[2]) {
				this._renderBoundaries[2] = rectX + 2*radius;
			}
			if (rectY + 2*radius > this._renderBoundaries[3]) {
				this._renderBoundaries[3] = rectY + 2*radius;
			}

	}
},

线性色谱

通过createLinearGradient你可以自主定制自己的热力图色谱(config.gradient)。

var _getColorPalette = function(config) {
	var gradientConfig = config.gradient || config.defaultGradient;
	var paletteCanvas = document.createElement('canvas');
	var paletteCtx = paletteCanvas.getContext('2d');

	paletteCanvas.width = 256;
	paletteCanvas.height = 1;

	var gradient = paletteCtx.createLinearGradient(0, 0, 256, 1);
	for (var key in gradientConfig) {
		gradient.addColorStop(key, gradientConfig[key]);
	}

	paletteCtx.fillStyle = gradient;
	paletteCtx.fillRect(0, 0, 256, 1);

	return paletteCtx.getImageData(0, 0, 256, 1).data;
};

着色

最后,透明度的叠加值(this.shadowCtx.getImageData)映射到线性色谱(palette),取线性色谱中的颜色为canvas上色(putImageData)就得到最终的热力图了。

_colorize: function() {
	var x = this._renderBoundaries[0];
	var y = this._renderBoundaries[1];
	var width = this._renderBoundaries[2] - x;
	var height = this._renderBoundaries[3] - y;
	var maxWidth = this._width;
	var maxHeight = this._height;
	var opacity = this._opacity;
	var maxOpacity = this._maxOpacity;
	var minOpacity = this._minOpacity;
	var useGradientOpacity = this._useGradientOpacity;

	if (x < 0) {
		x = 0;
	}
	if (y < 0) {
		y = 0;
	}
	if (x + width > maxWidth) {
		width = maxWidth - x;
	}
	if (y + height > maxHeight) {
		height = maxHeight - y;
	}

	var img = this.shadowCtx.getImageData(x, y, width, height);
	var imgData = img.data;
	var len = imgData.length;
	var palette = this._palette;


	for (var i = 3; i < len; i+= 4) {
		var alpha = imgData[i];
		var offset = alpha * 4;


		if (!offset) {
			continue;
		}

		var finalAlpha;
		if (opacity > 0) {
			finalAlpha = opacity;
		} else {
			if (alpha < maxOpacity) {
				if (alpha < minOpacity) {
					finalAlpha = minOpacity;
				} else {
					finalAlpha = alpha;
				}
			} else {
				finalAlpha = maxOpacity;
			}
		}

		imgData[i-3] = palette[offset];
		imgData[i-2] = palette[offset + 1];
		imgData[i-1] = palette[offset + 2];
		imgData[i] = useGradientOpacity ? palette[offset + 3] : finalAlpha;

	}

	img.data = imgData;
	this.ctx.putImageData(img, x, y);

	this._renderBoundaries = [1000, 1000, 0, 0];

},

其他

以上就是heatmap.js库最精华的部分了。当然,为了让库的设计更完备,它还做了很多其他工作。

Coordinator

自己实现了一个发布订阅模式来作为整个类库功能的调度者。

var Coordinator = (function CoordinatorClosure() {

function Coordinator() {
    this.cStore = {};
};

Coordinator.prototype = {
    on: function(evtName, callback, scope) {
    var cStore = this.cStore;

    if (!cStore[evtName]) {
        cStore[evtName] = [];
    }
    cStore[evtName].push((function(data) {
        return callback.call(scope, data);
    }));
    },
    emit: function(evtName, data) {
    var cStore = this.cStore;
    if (cStore[evtName]) {
        var len = cStore[evtName].length;
        for (var i=0; i<len; i++) {
        var callback = cStore[evtName][i];
        callback(data);
        }
    }
    }
};

return Coordinator;
})();

如你需要renderpartial、renderall,只需要emit就可以了。

coordinator.on('renderpartial', renderer.renderPartial, renderer);
coordinator.on('renderall', renderer.renderAll, renderer);

plugin

提供了插件接口,你可以使用heatmap.js提供的如gmaps-heatmap等各种插件。

if (config['plugin']) {
    var pluginToLoad = config['plugin'];
    if (!HeatmapConfig.plugins[pluginToLoad]) {
    throw new Error('Plugin \''+ pluginToLoad + '\' not found. Maybe it was not registered.');
    } else {
    var plugin = HeatmapConfig.plugins[pluginToLoad];
    // set plugin renderer and store
    this._renderer = new plugin.renderer(config);
    this._store = new plugin.store(config);
    }
}

Heatmap

实现了Heatmap构造方法,使用户可以通过heatmap实例调用各种功能。

Heatmap.prototype = {
    addData: function() {
    },
    removeData: function() {
    },
    setData: function() {
    },
    setDataMax: function() {
    },
    setDataMin: function() {
    },
    configure: function(config) {
    },
    repaint: function() {
    },
    getData: function() {
    },
    getDataURL: function() {
    },
    getValueAt: function(point) {
    }
};

return Heatmap;

Store

实现了自己的Store来统一管理热力图数据。

  Store.prototype = {
    // when forceRender = false -> called from setData, omits renderall event
    _organiseData: function(dataPoint, forceRender) {
    },
    _unOrganizeData: function() {
    },
    _onExtremaChange: function() {
    },
    addData: function() {
    },
    setData: function(data) {
    },
    removeData: function() {
      // TODO: implement
    },
    setDataMax: function(max) {
    },
    setDataMin: function(min) {
    },
    setCoordinator: function(coordinator) {
      this._coordinator = coordinator;
    },
    _getInternalData: function() {
      return { 
        max: this._max,
        min: this._min, 
        data: this._data,
        radi: this._radi 
      };
    },
    getData: function() {
      return this._unOrganizeData();
    }
  };

总结

以上,就是heatmap全部源码的内容(除各种plugins不一一介绍了),总体实现上其实并不复杂,但确实可以很方便地绘制热力图。基于它的原理,我们可以进行二次开发,实现定制的热力图。比如,我们一般都是通过xpath来保存无埋点数据的,而一般xpath元素并不是圆点,大部分时候它都是长方形元素。这时用圆点拟合就不太合适了,我们改造为椭圆点可以更好地拟合实际点击情况。 参见:web-log-sdk (github.com/mfaying/web…)

前端阅读室