先上 Demo 連接 & 效果圖 demo 連接 github 連接javascript
效果圖 2D: html
效果圖 3D: java
最初想法是由於 github 提供的頁面沒法一次看到用戶的全部 repository, 也沒法直觀的看到每一個 repository 的量級對比(如 commit 數, star 數),node
因此但願作一個能直觀展現用戶全部 repository 的網站.git
用戶 Github Repository 數據的2D和3D展現, 點擊用戶 github 關注用戶的頭像, 能夠查看他人的 Github Repository 展現效果.github
2D 和 3D 版本均支持:api
其中 2D 視圖支持頁面縮放和拖拽 && 單個 Repository 的縮放和拖拽, 3D 視圖僅支持頁面的縮放和拖拽.微信
2D 效果圖中, 每個 Repository 用一個圓形表示, 圓形的大小表明了 commit 數目 || start 數目 || fork 數目.app
佈局使用的是 d3-layout 中的 forceLayout, 達到模擬物理碰撞的效果. 拖拽用到了 d3-drag 模塊, 大體邏輯爲:dom
==> 檢測鼠標拖拽事件
==> 更新 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
三個鼠標事件的回調便可:
fx, fy
(即 forceX, forceY, 設置這兩個值會讓 force-layout 添加做用力將該節點移動到 fx, fy
)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 效果圖中的佈局使用的是 d3-layout 中的 pack layout, 3D 場景中的拖拽合縮放直接使用了插件 three-orbit-controls.
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
屬性.
這裏咱們使用 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 的文字建立一個 TextGeometry, 結果 3D 視圖加載很是緩慢. 後來通過四處搜索,終於在 Three.js 的 一個 github issue 裏面的找到了比較好的解決方案: 將 26 個英文字母分別建立 TextGeometry, 而後在建立每個單詞時, 使用現有的 26 個字母的 TextGeometry 拼接出單詞, 這樣就能夠大幅節省建立 TextGeometry 的時間. 討論該 issue 的連接以下:
github issue: https://github.com/mrdoob/three.js/issues/1825
示例代碼以下:
// 事先將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 、 數據可視化 的 github 地址, 歡迎 star & fork :tada:
郵箱: ssthouse@163.com
微信: