原文地址:https://geekplux.com/2018/08/28/how-to-implement-sankey-diagram.html
什麼是桑基圖
Google 搜索桑基圖,可以搜到一大堆定義。簡而言之,桑基圖是一種數據流圖,展示了數據是如何從左到右流向最後的節點,每條邊代表一條數據流,寬度代表數據流的大小。桑基圖常用於流量分析,可以很清楚的看出數據是如何漸漸分流的。本文着重講解如何實現,理論方面的東西各位可以自行了解。
實現桑基圖的關鍵點
關鍵點有兩個:
1. 座標計算
桑基圖要展現的數據流,算是圖(拓撲類、網絡型或關係型)數據的一種。實現一個數據可視化圖,最重要的就是拆解元素。而實現一個圖數據可視化,則最重要的是分清“節點”和“邊”。
拆解元素之後,最重要的便是座標的計算,這裏包括點和邊的座標。而圖形中,任何的元素都可以看作是點連線而成,所以元素座標的計算實際上就成了點座標的計算。比如桑基圖中,節點是一個矩形,那麼只需計算兩個點(左上和右下)的座標(x0, y0),(x1, y1)
便可確定;邊是一個帶形,需要計算四個端點才能確定,帶形的弧度則可由簡單的三次貝塞爾曲線計算得來。
由此觀之,實現桑基圖的核心在於計算出以上的這些點座標。其實實現任意一種可視化都是計算點的座標。
2. 減少邊交叉
當數據量到一定程度的時候, 桑基圖中的邊會出現重疊現象,造成一定的視覺混亂。如何減少可以閱讀本文第二節。
一、座標計算的實現
準備工作
設計數據結構
經典的圖數據結構一般是鄰接矩陣和鄰接表,我們也可以自己設計。我在做拓撲數據可視化的時候,會先和後端或數據同學商定好我需要拿到的數據結構,通常是這個樣子:
{
nodes: [
{ foo: bar },
{ foo: baz },
...
],
links: [
{ source: 0, target: 1, value: 100 },
{ source: 1, target: 2, value: 10 },
...
]
}
而我拿到之後要做的第一步就是先把 nodes 和 links 串聯起來,這裏每個 link 的 source 和 target 分別是 nodes 的下標,當然你也可以設置其他的引用(指針),總之通過引用講兩者串聯起來,變成:
{
nodes: [
{
foo: bar,
column: 0, // 節點所在第幾列
row: 0, // 節點所在第幾行
value: 100, // 節點數據流大小
sourceLinks: [
{
source: 0,
target: 1,
...
}
],
targetLinks: [
...
]
},
...
],
links: [
{
source: 0,
target: 1,
value: 100,
sourceNode: {
foo: bar,
column: 0,
row: 0,
...
},
targetNode: {
...
}
},
...
]
}
這樣,對於某個節點來說,可以直接用 O(1) 的時間複雜度訪問到它的任意相鄰節點。
計算節點數據流大小
這裏的計算方法可自己定,通常是取該節點入邊和出邊的數據流大小之和的最小值。
計算節點所在行列
在桑基圖的計算中,我們還需要進行一個關鍵的計算——計算節點在桑基圖中的第幾行第幾列。
第幾列的計算,即爲節點在圖中的深度計算:
- 入度爲 0 的節點深度爲 0,在第一列
- 出度爲 0 的節點深度最大,在最後一列
- 其餘節點的深度爲他相連源節點的最大深度加 1
第幾行的計算,涉及到排序的問題,通常某一列中的節點都是按節點數據流大小,從大到小排序。
節點座標計算
剛纔我們說過,座標計算可以分爲兩部分:節點和邊。其中,邊的座標位置依賴於節點的座標,所以應該先計算節點座標。
但在計算座標之前,首先要明確一個問題:是否限定視圖的寬高。這個問題引申出兩種節點座標的計算方式。
不限定視圖寬高
如果不限定寬高,那麼節點座標的計算步驟很簡單:
- 設置一個節點的寬度
- 設置節點的水平間距
- 從左至右,根據剛纔計算出的節點所在第幾列,計算出節點的橫座標(x0, x1),初始的 x0 爲 0
- 設定一個比例尺函數(多大的數據流對應屏幕上的多少像素,通常是首先設定一個節點最小高度和一個節點最大高度,然後找出所有節點數據流的最小和最大值,映射成一個定義域爲節點數據流大小,值域爲節點高度的函數)
- 通過比例尺計算出節點高度
- 設置一個節點垂直間距
- 從上至下,根據剛纔計算出的節點所在第幾行,計算出節點的縱座標 (y0, y1),初始的 y0 爲 0
大致是這個思路,橫座標的計算取決於兩個值,節點寬度和 節點水平間距;縱座標的計算取決於 節點的數據流大小 和 節點垂直間距。
具體的計算代碼,可根據你自己的數據結構來調整。
限定視圖寬高
如果限定寬高,那麼計算步驟需要換個思路:
- 節點的寬度和節點的水平間距需要根據節點的列數和視圖寬度來計算,你可以自己手動調整也可以設計個算法來算
- 從左只有,根據節點寬度和節點水平間距,計算出節點橫座標
- 設定一個比例尺函數,計算出節點的高度
- 設置一個節點垂直間距
- 通過高斯-賽德爾迭代(Gauss–Seidel method)計算出縱座標(大致的思路是,先根據前兩步的數值算出一個初始節點座標,如果總體佈局超出視圖的下界,則節點高度和節點垂直間距都按比例縮小(如 0.95),並同時上移 n 個像素,如果總體佈局超出視圖上界,則節點高度和垂直間距都按比例縮小,並同時下移 n 個像素,直到總體的桑基圖佈局適應一開始限定的視圖寬高)
這個思路是 d3-sankey 的實現思路。如果你有限定視圖寬高的需求,那麼可以直接使用 d3-sankey。
邊的座標計算
只要確定了節點座標,邊的座標可以根據它源節點和目標節點的座標來算出:
- 對於一個節點,將它的出邊和入邊進行排序(排序方法通常是根據相連節點在第幾行從上到下排,也可以通過一些其他排序方法減少邊的交叉,具體在第二節介紹)
- 計算每個節點中單位數據流佔節點高度的比例
- 根據出邊入邊的數據流大小,乘上一步計算出的比例,則可得到每條邊左右兩邊的高度
- 從上到下,計算每條邊的縱座標
- 每條邊四個端點的橫座標分別對應源節點和目標節點的橫座標
以上操作可以通過遍歷每個 node 的 sourceLinks 和 targetLinks 來計算。得到邊的四個端點以後,就可以算出三次貝塞爾曲線的控制點了:
二、如何減少交叉
通常要減少邊的交叉,可以採用下面兩種方法:
- 均值排序
- sugiyama 算法
均值排序這個名字是我自己起的。。不過這個方法很實用有效。
對於每個源節點來說,都有相連的目標節點。這裏的“均值”指的是所有相連目標節點所在行數的平均值(所有目標節點的行數相加,除以目標節點個數),這個平均值可以大致描述該節點每個出邊的位置。每條出邊都有這樣一個值,這個值越小,則說明該出邊要連接的目標節點的位置越靠上,反之越靠下。所以可根據這個值,從小到大排出出邊在該節點上從上到下的位置。
三、具體項目中的交互
我參與的 UBA (User Behavior Analytics 內部項目) 項目中,正好用到了桑基圖。除了上述的圖形繪製之外,主要複雜的是交互。
如圖所示,除了基本的 hover 交互之外,項目中主要還有
- minimap 拖拽和刷選
- 主視圖的拖拽和縮放
- 左下角的過濾器
- 點擊交互,高亮只經過選中節點的路徑,並且邊上高亮的部分由最後一個選中節點懈怠的數據流值確定,其餘部分半透明
整個桑基圖實現下來發現繪製只是一些計算,交互纔是更難抽象和處理的部分。
綜上,桑基圖是一個 展現數據流非常好用的視圖,感興趣的同學可以自己實現一個試試。除了我文章中這些基本的桑基圖佈局,你還可以試試其他變種,另外交互方面也可以突破剛纔我提到的那些,比如我之前實現過點擊節點進行摺疊/展開的交互。總體來說可視化還是一個比較有意思的方向。
本作品採用知識共享 署名-非商業性使用-禁止演繹 4.0 國際 許可協議進行許可。