2021-12-24
Heatmap.js 是目前應用最廣的web動態熱圖javaScript庫。heatmap使用 canvas 進行繪制。
一、傳送門
Heatmap官網:https://www.patrick-wied.at/static/heatmapjs/
github下載: https://github.com/pa7/heatmap.js
二、代碼結構
1、整個js庫包裹在一個立即執行的匿名函數里,以避免污染全局命名空間。這也是很多js庫的常見寫法。
2、核心對象有三個:Store(數據)、Canvas2dRenderer(繪制工具)、HeatMap(構建器)。
3、通過global['h337']暴露創建工廠。
三、熱力圖渲染原理
以 heatmap.js v2.0.5 為例子; heatmap使用 canvas 進行繪制。
Heatmap.js 最重要的4個點: _getPointTemplate, _getColorPalette , _drawAlpha , _colorize
3.1、點模板 _getPointTemplate,設置單點渲染模板
點模板對應熱力圖數據點。它是一個圓點,根據可配置的模糊因子(blurFactor,默認.85),可使圓點帶有模糊效果(借助createRadialGradient)。
主要是調用 canvas 的 createRadialGradient 方法。核心方法是canvas的createRadialGradient方法,每個點設置渲染半徑,由漸變因子 blur 確定內圓比例,內圓與外圓的圓周間進行無色的放射漸變,達到中間透明度高,邊緣透明度低的效果。這個無色的透明度漸變的圓形即為點的模板。
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; };
用 html canvas代碼測試效果,代碼如下:

<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>_getPointTemplate</title> <style> /*canvas {border:1px solid black;}*/ </style> </head> <body> <div class="heatmap"> </div> <script> window.onload = function () { var container = document.querySelector('.heatmap'); var p1 = _getPointTemplate(40, 0.85); //document.body.appendChild(p1); container.appendChild(p1); } 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; }; </script> </body> </html>
當 radius=50, blurFactor = 0.85 ,測試效果如下:
當 radius=50, blurFactor = 0.5 ,測試效果如下:
3.2、線性色譜 _getColorPalette , 構建0到256的調色板
通過createLinearGradient你可以自主定制自己的熱力圖色譜(config.gradient)。
主要是調用 canvas 的 createLinearGradient 方法。核心方法是canvas的createLinearGradient方法。
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; };
用 html canvas代碼測試效果,代碼如下:

<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>_getColorPalette</title> <style> /*canvas {border:1px solid black;}*/ </style> </head> <body> <div class="heatmap"> </div> <script> window.onload = function () { var container = document.querySelector('.heatmap'); /*var HeatmapConfig = { defaultRadius: 40, defaultRenderer: 'canvas2d', defaultGradient: { 0.25: "rgb(0,0,255)", 0.55: "rgb(0,255,0)", 0.85: "yellow", 1.0: "rgb(255,0,0)"}, defaultMaxOpacity: 1, defaultMinOpacity: 0, defaultBlur: .85, defaultXField: 'x', defaultYField: 'y', defaultValueField: 'value', plugins: {} };*/ var config = { defaultGradient: { 0.25: "rgb(0,0,255)", 0.55: "rgb(0,255,0)", 0.85: "yellow", 1.0: "rgb(255,0,0)"} } var imgData = _getColorPalette(config); var c=document.createElement("canvas"); container.appendChild(c); var ctx=c.getContext("2d"); var img = ctx.getImageData(0, 0, 256, 1) //img.data = imgData; for (let i = 0; i < img.data.length; i++) { img.data[i] = imgData[i]; } ctx.putImageData(img, 0, 0); } 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; }; </script> </body> </html>
當 var gradientConfig = { 0.25: "rgb(0,0,255)", 0.55: "rgb(0,255,0)", 0.85: "yellow", 1.0: "rgb(255,0,0)"};,測試效果如下:
3.3、灰度(透明度)疊加 _drawAlpha, 根據模板繪制全部點
這個熱力圖的"靈魂"。rgb通道是無法線性疊加呈現效果的,但是透明度是近似線性的。var templateAlpha = (value-min)/(max-min);
,根據數據點的比率,對應於透明度的值alpha,我們在canvas上(shadowCtx)繪制一個數據點。它們的透明度是可以疊加的,值越大,越"不透明"。
主要是調用 canvas 的 drawImage方法。核心方法是canvas的drawImage方法,在每個點的位置按照模板進行繪制,達到重疊部分透明度疊加的效果。
_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; } } },
用 html canvas代碼測試效果,代碼如下:

<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>_drawAlpha</title> <style> /*canvas {border:1px solid black;}*/ </style> </head> <body> <div class="heatmap"> </div> <script> window.onload = function () { var container = document.querySelector('.heatmap'); var shadowCanvas = this.shadowCanvas = document.createElement('canvas'); this._width = shadowCanvas.width = 900; this._height = shadowCanvas.height = 900; this.shadowCtx = shadowCanvas.getContext('2d'); shadowCanvas.style.cssText = 'position:absolute;left:0;top:0;'; container.style.position = 'relative'; container.appendChild(shadowCanvas); var renderBoundaries = this._renderBoundaries = [10000, 10000, 0, 0]; // this._min // this._max this._blur = 0.85; this._templates = {}; var data = { min: 0, max: 10, // x坐標, y坐標, value值, radius圓半徑 data: [{x: 10, y: 15, value: 5, radius: 40}, {x: 130, y: 170, value: 8, radius: 40}, {x: 200, y: 250, value: 10, radius: 40}, {x: 300, y: 450, value: 5, radius: 40}, {x: 300, y: 450, value: 5, radius: 40}] }; _drawAlpha(data); } 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; }; var _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; } } } </script> </body> </html>
測試效果如下:
3.4、着色 _colorize
最后,透明度的疊加值(this.shadowCtx.getImageData)映射到線性色譜(palette),取線性色譜中的顏色為canvas上色(putImageData)就得到最終的熱力圖了。
主要是調用 canvas 的 putImageData方法。核心方法是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]; },
用 html canvas代碼測試效果,代碼如下:

<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>_colorize</title> <style> /*canvas {border:1px solid black;}*/ </style> </head> <body> <div class="heatmap"> </div> <script> window.onload = function () { var container = document.querySelector('.heatmap'); var shadowCanvas = this.shadowCanvas = document.createElement('canvas'); var canvas = this.canvas = document.createElement('canvas'); var renderBoundaries = this._renderBoundaries = [10000, 10000, 0, 0]; canvas.className = 'heatmap-canvas'; this._width = canvas.width = shadowCanvas.width = 900; this._height = canvas.height = shadowCanvas.height = 900; this.shadowCtx = shadowCanvas.getContext('2d'); this.ctx = canvas.getContext('2d'); canvas.style.cssText = shadowCanvas.style.cssText = 'position:absolute;left:0;top:0;'; container.style.position = 'relative'; container.appendChild(canvas); var config = { defaultGradient: { 0.25: "rgb(0,0,255)", 0.55: "rgb(0,255,0)", 0.85: "yellow", 1.0: "rgb(255,0,0)"} } this._palette = _getColorPalette(config); this._templates = {}; var opacity = this._opacity = 0.8; var maxOpacity = this._maxOpacity = 1; var minOpacity = this._minOpacity = 0; var useGradientOpacity = this._useGradientOpacity = true; // this._min // this._max this._blur = 0.85; var data = { min: 0, max: 10, // x坐標, y坐標, value值, radius圓半徑 data: [{x: 10, y: 15, value: 5, radius: 40}, {x: 130, y: 170, value: 8, radius: 40}, {x: 200, y: 250, value: 10, radius: 40}, {x: 300, y: 450, value: 5, radius: 40}, {x: 300, y: 450, value: 5, radius: 40}] }; _drawAlpha(data); _colorize(); }; 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; }; 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; }; var _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; } } }; var _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]; }; </script> </body> </html>
測試效果如下:
當然還有很多其他的方法,這里不一一介紹了。
四、調用 heatmap.js 生成熱力圖
首先引入 heatmap.js 文件:
官網:https://www.patrick-wied.at/static/heatmapjs/
github:https://github.com/pa7/heatmap.js
代碼如下:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Title</title> <!-- https://www.patrick-wied.at/static/heatmapjs/docs.html --> <!-- <link rel="stylesheet" type="text/css" href="style.css">--> <!--<script src="../../lib/heatmap.js-master/build/heatmap.min.js"></script>--> <script src="https://cdn.bootcdn.net/ajax/libs/heatmap.js/2.0.0/heatmap.min.js"></script> <style> div { width: 100%; height: 900px; /*border-style:solid;*/ /*border-color:red;*/ border:2px solid Orange; } </style> </head> <body> <div id="heatmap"></div> <script type="text/javascript"> /* h337”是heatmap.js注冊的全局對象的名字。您可以使用它來創建熱圖實例 h337.create(configObject) 返回一個heatmapInstance。 使用 h337.create 創建熱圖實例。可以使用 configObject 自定義熱圖。 configObject 參數是必需的。 */ var heatmap = h337.create({ // 可能的配置屬性: // container (DOMNode) *必需* // A DOM node where the heatmap canvas should be appended (heatmap will adapt to the node's size) // 應附加熱圖畫布的 DOM 節點(熱圖將適應節點的大小) container: document.getElementById("heatmap"), // 背景色 // backgroundColor (string) *optional* // A background color string in form of hexcode, color name, or rgb(a) // 十六進制代碼、顏色名稱或 rgb(a) 形式的背景顏色字符串 //backgroundColor: "#f3f3f3", //backgroundColor: "rgb(240, 240, 240)", backgroundColor: "rgba(240, 240, 240, 0.2)", // gradient (object) *可選* // An object that represents the gradient (syntax: number string [0,1] : color string), check out the example // 表示漸變的對象(語法:數字字符串[0,1]:顏色字符串),查看示例 gradient: { 0.25: "rgb(0,0,255)", 0.55: "rgb(0,255,0)", 0.85: "yellow", 1.0: "rgb(255,0,0)"}, // radius (number) *可選* // The radius each datapoint will have (if not specified on the datapoint itself) // 每個數據點將具有的半徑(如果未在數據點本身上指定) radius: 20, // opacity (number) [0,1] *可選* default = .6 // A global opacity for the whole heatmap. This overrides maxOpacity and minOpacity if set! // 整個熱圖的全局不透明度。如果設置,這將覆蓋 maxOpacity 和 minOpacity! // opacity: .6, // maxOpacity (number) [0,1] *可選* // The maximal opacity the highest value in the heatmap will have. (will be overridden if opacity set) // 熱圖中最高值的最大不透明度。(如果設置不透明度將被覆蓋) maxOpacity: 1, // minOpacity(number) [0,1] *可選* // The minimum opacity the lowest value in the heatmap will have (will be overridden if opacity set) // 熱圖中最低值的最小不透明度(如果設置了不透明度,將被覆蓋) minOpacity: 0, // onExtremaChange function callback 函數回調 // Pass a callback to receive extrema change updates. Useful for DOM legends. // 傳遞回調以接收極值更改更新。對 DOM 圖例很有用。 // onExtremaChange // blur (number) [0,1] *可選* default = 0.85 // // 將應用於所有數據點的模糊因子。模糊系數越高,漸變就越平滑 // xField (string) *可選* default = "x" // // 數據點中 x 坐標的屬性名稱 xField: "x", // yField (string) *optional* default = "y" // // 數據點中 y 坐標的屬性名稱 yField: "y", // valueField (string) *optional* default = "value" // // 數據點中 y 坐標的屬性名稱 valueField: "value" }); heatmap.setData({ min: 0, max: 10, data: [{x: 10, y: 15, value: 5, radius: 40}, {x: 130, y: 170, value: 8, radius: 40}, {x: 200, y: 250, value: 10, radius: 40}, {x: 300, y: 450, value: 5, radius: 40}, {x: 300, y: 450, value: 5, radius: 40}] //data: [{ x: 50, y: 75, value: 5, radius: 40}, {x: 200, y: 350, value: 3, radius: 10}] }); </script> </body> </html>
結果如下:
參考: