硬核乾貨來了!鵝廠前端工程師手把手教你實現熱力圖!

如下內容轉載自騰訊位置服務公衆號的文章《硬核乾貨來了!鵝廠前端工程師手把手教你實現熱力圖!》javascript

做者:騰訊位置服務html

連接:https://mp.weixin.qq.com/s/bgS7uFlyLtK8WtusKfv8lA前端

來源:微信公衆號java

著做權歸做者全部。商業轉載請聯繫做者得到受權,非商業轉載請註明出處。web

各位小夥伴們,還記得今年年初時咱們推出的數據可視化組件嗎?《助你開啓「上帝視角」 數據可視化組件全新上線》。這些基於地圖的數據可視化組件,以附加庫的形式加入到JSAPI中,目前主要包括熱力圖、散點圖、區域圖、遷徙圖。canvas

alt

想知道這個「上帝視角」是如何開啓的嗎?想了解這些可視化組件背後的實現原理嗎?下面就讓騰訊位置服務web開發一線工程師,美貌與智慧並存的totoro同窗爲你們揭祕。數組

因爲篇幅有限,本文以熱力圖爲例,描述其背後的實現原理。瀏覽器

熱力圖簡介

熱力圖是以顏色來表現數據強弱大小及分佈趨勢的可視化類型,熱力圖可應用於人口密度分析、活躍度分析等。呈現熱力圖的數據主要包括離散的座標點及對應的強弱數值。性能優化

熱力圖實現

數據準備 本文只關心熱力圖的基礎實現,不管你是用於地圖,仍是網頁焦點分析仍是其餘場景,均需將對應場景的座標轉化爲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}
];

注:具體到使用場景,好比在地圖上應用時,須要藉助地圖API將經緯度座標轉化爲像素座標。

實現原理

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

[ 熱力圖原理 ]

咱們能夠直觀的感覺到:

一、在熱力圖中,每一個數據點所呈現的是一個填充了徑向漸變色的圓形(所謂徑向漸變即由圓心隨着半徑增長而逐漸變化),而這個漸變圓表現的是數據由強變弱的輻射效果

二、兩個圓之間能夠相互疊加,且是線性的疊加,其實質表現的是數據強弱的疊加

三、數據強弱的數值與顏色一一映射,通常表現爲紅強藍弱的線性漸變,固然你也能夠設計本身的強度色譜

根據咱們的直觀感覺,咱們須要作的是:

一、將每個數據映射爲一個圓形

二、選定一個線性維度表示數據強度值,圓形區域內該維度在圓心處達到最大值,沿着半徑逐漸變小,直至邊緣處爲最小值

三、將圓形內的強度值進行疊加

四、以強度色譜進行顏色映射

每每有人對第二、3步有疑問,爲何不直接以強度色譜填充圓形呢?

由於沒有alpha通道時不會進行混色,重疊的時候顏色會相互覆蓋而非疊加;且即便在強度色譜上設置了alpha值,疊加時也是rgb三個通道上分別進行計算,簡單來講就是沒法將藍色與藍色疊加出現紅色。

那須要開一個二維數組存儲強度值進行疊加計算嗎?

也不用。其實canvas畫布自己就能夠看做一個二維數組,能夠選取alpha單通道做爲表示強弱的維度,雖然alpha通道並不是嚴格的線性疊加,其爲a = a1 + a2 - a1 * a2,但也能夠知足咱們的需求,以下圖所示,其與a = a1 + a2所表示的平面比較貼近。

alt

[ alpha疊加 ]

動手實現

繪製圓形

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

arc(x, y, radius, startAngle, endAngle, anticlockwise)

x和y對應到數據的座標,radius可自由設置,startAngle和endAngle表示起止角度,分別取0和2 * Math.PI,anticlockwise表示是否逆時針,可不設置。

漸變色

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爲數據最大值,至此,咱們獲得的圖形以下: alt

[ 漸變圓形 ]

顏色映射

可見圖中的透明度已能表明數據強弱及輻射效果,且在相交處進行了線性的疊加。咱們如今要給圖形上色,須要使用ImageData對象對圖像進行像素操做,讀取每一個像素點的透明度,而後使用其映射後的顏色改寫ImageData數值。

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

alt [ 調色盤 ]

如上圖所示,咱們能夠建立一個跨度爲 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);

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

alt

[ 熱力圖 ]

性能優化

離屏渲染

離屏渲染是指在文檔流外的canvas中預先繪製好所需圖形,而後將其做爲紋理繪製到畫布上,主要應用於局部繪製過程較複雜,而該局部又被重複繪製的場景下;同時應保證這個離屏的畫布大小適中,由於複製過大的畫布會帶來很大的性能損耗。

那麼熱力圖是否可使用離屏渲染提高性能呢?考慮一下,若是咱們在地圖上呈現熱力圖,隨着地圖的移動,數據點的座標會變化,但其對應的圓形圖像實際上是不變的。因此爲了不更新座標時重複地建立漸變色、設置globalAlpha、繪製及填充顏色等,咱們可使用離屏渲染預先繪製好每一個數據點的圖像,

在從新渲染的時候經過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);
};

然而通過性能測試發現,熱力圖局部繪製過程其實比較簡單,與直接使用drawImage的耗時相差無幾,因此無需使用離屏渲染。

避免浮點數座標

使用drawImage時若是使用了浮點數座標,瀏覽器爲了達到抗鋸齒的效果,會作額外計算,渲染子像素。因此儘可能使用整數座標。

怎麼樣?看完咱們tototo同窗的細緻介紹,不知道你有沒有掌握可視化組件背後的祕密?若是有任何問題歡迎在下方直接留言。

固然,若是你對這些底層的技術不是那麼關心,那也沒有關係。咱們騰訊位置服務的願景就是爲了下降開發者門檻,減小開發者成本,解放開發者生產力。因此,totoro同窗和她的小夥伴們才把這些複雜的底層實現包裝成了組件的形式,方便你們調用。

那麼還猶豫什麼呢?當即點擊這裏直接用起來吧!你們對可視化組件的每一次調用,都是 「春哥」和她小夥伴們辛勤工做的一份確定。

最後,提早劇透一下,基於WebGL開發的3D版可視化組件也即將上線,展現效果更加酷炫,還請各位開發者小夥伴持續關注!

相關文章
相關標籤/搜索