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 主页

知乎专栏

掘金

欢迎关注个人公众号:

相关文章
相关标签/搜索