用Three.js实现3D圆环图的思路及实例代码
作者:Iam0830
最近做大屏,碰到个挺烦的问题:ECharts 和highCharts的 3D 圆环图在特定角度下会有透视错位,在网上找了多个例子 基本都有这个问题。
折腾了一下午,突然想到three.js这个3d库,于是干脆用 Three.js 实现,效果还不错,简单记录下思路。




【实现思路】
其实核心逻辑就几步,没想象中那么复杂:
1. 搞定几何体 (Geometry)
核心是利用ExtrudeGeometry将二维圆环平面挤压成三维实体。具体代码步骤如下:
(1) 绘制二维圆环面 (THREE.Shape):
- 实例化一个Shape对象。 利用.absarc(0, 0, outerRadius, startAngle, endAngle,
false) 方法绘制外圆弧(逆时针)。 - 创建一个 Path 对象,同样利用 .absarc(0, 0, innerRadius, startAngle, endAngle, true) 绘制内圆弧(顺时针),这代表圆环中间的“洞”。
- 将内圆弧 Path 加入到Shape.holes 数组中,这就构成了一个封闭的二维圆环面。
(2) 挤压成型 (ExtrudeGeometry):
- 配置挤压参数 settings:核心是 depth (高度)。我们将数据数值映射为 depth,数值越大挤压越高,形成阶梯视觉。
- 关键设置:必须设置 bevelEnabled: false。ECharts 的 3D 饼图通常带倒角 (Bevel),导致拼接处有缝隙。关闭倒角后,扇区之间是纯粹的几何体贴合,严丝合缝。
- 最后调用 new THREE.ExtrudeGeometry(shape, settings) 生成三维几何体。
2. 解决“遮挡”问题
刚做完发现个坑:大扇面把小扇面挡住了。
因为 3D 视角通常是俯视+侧视,如果一个很高的扇形在正前方 (Camera 看来),后面的数据如果较小根本就看不到。
解决办法:简单粗暴,把数据排个序。渲染前先把数据按数值 从大到小 (Desc) 排序。
原理:Three.js 逆时针绘制。第一块最大的数据会占据 0° (右侧) 到 90°+ (后方) 的区域。
结果:最高的“墙”被甩到了最后面,最矮的小扇区最后绘制,刚好落在 270° (前方)。形成了“前低后高”的剧院式布局,完美解决遮挡。
3. 标签 (Label) 怎么搞?
Three.js 自带的 TextGeometry 生成汉字不仅包大,还容易有锯齿。推荐用 CSS2DRenderer。简单说就是把 DOM 节点映射到 3D 坐标上。
优势:直接用 CSS 写样式,文字永远正对屏幕,不会因为旋转而变形。
细节:图表是深色的,文字也是深色的,容易看不清。我给文字加了一圈白色的 Halo (光晕) 描边 (text-shadow),看起来就清晰了。
计算一下扇区的中心点坐标 (Math.cos/sin),把 div 定位过去就行。
【总结】
虽然代码量比配置 ECharts / HighCharts 多,但是效果很好,完全没有错位问题,并且十分流畅。
代码放到下方,需要用的朋友自取
<template>
<div class="three-container" ref="container">
<div id="three-tooltip" class="three-tooltip" :style="tooltipStyle" v-show="tooltipVisible" v-html="tooltipContent"></div>
</div>
</template>
<script>
import * as THREE from 'three'
import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import { formatterAmount, floatAdd, floatSub, floatDiv, floatMul } from '@/utils/NumberFormat'
const chartColors = [
'rgba(240, 134, 63, 0.8)',
'rgba(55, 162, 179, 0.8)',
'rgba(31, 91, 170, 0.8)',
'rgba(140, 205, 241, 0.8)',
'rgba(246, 192, 84, 0.8)',
'rgba(255, 169, 206, 0.8)',
'rgba(162, 133, 210, 0.8)',
'rgba(235, 126, 101, 0.8)'
]
export default {
props: {
chartData: {
type: Array,
default: () => []
},
showType: {
type: String, // 'amount' or 'rate' 用于控制标签显示格式
default: 'amount'
}
},
data() {
return {
camera: null,
scene: null,
renderer: null,
labelRenderer: null,
controls: null,
meshGroup: null,
raycaster: new THREE.Raycaster(),
mouse: new THREE.Vector2(),
hoveredIndex: -1,
tooltipVisible: false,
tooltipContent: '',
tooltipStyle: {
left: '0px',
top: '0px'
}
}
},
watch: {
chartData: {
handler(val) {
if (val && val.length) {
this.$nextTick(() => {
this.rebuildChart()
})
}
},
deep: true
},
showType() {
// 类型切换时更新标签,不需要完全重建几何体,但为了简单起见,这里重建标签或整体
this.rebuildChart()
}
},
mounted() {
this.initThree()
this.rebuildChart()
window.addEventListener('resize', this.onWindowResize)
this.$refs.container.addEventListener('mousemove', this.onMouseMove)
},
beforeDestroy() {
window.removeEventListener('resize', this.onWindowResize)
this.$refs.container.removeEventListener('mousemove', this.onMouseMove)
this.cleanUp()
},
methods: {
cleanUp() {
if (this.renderer) {
this.renderer.dispose()
}
if (this.scene) {
this.scene.traverse((object) => {
if (object.geometry) object.geometry.dispose()
if (object.material) {
if (Array.isArray(object.material)) {
object.material.forEach(m => m.dispose())
} else {
object.material.dispose()
}
}
})
}
},
initThree() {
const container = this.$refs.container
const width = container.clientWidth
const height = container.clientHeight
// Scene
this.scene = new THREE.Scene()
this.scene.background = null // 透明背景
// Camera
this.camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000)
this.camera.position.set(0, 30, 40) // 调整相机位置以获得良好的俯视 3D 视角
this.camera.lookAt(0, 0, 0)
// Lights - 柔和光照
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6) // 降低环境光,避免过曝
this.scene.add(ambientLight)
const mainLight = new THREE.DirectionalLight(0xffffff, 0.8)
mainLight.position.set(10, 20, 20)
this.scene.add(mainLight)
const fillLight = new THREE.DirectionalLight(0xffffff, 0.5)
fillLight.position.set(-20, 10, -10)
this.scene.add(fillLight)
const topLight = new THREE.DirectionalLight(0xffffff, 0.3)
topLight.position.set(0, 50, 0)
this.scene.add(topLight)
// WebGL Renderer
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
this.renderer.setSize(width, height)
this.renderer.setPixelRatio(window.devicePixelRatio)
container.appendChild(this.renderer.domElement)
// CSS2D Renderer (Labels)
this.labelRenderer = new CSS2DRenderer()
this.labelRenderer.setSize(width, height)
this.labelRenderer.domElement.style.position = 'absolute'
this.labelRenderer.domElement.style.top = '0px'
this.labelRenderer.domElement.style.pointerEvents = 'none' // 允许鼠标穿透到下方 Canvas
container.appendChild(this.labelRenderer.domElement)
this.meshGroup = new THREE.Group()
this.scene.add(this.meshGroup)
// OrbitControls 用于交互
this.controls = new OrbitControls(this.camera, this.renderer.domElement)
this.controls.enableDamping = true // 阻尼感
this.controls.dampingFactor = 0.05
this.controls.enableZoom = false // 禁用缩放,避免穿模或太远
this.controls.autoRotate = false // 不自动旋转,由用户控制
this.controls.minPolarAngle = 0 // 限制垂直旋转角度,避免看穿底部
this.controls.maxPolarAngle = Math.PI / 2 // 限制只能从上方看
this.animate()
},
rebuildChart() {
if (!this.meshGroup) return
// 清空旧物体
while(this.meshGroup.children.length > 0){
const child = this.meshGroup.children[0]
this.meshGroup.remove(child)
if (child.geometry) child.geometry.dispose()
if (child.material) child.material.dispose()
}
if (!this.chartData || this.chartData.length === 0) return
const total = this.chartData.reduce((acc, item) => floatAdd(acc, item.amount), 0)
let startAngle = 0
let accumulatedPercent = 0
const CONFIG = {
innerRadius: 8,
outerRadius: 15,
baseHeight: 2,
heightScale: 6 // 高度差异倍数
}
// 找到最大值用于归一化高度
const maxAmount = Math.max(...this.chartData.map(d => d.amount))
this.chartData.forEach((item, index) => {
const isLast = index === this.chartData.length - 1
let percentVal
if (isLast) {
// 最后一个扇形:100 - 前面的总和
// floatSub 返回的是字符串,需要转为数字
percentVal = Number(floatSub(100, accumulatedPercent))
// 防止浮点数误差出现负数极小值
if (percentVal < 0) percentVal = 0
} else {
// 计算占比:(amount / total) * 100
// 保留2位小数,避免精度问题导致 gap
const ratio = floatDiv(item.amount, total)
const p = floatMul(ratio, 100)
percentVal = Number(p.toFixed(2))
accumulatedPercent = floatAdd(accumulatedPercent, percentVal)
}
// 根据占比计算角度 ( percentVal / 100 * 2PI )
const angleLength = (percentVal / 100) * Math.PI * 2
// 最后一个扇形强制闭合到 2PI
const endAngle = isLast ? Math.PI * 2 : startAngle + angleLength
// 1. 创建 Shape
const shape = new THREE.Shape()
// 绘制圆环截面
shape.absarc(0, 0, CONFIG.outerRadius, startAngle, endAngle, false)
shape.absarc(0, 0, CONFIG.innerRadius, endAngle, startAngle, true) // 内圆反向
// 自动闭合 shape.closePath() 被 ExtrudeGeometry 处理
// 2. 计算挤压设置
const itemRatio = item.amount / maxAmount
const extrusionDepth = CONFIG.baseHeight + (itemRatio * CONFIG.heightScale)
const extrudeSettings = {
steps: 1,
depth: extrusionDepth,
bevelEnabled: false, // 禁用倒角,解决扇区交界处的重叠问题
bevelThickness: 0.2, // 减小倒角使其看起来更锐利,像ECharts
bevelSize: 0.2,
bevelSegments: 2,
curveSegments: 32 // 平滑度
}
const geometry = new THREE.ExtrudeGeometry(shape, extrudeSettings)
geometry.computeBoundingBox()
// 使用 PhysicalMaterial 增加质感
// 移除反光,回归 ECharts 风格的哑光质感
const material = new THREE.MeshPhongMaterial({
color: chartColors[index % chartColors.length],
shininess: 5, // 极低光泽度
specular: 0x222222, // 弱高光
flatShading: false, // 平滑着色
transparent: true,
opacity: 0.8,
side: THREE.DoubleSide
})
const mesh = new THREE.Mesh(geometry, material)
// 旋转 Mesh 使其平躺,高度方向变为 Y 轴 (原 Extrude 方向为 Z)
mesh.rotation.x = -Math.PI / 2
// 调整位置,使其底面位于 Y=0 (原 Z=0 变为 Y=0)
// 此时扇形中心在 (0,0,0)
mesh.userData = {
name: item.name,
value: item.amount,
ratio: percentVal.toFixed(2) + '%',
originalColor: material.color.getHex(),
index: index
}
this.meshGroup.add(mesh)
// 4. 添加标签
this.addLabel(startAngle, endAngle, CONFIG.outerRadius, extrusionDepth, item, percentVal.toFixed(2) + '%')
startAngle = endAngle
})
// 整体居中一点
this.meshGroup.position.set(0, -5, 0)
},
addLabel(startAngle, endAngle, radius, height, item, ratioText) {
// 计算角度中点
const midAngle = startAngle + (endAngle - startAngle) / 2
// 计算标签在 XZ 平面上的位置 ( Mesh 旋转前是 XY,旋转后对应 XZ )
// 因为我们把 Mesh 绕 X 旋转了 -90度,所以原 Mesh 的 (x, y, z) -> 新的 (x, z, -y)
// Extrude 的 Z 变成了 场景的 Y
// 标签半径稍微大一点
const labelRadius = radius + 4
const x = Math.cos(midAngle) * labelRadius
const z = Math.sin(midAngle) * labelRadius // 对应原系的 y
const y = height + 2 // 标签高度浮在柱体上方
// 创建 DOM
const div = document.createElement('div')
div.className = 'chart-label'
const valueText = this.showType === 'amount' ? formatterAmount(item.amount) : ratioText
// 字体颜色仿照 FundSourceCard (资金来源分布图)
// name: rgba(44, 53, 65, 1)
// value: rgba(2, 2, 2, 1)
div.innerHTML = `<span class="label-name" style="color: rgba(44, 53, 65, 1)">${item.name}</span>
<span class="label-value" style="color: rgba(2, 2, 2, 1)">${valueText}</span>`
div.style.textAlign = 'center'
div.style.fontSize = '12px'
div.style.fontFamily = 'Microsoft YaHei'
// 增加描边效果 (halo) 以防止背景干扰
div.style.textShadow = '1px 1px 0 #fff, -1px -1px 0 #fff, 1px -1px 0 #fff, -1px 1px 0 #fff, 0 1px 0 #fff, 0 -1px 0 #fff'
const label = new CSS2DObject(div)
label.position.set(
Math.cos(midAngle) * labelRadius,
height * 0.8, // 稍微低一点,不要浮太高
-Math.sin(midAngle) * labelRadius
)
this.meshGroup.add(label)
// 绘制引导线 (Line)
// 从柱体中心点连到标签点
const points = []
// 起点:柱体顶部边缘
const startP = new THREE.Vector3(
Math.cos(midAngle) * radius,
height,
-Math.sin(midAngle) * radius
)
// 终点:标签位置
const endP = label.position.clone()
points.push(startP)
points.push(endP)
const lineGeo = new THREE.BufferGeometry().setFromPoints(points)
const lineMat = new THREE.LineBasicMaterial({ color: 0x999999, transparent: true, opacity: 0.5 })
const line = new THREE.Line(lineGeo, lineMat)
this.meshGroup.add(line)
},
totalAmount() {
return this.chartData.reduce((t, i) => t + i.amount, 0)
},
onWindowResize() {
if (!this.$refs.container) return
const width = this.$refs.container.clientWidth
const height = this.$refs.container.clientHeight
this.camera.aspect = width / height
this.camera.updateProjectionMatrix()
this.renderer.setSize(width, height)
this.labelRenderer.setSize(width, height)
},
onMouseMove(event) {
event.preventDefault()
const rect = this.$refs.container.getBoundingClientRect()
this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
// 更新 Tooltip 位置
this.tooltipStyle = {
left: (event.clientX - rect.left + 15) + 'px',
top: (event.clientY - rect.top + 15) + 'px'
}
},
animate() {
requestAnimationFrame(this.animate)
if (this.controls) this.controls.update()
// Raycaster
this.raycaster.setFromCamera(this.mouse, this.camera)
// 只检测 Mesh,排除 Line 和 CSS2DObject
const intersects = this.raycaster.intersectObjects(
this.meshGroup.children.filter(obj => obj.type === 'Mesh')
)
if (intersects.length > 0) {
const object = intersects[0].object
if (this.hoveredIndex !== object.userData.index) {
// 恢复上一个
if (this.hoveredIndex !== -1) {
this.resetHighlight()
}
this.hoveredIndex = object.userData.index
// 高亮当前
object.material.emissive.setHex(0x333333)
object.material.opacity = 0.8
// 显示 Tooltip
this.tooltipVisible = true
const d = object.userData
this.tooltipContent = `<div class="tooltip-title">${d.name}</div><div class="tooltip-item"><span class="marker" style="background:${this.getHexColor(object.material.color)}"></span><span class="label">金额:</span><span class="value">${formatterAmount(d.value)}元</span></div><div class="tooltip-item"><span class="marker" style="background:${this.getHexColor(object.material.color)}"></span><span class="label">占比:</span><span class="value">${d.ratio}</span></div>`
}
} else {
if (this.hoveredIndex !== -1) {
this.resetHighlight()
this.hoveredIndex = -1
this.tooltipVisible = false
}
}
this.renderer.render(this.scene, this.camera)
this.labelRenderer.render(this.scene, this.camera)
},
resetHighlight() {
this.meshGroup.children.forEach(child => {
if (child.type === 'Mesh') {
// 重置时恢复原始透明度
child.material.emissive.setHex(0x000000)
child.material.opacity = 0.8
}
})
},
getHexColor(color) {
return '#' + color.getHexString()
}
}
}
</script>
<style lang="less" scoped>
.three-container {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
.three-tooltip {
position: absolute;
background-color: rgba(50, 50, 50, 0.7);
color: #fff;
padding: 8px 12px;
border-radius: 4px;
font-size: 14px;
line-height: 1.2;
pointer-events: none;
z-index: 100;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
transition: opacity 0.2s;
.tooltip-title {
font-weight: bold;
margin: 0 0 6px 0;
}
.tooltip-item {
display: flex;
align-items: center;
margin: 0 0 4px 0;
.marker {
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
}
.label {
margin-right: 8px;
}
.value {
font-weight: 500;
}
}
}
</style>
<style>
/* CSS2D Object 样式 */
.chart-label {
pointer-events: none;
font-size: 12px;
line-height: 1.2;
}
.label-name {
color: #333;
font-weight: bold;
}
.label-value {
color: #666;
}
</style>
总结
到此这篇关于用Three.js实现3D圆环图的文章就介绍到这了,更多相关Three.js实现3D圆环图内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!
