Web資料視覺化-手把手教你實現熱力圖

多多洛愛學習發表於2019-01-13

熱力圖簡介

位置資料是連線線上線下的重要資訊資源,在前端藉助於圖形化的手段將資料有效呈現是進行資料分析的重要手段。基於此,我們開發了基於地圖的資料視覺化元件,以附加庫的形式加入到JSAPI中,目前主要包括熱力圖、散點圖、區域圖、遷徙圖。

視覺化元件

熱力圖是以顏色來表現資料強弱大小及分佈趨勢的視覺化型別,如上圖左上角所示,熱力圖可應用於人口密度分析、活躍度分析等。呈現熱力圖的資料主要包括離散的座標點及對應的強弱數值。

熱力圖實現

資料準備

本文只關心熱力圖的基礎實現,無論你是用於地圖,還是網頁焦點分析還是其他場景,均需將對應場景的座標轉化為Canvas畫布上的二維座標,最終我們需要的資料格式如下:

// x, y 表示二維座標; value表示強弱值
var data = [
	{x: 471, y: 277, value: 25},
	{x: 438, y: 375, value: 97},
	{x: 373, y: 19, value: 71},
	{x: 473, y: 42, value: 63},
	{x: 463, y: 95, value: 97},
	{x: 590, y: 437, value: 34},
	{x: 377, y: 442, value: 66},
	{x: 171, y: 254, value: 20},
	{x: 6, y: 582, value: 64},
	{x: 387, y: 477, value: 14},
	{x: 300, y: 300, value: 80}
];
複製程式碼

實現原理

讓我們從結果來反推我們應該如何實現熱力圖。

熱力圖原理
我們可以直觀的感受到:

  1. 在熱力圖中,每個資料點所呈現的是一個填充了徑向漸變色的圓形(所謂徑向漸變即由圓心隨著半徑增加而逐漸變化),而這個漸變圓表現的是資料由強變弱的輻射效果
  2. 兩個圓之間可以相互疊加,且是線性的疊加,其實質表現的是資料強弱的疊加
  3. 資料強弱的數值與顏色一一對映,一般表現為紅強藍弱的線性漸變,當然你也可以設計自己的強度色譜

根據我們的直觀感受,我們需要做的是:

  1. 將每一個資料對映為一個圓形
  2. 選定一個線性維度表示資料強弱,顏色以該維度進行漸變並填充圓形
  3. 將圓形進行疊加
  4. 以強度色譜進行顏色對映

以上步驟中需要注意的是第2步,我們並非直接以強度色譜填充圓形,因為這樣得到的顏色是3個維度的,在疊加的時候不是線性的。本文選取了alpha即顏色中的透明度作為表示強弱的維度,你也可以選取r或者g或者其他,後文會解釋選擇alpha的好處。

動手實現

繪製圓形

Canvas 中繪製弧線或者圓形可以使用arc()方法:

arc(x, y, radius, startAngle, endAngle, anticlockwise)
複製程式碼

xy對應到資料的座標,radius可自由設定,startAngleendAngle表示起止角度,分別取02 * Math.PIanticlockwise表示是否逆時針,可不設定。

漸變色

Canvas 中可以使用canvasGradient物件建立漸變色,分為直線漸變createLinearGradient(x1, y1, x2, y2)和徑向漸變createRadialGradient(x1, y1, r1, x2, y2, r2),我們採用後者。建立徑向漸變色需要定義兩個圓,顏色在兩個圓之間的區域進行漸變,故而我們將兩個圓心都設定在資料的座標點,而第一個圓半徑取0,第二個半徑同我們需要繪製的圓形半徑一致。

然後我們需要通過addColorStop(position, color)定義在兩個圓之間顏色漸變的規則。我們要達到的效果是顏色在某一個維度上的數值從中心隨半徑增加逐漸變小,而且同時,該維度的數值與資料的value正相關,否則所有資料點繪製出的圖形都會一模一樣。所以,我們選擇alpha作為變化維度,因為我們可以使用globalAlpha來設定一個全域性的透明度,這個透明度與value正相關,這樣的話我們就可以統一使用rgba(r,g,b,1)rgba(r,g,b,0)作為中心點和半徑邊緣的顏色。

那麼我們通過以下程式碼來實現以上兩個步驟:

/*
 * radius: 繪製半徑,請自行設定
 * min, max: 強弱閾值,可自行設定,也可取資料最小最大值
 */
data.forEach(point => {
    let {x, y, value} = point; 
    context.beginPath();
    context.arc(x, y, radius, 0, 2 * Math.PI);
    context.closePath();
    
    // 建立漸變色: r,g,b取值比較自由,我們只關注alpha的數值
    let radialGradient = context.createRadialGradient(x, y, 0, x, y, radius);
    radialGradient.addColorStop(0.0, "rgba(0,0,0,1)");
    radialGradient.addColorStop(1.0, "rgba(0,0,0,0)");
    context.fillStyle = radialGradient;

    // 設定globalAlpha: 需注意取值需規範在0-1之間
    let globalAlpha = (value - min) / (max - min);
    context.globalAlpha = Math.max(Math.min(globalAlpha, 1), 0);

    // 填充顏色
    context.fill();
});
複製程式碼

在示例中min為0,max為資料最大值,至此,我們得到的圖形如下:

漸變圓形

顏色對映

可見圖中的透明度已能代表資料強弱及輻射效果,且在相交處進行了線性的疊加。我們現在要給圖形上色,需要使用ImageData物件對影象進行畫素操作,讀取每個畫素點的透明度,然後使用其對映後的顏色改寫ImageData數值。

先不急著瞭解畫素操作如何進行,我們首先要確定的是透明度數值到顏色的對映關係。ImageData中的透明度數值是取值在[0, 255]之間的整數,我們要建立一個離散的對映函式,使0對應到最弱色(示例中為淺藍色,你也可以自由設定),255對應到最強色(示例中為正紅色)。而這個漸變的過程並不是單一維度的遞增,好在我們已有工具解決漸變的問題,即上文已介紹過的createLinearGradient(x1, y1, x2, y2)

調色盤

如上圖所示,我們可以建立一個跨度為 256 畫素的直線漸變色,用其填充一個 256*1 的矩形,相當於一個調色盤。在這個調色盤上(0, 0)位置的畫素呈現最弱色,(255, 0)位置的畫素呈現最強色,所以對於透明度a,(a, 0)位置的畫素顏色即為其對映顏色。程式碼如下:

const defaultColorStops = {
    0: "#0ff",
    0.2: "#0f0",
    0.4: "#ff0",
    1: "#f00",
};
const width = 20, height = 256;

function Palette(opts) {
    Object.assign(this, opts);
    this.init();
}

Palette.prototype.init = function() {
    let colorStops = this.colorStops || defaultColorStops;

    // 建立canvas
    let canvas = document.createElement("canvas");
    canvas.width = width;
    canvas.height = height;
    let ctx = canvas.getContext("2d");

    // 建立線性漸變色
    let linearGradient = ctx.createLinearGradient(0, 0, 0, height);
    for (const key in colorStops) {
        linearGradient.addColorStop(key, colorStops[key]);
    }

    // 繪製漸變色條
    ctx.fillStyle = linearGradient;
    ctx.fillRect(0, 0, width, height);

    // 讀取畫素資料
    this.imageData = ctx.getImageData(0, 0, 1, height).data;
    this.canvas = canvas;
};

/**
 * 取色器
 * @param {Number} position 畫素位置
 * @return {Array.<Number>} [r, g, b]
 */
Palette.prototype.colorPicker = function(position) {
    return this.imageData.slice(position * 4, position * 4 + 3);
};
複製程式碼

畫素著色

簡單介紹一下ImageData物件,其儲存著Canvas物件真實的畫素資料,包括width, height, data三個屬性。我們可以:

  • 通過createImageData(anotherImageData | width, height)來建立一個新物件
  • 或者getImageData(left, top, width, height)來建立帶有Canvas畫布中特定區域的畫素資料的物件
  • 使用putImageData(myImageData, left, top)來向Canvas畫布寫入畫素資料

基於此,我們先獲取畫布資料,遍歷畫素點讀取透明度,獲取透明度對映顏色,改寫畫素資料並最終寫入畫布即可。

// 畫素著色
let imageData = context.getImageData(0, 0, width, height);
let data = imageData.data;
for (var i = 3; i < data.length; i+=4) {
    let alpha = data[i];
    let color = palette.colorPicker(alpha);
    data[i - 3] = color[0];
    data[i - 2] = color[1];
    data[i - 1] = color[2];
}
context.putImageData(imageData, 0, 0);
複製程式碼

至此,我們已經完成了熱力圖的繪製,看看效果吧:

熱力圖

效能優化

離屏渲染

如果我們在地圖上呈現熱力圖,隨著地圖的移動,資料點的座標會變化,但其對應的圓形影象其實是不變的。所以為了避免更新座標時重複地建立漸變色、設定globalAlpha、繪製及填充顏色等,我們可以預先繪製好每個資料點的影象,通過一個不在文件流中的Canvas儲存下來,在重新渲染的時候通過drawImage將其繪製到畫布上:

function Radiation(opts) {
    Object.assign(this, opts);
    this.init();
}

Radiation.prototype.init = function() {
    let {radius, globalAlpha} = this;

    // 建立canvas
    let canvas = document.createElement("canvas");
    canvas.width = canvas.height = radius * 2;
    
    // 獲取上下文,初始化設定
    let ctx = canvas.getContext("2d");
    ctx.translate(radius, radius);
    ctx.globalAlpha = Math.max(Math.min(globalAlpha, 1), 0);
    
    // 建立徑向漸變色:灰度由強到弱
    let radialGradient = ctx.createRadialGradient(0, 0, 0, 0, 0, radius);
    radialGradient.addColorStop(0.0, "rgba(0,0,0,1)");
    radialGradient.addColorStop(1.0, "rgba(0,0,0,0)");
    ctx.fillStyle = radialGradient;
    
    // 畫圓
    ctx.arc(0, 0, radius, 0, Math.PI * 2);
    ctx.fill();

    this.canvas = canvas;
};

Radiation.prototype.draw = function(context) {
    let {canvas, x, y, radius} = this;
    context.drawImage(canvas, x - radius, y - radius);
};
複製程式碼

2019.1.14更新:經過效能測試發現,上文所述有誤。離屏渲染主要應用於區域性繪製過程較複雜,而該區域性又被重複繪製的場景下;同時應保證這個離屏的畫布大小適中,因為複製過大的畫布會帶來很大的效能損耗。在上文中,區域性繪製過程其實很簡單,與直接使用drawImage的耗時相差無幾,所以無需使用離屏渲染。

避免浮點數座標

使用drawImage時如果使用了浮點數座標,瀏覽器為了達到抗鋸齒的效果,會做額外計算,渲染子畫素。所以儘量使用整數座標。

相關文章