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

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

先上 Demo 連接 & 效果圖 demo 連接 github 連接javascript

效果圖 2D: html

demo 2d

效果圖 3D: java

demo 3d

爲何要作這樣一個網站?

最初想法是由於 github 提供的頁面沒法一次看到用戶的全部 repository, 也沒法直觀的看到每一個 repository 的量級對比(如 commit 數, star 數),node

因此但願作一個能直觀展現用戶全部 repository 的網站.git

實現的功能有哪些?

用戶 Github Repository 數據的2D3D展現, 點擊用戶 github 關注用戶的頭像, 能夠查看他人的 Github Repository 展現效果.github

2D 和 3D 版本均支持:api

  • 展現用戶的 Repository 可視化效果
  • 點擊 following people 的頭像查看他人的 Repository 可視化效果

其中 2D 視圖支持頁面縮放和拖拽 && 單個 Repository 的縮放和拖拽, 3D 視圖僅支持頁面的縮放和拖拽.app

用到了哪些技術?

  • 數據來源爲 Github 提供的 GraphQL API.
  • 2D 實現使用到了 D3.js
  • 3D 實現使用到了 Three.js
  • 頁面搭建使用 Vue.js

實現細節?

2D 實現

2D 效果圖中, 每個 Repository 用一個圓形表示, 圓形的大小表明了 commit 數目 || start 數目 || fork 數目.dom

佈局使用的是 d3-layout 中的 forceLayout, 達到模擬物理碰撞的效果. 拖拽用到了 d3-drag 模塊, 大體邏輯爲:ide

==> 檢測鼠標拖拽事件

==> 更新 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 主頁

知乎專欄

掘金

歡迎關注個人公衆號:

相關文章
相關標籤/搜索