Github Repository 視覺化 (D3.js & Three.js)
先上 Demo 連結 & 效果圖 demo 連結 github 連結
效果圖 2D:
效果圖 3D:
為什麼要做這樣一個網站?
最初想法是因為 github 提供的頁面無法一次看到使用者的所有 repository, 也無法直觀的看到每個 repository 的量級對比(如 commit 數, star 數),
所以希望做一個能直觀展示使用者所有 repository 的網站.
實現的功能有哪些?
使用者 Github Repository 資料的2D和3D展示, 點選使用者 github 關注使用者的頭像, 可以檢視他人的 Github Repository 展示效果.
2D 和 3D 版本均支援:
- 展示使用者的 Repository 視覺化效果
- 點選 following people 的頭像檢視他人的 Repository 視覺化效果
其中 2D 檢視支援頁面縮放和拖拽 && 單個 Repository 的縮放和拖拽, 3D 檢視僅支援頁面的縮放和拖拽.
用到了哪些技術?
- 資料來源為 Github 提供的 GraphQL API.
- 2D 實現使用到了 D3.js
- 3D 實現使用到了 Three.js
- 頁面搭建使用 Vue.js
實現細節?
2D 實現
2D 效果圖中, 每一個 Repository 用一個圓形表示, 圓形的大小代表了 commit 數目 || start 數目 || fork 數目.
佈局使用的是 d3-layout 中的 forceLayout, 達到模擬物理碰撞的效果. 拖拽用到了 d3-drag 模組, 大致邏輯為:
==> 檢測滑鼠拖拽事件
==> 更新 UI 元素座標
==> 重新計算佈局座標
==> 更新 UI 來達到圓形可拖拽的效果.
讓我們來看看具體程式碼:
2D 頁面依賴 D3.js 的 force-layout 進行動態更新, 我們為 force-layout 新增了以下幾種 force(作用力):
.force('charge', this.$d3.forceManyBody())
新增節點之間的相互作用力.force('collide',radius)
新增物理碰撞, 半徑設定為圓形的半徑.force('forceX', this.$d3.forceX(this.width / 2).strength(0.05))
新增橫座標居中的作用力.force('forceY', this.$d3.forceY(this.height / 2).strength(0.05))
新增縱座標居中的作用力
主要程式碼如下:
this.simulation = this.$d3
.forceSimulation(this.filteredRepositoryList)
.force('charge', this.$d3.forceManyBody())
.force(
'collide',
this.$d3.forceCollide().radius(d => this.areaScale(d.count) + 3)
)
.force('forceX', this.$d3.forceX(this.width / 2).strength(0.05))
.force('forceY', this.$d3.forceY(this.height / 2).strength(0.05))
.on('tick', tick)
複製程式碼
最後一行 .on('tick', tick)
為 force-layout simulation 的回撥方法, 該方法會在物理引擎更新的每個週期被呼叫, 我們可以在這個回撥方法中更新頁面, 以達到動畫效果.
我們在這個 tick
回撥中要完成的任務是: 重新整理 svg 中 circle 和 html 的span 的座標. 具體程式碼如下.
如果用過 D3.js 的同學應該很熟悉這段程式碼了, 就是使用 d3-selection 對 DOM 元素 enter(), update(), exit()
三種狀態進行的簡單控制.
這裡需要注意的一點是, 我們沒有使用 svg 的 text 元素來實現文字而是使用了 html 的 span, 目的是更好的控制文字換行.
const tick = function() {
const curTransform = self.$d3.zoomTransform(self.div)
self.updateTextLocation()
const texts = self.div.selectAll('span').data(self.filteredRepositoryList)
texts
.enter()
.append('span')
.merge(texts)
.text(d => d.name)
.style('font-size', d => self.textScale(d.count) + 'px')
.style(
'left',
d =>
d.x +
self.width / 2 -
((self.areaScale(d.count) * 1.5) / 2.0) * curTransform.k +
'px'
)
.style(
'top',
d => d.y - (self.textScale(d.count) / 2.0) * curTransform.k + 'px'
)
.style('width', d => self.areaScale(d.count) * 1.5 + 'px')
texts.exit().remove()
const repositoryCircles = self.g
.selectAll('circle')
.data(self.filteredRepositoryList)
repositoryCircles
.enter()
.append('circle')
.append('title')
.text(d => 'commit number: ' + d.count)
.merge(repositoryCircles)
.attr('cx', d => d.x + self.width / 2)
.attr('cy', d => d.y)
.attr('r', d => self.areaScale(d.count))
.style('opacity', d => self.alphaScale(d.count))
.call(self.enableDragFunc())
repositoryCircles.exit().remove()
}
複製程式碼
完成以上的邏輯後, 就能看到 2D 初始載入資料時的效果了:
但此時頁面中的 圓圈 (circle)還不能響應滑鼠拖拽事件, 讓我們使用 d3-drag 加入滑鼠拖拽功能.
程式碼非常簡單, 使用 d3-drag 處理 start, drag, end
三個滑鼠事件的回撥即可:
- start & drag ==> 將當前節點的
fx, fy
(即 forceX, forceY, 設定這兩個值會讓 force-layout 新增作用力將該節點移動到fx, fy
) - end ==> 拖拽事件結束, 清空選中節點的
fx, fy
,
enableDragFunc() {
const self = this
this.updateTextLocation = function() {
self.div
.selectAll('span')
.data(self.repositoryList)
.each(function(d) {
const node = self.$d3.select(this)
const x = node.style('left')
const y = node.style('top')
node.style('transform-origin', '-' + x + ' -' + y)
})
}
return this.$d3
.drag()
.on('start', d => {
if (!this.$d3.event.active) this.simulation.alphaTarget(0.3).restart()
d.fx = this.$d3.event.x
d.fy = this.$d3.event.y
})
.on('drag', d => {
d.fx = this.$d3.event.x
d.fy = this.$d3.event.y
self.updateTextLocation()
})
.on('end', d => {
if (!this.$d3.event.active) this.simulation.alphaTarget(0)
d.fx = null
d.fy = null
})
},
複製程式碼
需要注意的是,我們在 drag 的回撥方法中,呼叫了 updateTextLocation()
, 這是因為我們的 drag 事件將會被應用到 circle 上, 而 text 不會自動更新座標, 所以需要我們去手動更新.
接下來,我們將 d3-drag 應用到 circle 上:
const repositoryCircles = self.g
.selectAll('circle')
.data(self.filteredRepositoryList)
repositoryCircles
.enter()
.append('circle')
.append('title')
.text(d => 'commit number: ' + d.count)
.merge(repositoryCircles)
.attr('cx', d => d.x + self.width / 2)
.attr('cy', d => d.y)
.attr('r', d => self.areaScale(d.count))
.style('opacity', d => self.alphaScale(d.count))
.call(self.enableDragFunc()) // add d3-drag function
repositoryCircles.exit().remove()
複製程式碼
如此我們便實現了拖拽效果:
最後讓我們加上 2D 介面的縮放功能, 這裡使用的是 d3-zoom. 和 d3-drag 類似, 我們只用處理滑鼠滾輪縮放的回撥事件即可:
enableZoomFunc() {
const self = this
this.zoomFunc = this.$d3
.zoom()
.scaleExtent([0.5, 10])
.on('zoom', function() {
self.g.attr('transform', self.$d3.event.transform)
self.div
.selectAll('span')
.data(self.repositoryList)
.each(function(d) {
const node = self.$d3.select(this)
const x = node.style('left')
const y = node.style('top')
node.style('transform-origin', '-' + x + ' -' + y)
})
self.div
.selectAll('span')
.data(self.repositoryList)
.style(
'transform',
'translate(' +
self.$d3.event.transform.x +
'px,' +
self.$d3.event.transform.y +
'px) scale(' +
self.$d3.event.transform.k +
')'
)
})
this.g.call(this.zoomFunc)
}
複製程式碼
同樣的, 因為 span 不是 svg 元素, 我們需要手動更新縮放和座標. 這樣我們便實現了滑鼠滾輪的縮放功能.
以上便是 2D 效果實現的主要邏輯.
3D 實現
3D 效果圖中的佈局使用的是 d3-layout 中的 pack layout, 3D 場景中的拖拽合縮放直接使用了外掛 three-orbit-controls.
讓我們來看看具體程式碼
建立基本 3D 場景
3D 檢視中, 承載所有 UI 元件的是 Three.js 中的 Scene,首先我們初始化 Scene.
this.scene = new THREE.Scene()
複製程式碼
接下來我們需要一個 Render(渲染器)來將 Scene 中的畫面渲染到 Web 頁面上:
this.renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true })
this.renderer.setClearColor(0xeeeeee, 0.3)
var contaienrElement = document.getElementById(this.containerId)
contaienrElement.appendChild(this.renderer.domElement)
複製程式碼
然後我們需要加入 Light, 對 Three.js 瞭解過的同學應該很容易理解, 我們需要 Light 來照亮場景中的物體, 否則我們看到就是一片漆黑.
// add light
var light = new THREE.AmbientLight(0x404040, 1) // soft white light
this.scene.add(light)
var spotLight = new THREE.DirectionalLight(0xffffff, 0.7)
spotLight.position.set(0, 0, 200)
spotLight.lookAt(0, 0, 0)
this.scene.add(spotLight)
複製程式碼
最後我們需要加入 Camera. 我們最終看到的 Scene 的樣子就是從 Camera 的角度看到的樣子. 我們使用 render 來將 Scene 從 Camera 看到的樣子渲染出來:
this.renderer.render(this.scene, this.camera)
複製程式碼
但是這樣子我們只是渲染了一次頁面, 當 Scene 中的物體發生變化時, Web 頁面上的 Canvas 並不會自動更新, 所以我們使用 requestAnimationFrame
這個 api 來實時重新整理 Canvas.
animate_() {
requestAnimationFrame(() => this.animate_())
this.controls.update()
this.renderer.render(this.scene, this.camera)
}
複製程式碼
實現佈局
為了實現和 2D 檢視中類似的佈局效果, 我們使用了 D3 的 pack-layout, 其效果是實現巢狀式的圓形佈局效果. 類似下圖:
這裡我們只是想使用這個佈局, 但是我們本身的資料不是巢狀式的, 所以我們手動將其包裝一層, 使其變為巢狀的資料格式:
{
"children": this.reporitoryList
}
複製程式碼
然後我們呼叫 D3 的pack-layout:
calcluate3DLayout_() {
const pack = D3.pack()
.size([this.layoutSize, this.layoutSize])
.padding(5)
const rootData = D3.hierarchy({
children: this.reporitoryList
}).sum(d => Math.pow(d.count, 1 / 3))
this.data = pack(rootData).leaves()
}
複製程式碼
這樣, 我們就完成了佈局. 在控制檯從檢視 this.data
, 我們就能看到每個節點的 x, y
屬性.
建立表示 Repository 的球體
這裡我們使用 THREE.SphereGeometry 來建立球體, 球體的材質我們使用 new THREE.MeshNormalMaterial(). 這種材質的效果是, 我們從任何角度來看球體, 其四周顏色都是不變的.如圖:
addBallsToScene_() {
const self = this
if (!this.virtualElement) {
this.virtualElement = document.createElement('svg')
}
this.ballMaterial = new THREE.MeshNormalMaterial()
const circles = D3.select(this.virtualElement)
.selectAll('circle')
.data(this.data)
circles
.enter()
.merge(circles)
.each(function(d, i) {
const datum = D3.select(this).datum()
self.ballGroup.add(
self.generateBallMesh_(
self.indexScale(datum.x),
self.indexScale(datum.y),
self.volumeScale(datum.r),
i
)
)
})
}
generateBallMesh_(xIndex, yIndex, radius, name) {
var geometry = new THREE.SphereGeometry(radius, 32, 32)
var sphere = new THREE.Mesh(geometry, this.ballMaterial)
sphere.position.set(xIndex, yIndex, 0)
return sphere
}
複製程式碼
需要注意的是, 這裡我們把所有的球體放置在 ballGroup 中, 並把 ballGroup 放置到 Scene 中, 這樣便於管理所有的球體(比如清空所有球體).
建立表示 Repository 名稱的 文字物體
在一開始開發時, 我直接為每一個 Repository 的文字建立一個 TextGeometry, 結果 3D 檢視載入非常緩慢. 後來經過四處搜尋,終於在 Three.js 的 一個 github issue 裡面的找到了比較好的解決方案: 將 26 個英文字母分別建立 TextGeometry, 然後在建立每一個單詞時, 使用現有的 26 個字母的 TextGeometry 拼接出單詞, 這樣就可以大幅節省建立 TextGeometry 的時間. 討論該 issue 的連結如下:
github issue: github.com/mrdoob/thre…
示例程式碼如下:
// 事先將26個字母建立好 TextGeometry
loadAlphabetGeoMap() {
const fontSize = 2.4
this.charGeoMap = new Map()
this.charWidthMap = new Map()
const chars =
'1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_-./?'
chars.split('').forEach(char => {
const textGeo = new THREE.TextGeometry(char, {
font: this.font,
size: fontSize,
height: 0.04
})
textGeo.computeBoundingBox()
const width = textGeo.boundingBox.max.x - textGeo.boundingBox.min.x
this.charGeoMap.set(char, textGeo)
this.charWidthMap.set(char, width)
})
console.log(this.charGeoMap)
}
// 建立整個單詞時直接使用現有字母的 TextGeometry進行拼接
addTextWithCharGroup(text, xIndex, yIndex, radius) {
const group = new THREE.Group()
const chars = text.split('')
let totalLen = 0
chars.forEach(char => {
if (!this.charWidthMap.get(char)) {
totalLen += 1
return
}
totalLen += this.charWidthMap.get(char)
})
const offset = totalLen / 2
for (let i = 0; i < chars.length; i++) {
const curCharGeo = this.charGeoMap.get(chars[i])
if (!curCharGeo) {
xIndex += 2
continue
}
const curMesh = new THREE.Mesh(curCharGeo, this.textMaterial)
curMesh.position.set(xIndex - offset, yIndex, radius + 2)
group.add(curMesh)
xIndex += this.charWidthMap.get(chars[i])
}
this.textGroup.add(group)
}
複製程式碼
需要注意的是該方法僅適用於英文, 如果是漢字的話, 我們是無法事先建立所有漢字的 TextGeometry 的, 這方面我暫時也還沒找到合適的解決方案.
如上, 我們便完成了 3D 檢視的搭建, 效果如圖:
想了解更多 D3.js 和 資料視覺化 ?
這裡是我的 D3.js 、 資料視覺化 的 github 地址, 歡迎 star & fork :tada: