D3.js 力導向圖的顯示優化

NebulaGraph發表於2020-04-30

image

D3.js

作為一個前端,說到視覺化除了聽過 D3.js 的大名,常見的視覺化庫還有 EChartsChart.js,這兩個庫功能也很強大,但是有一個共同特點是封裝層次高,留給開發者可設計和控制的部分太少。和 EChart、Chart.js 等相比,D3.js** 的相對來說自由度會高很多,得益於 D3.js **中的 SVG 畫圖對事件處理器的支援,D3.js 可將任意資料繫結到文件物件模型(DOM)上,也可以直接操作物件模型(DOM)完成 W3C DOM API 相關操作,對於想要展示自己設計圖形的開發者,D3.js 絕對是一個不錯的選擇。

d3-force 力導向圖

以實現一個關係網來說,d3-force 力導向圖是不二的選擇。d3-force 是 D3.js 實現以模擬粒子物理運動的 velocity Verlet 數值積分器的模組,可用來控制粒子和邊秩序。在力導向圖中,d3-force 中的每個節點都可以看成是一個放電粒子,粒子間存在某種斥力(庫侖斥力)。同時,這些粒子間被它們之間的“邊”所牽連,從而產生牽引力。

而 d3-force 中的粒子在斥力和牽引力的作用下,從隨機無序的初態不斷髮生位移,逐漸趨於平衡有序。整個圖只有點 / 邊,圖形實現樣例較少且自定義樣式居多。

下圖就是最簡單的關係網圖,想要實現自己想要的關係網圖,還是動手自己實現一個 D3.js 力導向圖最佳。

image

構建 D3.js 力導向圖

在這裡實踐過程中,我們用 D3.js 力導向圖來對圖資料庫的資料關係進行分析,其節點和關係線直觀地體現出圖資料庫的資料關係,並且還可以關聯相對應的圖資料庫語句完成擴充查詢。進階來說,可通過對文件物件模型(DOM)的直接操作同步到資料庫進而更新資料,當然操作這個比較複雜,? 不在本文中詳細講述。

下面,我們來實現一個簡單的力導向圖,初窺 D3.js 對資料分析的作用和顯示優化的一些思路。首先我們建立一個力導向圖:

this.force = d3
        .forceSimulation()
        // 為節點分配座標
        .nodes(data.vertexes)
        // 連線線
        .force('link', linkForce)
        // 整個例項中心
        .force('center', d3.forceCenter(width / 2, height / 2))
        // 引力
        .force('charge', d3.forceManyBody().strength(-20))
        // 碰撞力 防止節點重疊
        .force('collide',d3.forceCollide().radius(60).iterations(2));

通過上述程式碼,我們可以得到下面這樣一個視覺化的節點和關係圖。

image

實現擴充查詢顯示優化

看到關係圖(上圖),我們會發現有一個新需求:選中節點繼續往下擴充查詢。為了實現擴充查詢,在這裡筆者要介紹下 D3.js 自帶 API。

D3.js 的 enter() API 可對新增的節點作單獨的邏輯處理,所以當擴充查詢到新的節點 push 進節點陣列時,不會去改變之前存在的節點資訊(包括 x,y 座標),而是按照 d3-force 例項分配的座標進行渲染。從 API 上理解來說確實是這樣,但是新增的節點對於 d3-force 這個已經存在的例項來說是一個不是簡單的 push 就能處理的。因為 d3.forceSimulation()  這個模型給當前節點分配的位置座標(x,y)是隨機,目前看來沒什麼問題對不對?

但由於d3.forceSimulation().node()  的座標隨機分配導致了圖形擴充出來位置的隨機出現,加上之前 d3-force 例項中我們設定好的 collide(碰撞力)和 links (引力)引數,所以和新節點相關聯的節點受到牽引力影響互相靠近。在靠近的過程中又會和其他節點傳送碰撞力的作用,當力導圖存在的節點的情況下,這些新增節點出現時會讓整個力導向圖在 collide 和 links 的作用下不停地碰撞,進行牽引,直到每個節點都找到自己合適的位置,即碰撞力和牽引力都滿足要求時才停止移動,看看下圖,像不像宇宙大爆炸 ?。

image

上述無序到有序熵減的過程,站在使用者角度,每新增一個節點導致整個力導圖都在一直在動,除了有一種抽搐的感,停止圖形變化又需要長時間的等待,這是不能接受的。可 D3.js API enter() 又是這樣定義規定好的,難道新增的節點和之前的節點的呈現處理需要開發者分開單獨處理嗎?如果是分開單獨處理,每次節點渲染都要遍歷判斷是不是新增,在節點較多時反而更影響效能?那麼如何優化這個新增節點呈現的問題呢?

網上解決新增節點呈現問題,大多采用減小 d3-force 例項 collide,增大 links 的 distance 引數值,這樣會讓節點很快地找到平衡點從而使整個力導圖穩定下來,這確實是一個好辦法。但是這樣節點之間的連線長度相差明顯,而且圖形整體偏大,對於大資料量的 case 來說,這種顯示方式並不太適合。

基於上述的方法,筆者有了另一種解決思路——保證新增節點是在該選中擴充的節點周圍,也就是說直接把新增節點的座標設定為對應選擇擴充節點一樣的 x,y 座標而不用 D3 .forceSimulation().node() 分配,這樣利用 d3-force 這個例項的節點碰撞力,保證新增節點的出現都不會覆蓋,最終會在選中擴充節點周圍出現。 這樣處理雖然還是對新增節點小的範圍內的節點有影響,但相對來說,不會大幅度地影響整個關係圖形走勢。關鍵程式碼如下:

# 給新增的座標設定為擴充起點中心或整個圖中心
addVertexes.map(d => {
  d.x = _.meanBy(selectVertexes, 'x') || svg.style('width') / 2;
  d.y = _.meanBy(selectVertexes, 'y') || svg.style('heigth') / 2;
});

如果沒有選中節點(既新增起點)則該起點座標位置就在圖中心位置,對已存在的節點來說,影響程度會小很多,這還是一個很不錯的思路,這個解決辦法可以推薦一下。

除了新增節點的呈現問題,整個圖形的呈現還有另外一個問題:兩點之間多邊優化顯示處理。

兩點之間多邊優化顯示處理

當兩個節點之間存在多條邊關係時,預設連線線是直線的情況下肯定會出現多線覆蓋。因此曲線連線便成了我們的另外需要解決的問題。
曲線如何定義彎曲度保證兩點之間的多條線不會互動覆蓋呢?在多條線彎曲下,如何平均半圓弧彎曲避免全跑到某半圓弧上?定義曲線弧方向?

上述問題都是下一步需要解決的問題,其實問題的解決方法也不少。目前筆者採用了先統計下兩點之間的線條數,再將這些連線線分配到一個 map 裡,兩個節點的 name 欄位進行拼接做成 key,這樣計算得到兩點之間的連線線總數。

然後在遍歷時同 map 的線根據方向分成正向、反向兩組,正向組遍歷給每條線追加設定一個 linknum 編號,同理,反向組遍歷追加一個 -linknum 編號值。這個正向、反向判斷方法很多,筆者是根據節點 source.name、target.name 進行比較,btw,這裡其實是比較 ASCII 碼。定義連線線的正反方向辦法太多了,用兩點之間的任意固定欄位比較即可,在這裡不做贅述。而我們設定的 linknum 值就是來確定該條弧線的彎曲度和彎曲方向的,這裡搭配下面程式碼講解比較好理解:

  const linkGroup = {};
  // 兩點之間的線根據兩點的 name 屬性設定為同一個 key,加入到 linkGroup 中,給兩點之間的所有邊分成一個組
  edges.forEach((link: any) => {
    const key =
      link.source.name < link.target.name
        ? link.source.name + ':' + link.target.name
        : link.target.name + ':' + link.source.name;
    if (!linkGroup.hasOwnProperty(key)) {
      linkGroup[key] = [];
    }
    linkGroup[key].push(link);
  });
  // 遍歷給每組去呼叫 setLinkNumbers 來分配 linkum
  edges.forEach((link: any) => {
    const key = setLinkName(link);
    link.size = linkGroup[key].length;
    const group = linkGroup[key];
    const keyPair = key.split(':');
    let type = 'noself';
    if (keyPair[0] === keyPair[1]) {
      type = 'self';
    }
    setLinkNumbers(group, type);
  });
#根據不同方向分為 linkA,linkB 兩個陣列,分別分配兩種 linknum,從而控制上下橢圓弧
export function setLinkNumbers(group) {
  const len = group.length;
  const linksA: any = [];
  const linksB: any = [];
  for (let i = 0; i < len; i++) {
    const link = group[i];
    if (link.source.name < link.target.name) {
      linksA.push(link);
    } else {
      linksB.push(link);
    }
  }
  let startLinkANumber = 1;
  linksA.forEach(linkA=> {
    linkA.linknum = startLinkANumber++;
  }
  let startLinkBNumber = -1;
  linksB.forEach(linkB=> {
    linkB.linknum = startLinkBNumber--;
  }
}

按照我們上面描述的思路,給每條連線線分配 linknum 值後,接著在實現監聽連線線的的 tick 事件函式裡面判斷 linknum 正負數判斷設定 path 路徑的彎曲度和方向 就行了,最終效果如下圖

image

結語

好了,以上便是筆者使用 D3.js 力導向圖實現關係網的優化思路和方法。其實要構建一個複雜的關係網,需要考慮的問題很多,需要優化的地方也很多,今天給大家分享兩個最容易遇到的新節點呈現、多邊處理問題,後續我們會繼續產出 D3.js 優化系列文,歡迎訂閱 Nebula Graph 部落格

最後,你可以通過訪問圖資料庫 Nebula Graph Studio:Nebula-Graph-Studio,體驗下 D3.js 是如何呈現關係的。本文中如有任何錯誤或疏漏歡迎去 GitHub:https://github.com/vesoft-inc/nebula issue 區向我們提 issue 或者前往官方論壇:https://discuss.nebula-graph.com.cn/建議反饋 分類下提建議 ?;加入 Nebula Graph 交流群,請聯絡 Nebula Graph 官方小助手微訊號:NebulaGraphbot

作者有話說:Hi,我是 Nico,是 Nebula Graph 的前端工程師,對資料視覺化比較感興趣,分享一些自己的實踐心得,希望這次分享能給大家帶來幫助,如有不當之處,歡迎幫忙糾正,謝謝~

關注公眾號

相關文章