D3 是目前最流行的資料視覺化庫,WebGL 是目前 Web 端最快的繪製技術。由於效能問題的侷限,將兩者結合的嘗試越來越多(如),本文將嘗試用 D3 的力導向圖 和 Three.js 和 PixiJS 結合。全文閱讀完大概 5 分鐘,因為你重點應該看程式碼。
做資料視覺化時,必然會考慮效能的問題。早前資料視覺化都是用 Qt 等 GUI,後來逐漸遷移到了迅猛發展的瀏覽器上展示,Web 的效能問題成了大多數視覺化的侷限,尤其是在三維視覺化,或資料量特別大的時候。現在主流的 Web 視覺化技術為三種:SVG、Canvas 和 WebGL,難易程度和效能如下圖:
SVG 的優點很多,編輯簡單,互動便捷,靈活性極高,業內成熟的視覺化工具(如 d3)都是用的 SVG。但是每個 SVG 都是一個 DOM 元素,隨著它的數量上來之後,互動開始慢的難以忍受。這是因為每當修改一個 DOM 物件,只要這個物件在文件裡,接著在瀏覽器裡就會發生兩個動作,一個叫 Reflow(重排,就是重新排版),另一個叫 Repaint(重繪,就是重新渲染頁面)。這兩個動作不一定都會發生,但如果被修改的 DOM 當前可見的話,那麼就會先重排,後重繪。繪製效能上 canvas 和 SVG(DOM 元素)應該差不多,但前者可以省掉重排過程,因此效能更高。然而,WebGL 的效能更勝一籌,因為 WebGL 使用 GPU 加速渲染,GPU 在大規模計算方面有絕對優勢(影像處理、深度學習都在用,顯示卡已經賣瘋了)。例子:用 WebGL 繪製 200000 個點的動畫(http://rickyreusser.com/smoothly-animating-points-with-regl/)
WebGL 雖然威力無窮,但是寫起來比較痛苦,畫個三角形大致要 100 行程式碼。所以很多人對 WebGL 進行了封裝。上面圖中提到的兩個 Three.js 和 PixiJS 是目前最流行的兩款 WebGL 庫,當然還有新興的 regl 在今年的 OpenVis 上大放異彩。本文嘗試用前兩者和 d3-force 結合(專案程式碼在此),後面如果有時間的話,我會把使用 regl 和原生 WebGL 的例子也補充進去(我知道這是個 flag)。
正文
首先我們要知道什麼是力導向圖和如何使用 d3-force。d3 4.0 之後,作者將其模組化,force 這個模組是基於 velocity Verlet 實現了物理粒子之間的作用力的模擬,常用於網路或關係結構資料。即你把網路中的節點想象成一個個粒子,它們之間互相有作用力,所以不停的拉扯,直到趨於一個穩定狀態,具體可以看我 demo 中視覺化出來的樣子。
仔細看 demo 中的原始碼可以發現,用 three.js 和用 pixi.js 實現起來非常類似,其中有關力導向圖的關鍵程式碼是下面幾句:
1 2 3 4 |
const simulation = d3.forceSimulation() // 建立一個作用力的模擬,但此時還沒啟動 .force('link', d3.forceLink().id((d) => d.id)) // 為邊之間新增 Link 型作用力 .force('charge', d3.forceManyBody()) // 指定節點間的作用力型別為 Many-Body 型 .force('center', d3.forceCenter(width / 2, height / 2)) // Centering 作用力指定佈局圍繞的中心 |
d3-force 提供了五種作用力,分別是 Centering、Collision、Links、Many-Body、Positioning。此時我們已經建立好帶有各種力的模擬器了,接下來需要啟動它:
1 2 3 4 5 |
simulation .nodes(data.nodes) // 根據 data.nodes 陣列來計算點之間的作用力,相當於不停計算節點的 xy 座標 .on('tick', ticked) // 每次 tick 呼叫 ticked simulation.force('link') .links(data.links) // 根據 data.links 資料計算邊之間的作用力 |
至此一個力導向圖的模擬就開始了,那麼怎麼把這些節點和邊顯示出來呢?讓我們繼續看原始碼,以 three.js 為例:
1 2 3 4 5 |
const scene = new THREE.Scene() const camera = new THREE.OrthographicCamera(0, width, height, 0, 1, 1000) const renderer = new THREE.WebGLRenderer({alpha: true}) renderer.setSize(width, height) container.appendChild(renderer.domElement) // container 這裡是 document.body |
在 Three.js 中展示場景需要具備三要素:場景、照相機、渲染器。照相機就相當於我們的眼睛,它對著渲染好的場景就相當於把場景成像到了相機中,這裡的照相機我們用的是平行投影相機,渲染器我們使用的是 WebGL 渲染器。設定好渲染器的大小,把它新增到頁面的元素上,相當於新增了一個 <canvas>
元素。接下來,我們生成每個節點和邊的樣子:
1 2 3 4 5 6 7 8 9 10 11 12 |
data.nodes.forEach((node) => { node.geometry = new THREE.CircleBufferGeometry(5, 32) node.material = new THREE.MeshBasicMaterial({ color: colour(node.id) }) node.circle = new THREE.Mesh(node.geometry, node.material) scene.add(node.circle) }) data.links.forEach((link) => { link.material = new THREE.LineBasicMaterial({ color: 0xAAAAAA }) link.geometry = new THREE.Geometry() link.line = new THREE.Line(link.geometry, link.material) scene.add(link.line) }) |
套路都一樣,都是先建一個幾何體,然後設定材質的樣式,新增到場景中就好了。接下來只要在剛才提到的 ticked 這個回撥函式中把節點和邊的座標更新一下就好了:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
function ticked () { data.nodes.forEach((node) => { const { x, y, circle } = node circle.position.set(x, y, 0) }) data.links.forEach((link) => { const { source, target, line } = link line.geometry.verticesNeedUpdate = true line.geometry.vertices[0] = new THREE.Vector3(source.x, source.y, -1) line.geometry.vertices[1] = new THREE.Vector3(target.x, target.y, -1) }) render(scene, camera) } |
是不是比想象的簡單多了?如果以上有什麼地方看不懂,說明你可能對 Three.js 不是很瞭解,不過沒關係,它的文件寫的很好,入門很快。希望這篇文章能給你帶來一些幫助,做了點微小的貢獻,很慚愧 :)