天天看點

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

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

先上 Demo 連結 & 效果圖

demo 連結 github 連結

效果圖 2D:

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

效果圖 3D:

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

為什麼要做這樣一個網站?

最初想法是因為 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 初始加載資料時的效果了:

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

但此時頁面中的 圓圈 (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()           

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

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

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

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

以上便是 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, 其效果是實作嵌套式的圓形布局效果. 類似下圖:

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

這裡我們隻是想使用這個布局, 但是我們本身的資料不是嵌套式的, 是以我們手動将其包裝一層, 使其變為嵌套的資料格式:

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

Github Repository 可視化 (D3.js & Three.js)
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: 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 視圖的搭建, 效果如圖:

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

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

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

D3-blog

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

github 首頁 知乎專欄 掘金

繼續閱讀