1. 程式人生 > >Github Repository 視覺化 (D3.js & Three.js)

Github Repository 視覺化 (D3.js & Three.js)

Github Repository 視覺化 (D3.js & Three.js)

先上 Demo 連結 & 效果圖 demo 連結 github 連結

效果圖 2D:

demo 2d

效果圖 3D:

demo 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 回撥中要完成的任務是: 重新整理 svgcirclehtmlspan 的座標. 具體程式碼如下. 如果用過 D3.js 的同學應該很熟悉這段程式碼了, 就是使用 d3-selection 對 DOM 元素 enter(), update(), exit() 三種狀態進行的簡單控制.

這裡需要注意的一點是, 我們沒有使用 svgtext 元素來實現文字而是使用了 htmlspan, 目的是更好的控制文字換行.

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 初始載入資料時的效果了:

enter view

但此時頁面中的 圓圈 (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()
複製程式碼

如此我們便實現了拖拽效果:

enter view

最後讓我們加上 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 元素, 我們需要手動更新縮放和座標. 這樣我們便實現了滑鼠滾輪的縮放功能.

zoom effect

以上便是 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, 其效果是實現巢狀式的圓形佈局效果. 類似下圖:

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(). 這種材質的效果是, 我們從任何角度來看球體, 其四周顏色都是不變的.如圖:

Normal Material

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 檢視的搭建, 效果如圖:

3D effect

想了解更多 D3.js 和 資料視覺化 ?

這裡是我的 D3.js資料視覺化 的 github 地址, 歡迎 star & fork :tada:

D3-blog

如果覺得本文不錯的話, 不妨點選下面的連結關注一下 : )

github 主頁

知乎專欄

掘金