背景:最近可视化中项目上需要用到3D的饼图,查了一下Echarts官网好像没有发现类似的效果,或者找了,也没有多少自定义的配置吧,比如相机的控制,鼠标移动时候的动画效果啥的,索性想着用three.js来实现,本来想通过柱状图来实现的,乍看确实也挺相似的
CylinderGeometry(radiusTop : Float,
radiusBottom : Float,
height : Float,
radialSegments : Integer,
heightSegments : Integer,
openEnded : Boolean,
thetaStart : Float,
thetaLength : Float)
但是会遇到一个问题,thetaLength 生成的圆柱,非弧形那一侧是空,没有闭合的如下图
本想继续用两个平面遮住或者是通过three.js的ExtrudeGeometry挤出一个等腰三角形将两侧及覆盖掉,但是在角度的换算中极其的复杂,也就是当生成CylinderGeometry时候的thetaLength和挤出的等腰三角形绕Y轴旋转,我没有找到规律,至于两个平面也是合成的等腰三角形也是如此,搞了我老半天都没有搞定,于是放弃了这两种办法
既然这两种办法不行,那结合一下会怎么样呢,于是我们只需要写出一个等腰三角形去掉不是腰的那一条边加上根据角度生成的弧形,然后挤出不就好了吗,直接看代码,为了方便我把它封装了一下
const createPip = (name = '', radius = 5, angle = Math.PI / 2, color = [0.8, 0.8, 0.8], depth = 5) => {
const curve = new THREE.EllipseCurve(
0, 0, // ax, aY
radius, radius, // xRadius, yRadius
0, angle, // aStartAngle, aEndAngle
false, // aClockwise
0 // aRotation
);
const points = curve.getPoints(50);
const shape = new THREE.Shape(points)
shape.moveTo(0, 0);
shape.lineTo(0, 0);
const extrudeSettings = {
steps: 0,
depth,
bevelEnabled: true,
bevelThickness: 0,
bevelSize: 0,
bevelOffset: 0,
bevelSegments: 1
};
const geometry = new THREE.ExtrudeGeometry(shape, extrudeSettings);
const cylinderMat = new THREE.MeshPhongMaterial({
specular: new THREE.Color(0.6,0.7,0.7),
shininess: 10,
color: new THREE.Color(...color),
transparent: true,
opacity: 0.8
})
const mesh = new THREE.Mesh(geometry, cylinderMat)
mesh.name = name
mesh.rotateX(Math.PI / 2) // 翻转
mesh.position.y += radius / 2 // 位移到0点
return mesh
}
看到这里小伙伴可能会疑惑了,
shape.moveTo(0, 0);
shape.lineTo(0, 0);
这两句代码有什么用呢,这个就是将弧线和中心点(0,0)连接在一起,也就是闭合的意思,不信可以试一下哈
以下是调用的代码,记住角度是根据比率算出来的,1代表2PI,0代表0PI
const names = ['违规车辆查询', '实时情况查询', '公交换乘查询'], defaultRadius = 5
const ratios = [0.3, 0.3, 0.4], colors = [[0.1, 0.2, 0.3], [0.2, 0.2, 0.3], [0.4, 0.5, 0.6]], depths = [2, 3, 4]
const combination = (names: string[], ratios: number[], colors: number[][], depth: number[], radius = 5) => {
const length = ratios.length
let count = 0, engleCount = 0
const group = new THREE.Group()
const minDepth = Math.min(...depth)
if (length != colors.length || length != depth.length || names.length != length) {
throw new Error('输入错误,比率、颜色、高度,名字,四个数组的长度不相等')
}
for (let i = 0; i < length; i++) {
count += ratios[i]
}
if (count != 1) {
throw new Error('比率数组的和不等于1')
}
ratios.forEach((ratio, index, self) => {
const angle = Math.PI * 2 * ratio
const pip = createPip(names[index], radius, angle, colors[index], depth[index])
if (index != 0) {
const rotate = Math.PI * 2 * self[index - 1] // 根据上一个的角度旋转
engleCount += rotate // 将角度累加用于当前的item进行旋转
pip.rotateZ(engleCount)
}
pip.translateZ(minDepth - depth[index]) // 因为挤出的深度不一样,要根据深度进行位移一点距离
group.add(pip)
})
return group
}
最后就是加一点补间动画,就是鼠标移动到item的时候让它放大1.2倍
const {width,height} = html.getBoundingClientRect()
const mouse = new THREE.Vector2()
const raycaster = new THREE.Raycaster()
let scaleItem: any
const Easing = TWEEN.Easing.Linear.None
const duration = 100
const resetScale = ()=> new TWEEN.Tween(scaleItem.scale).to(new THREE.Vector3(1, 1, 1), duration).easing(Easing).start()
html.addEventListener('pointermove', (event) => {
mouse.x = (event.offsetX / width) * 2 - 1;
mouse.y = -(event.offsetY / height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const raycasters = raycaster.intersectObjects(g.children, true);
if (raycasters.length != 0) {
const selectItem = raycasters[0].object
if (scaleItem && scaleItem != raycasters[0].object) {
resetScale()
}
new TWEEN.Tween(selectItem.scale).to(new THREE.Vector3(1.2, 1.2, 1.2), duration).easing(Easing).start()
scaleItem = selectItem
} else {
if (scaleItem) {
resetScale()
scaleItem = undefined
}
}
})
最终效果如下