先上 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 视图仅支持页面的缩放和拖拽.app
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
回调中要完成的任务是: 刷新 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: 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 视图的搭建, 效果如图:
这里是个人 D3.js 、 数据可视化 的 github 地址, 欢迎 star & fork :tada: