Three.js 3D饼图+图例实现(可旋转)
暂时还无法添加好看的引导线和数据标签 template div classplatform-3d-pie-card div classpie-container refcontainer/div div classpie-legend div classlegend-item v-foritem in legendData :keyitem.name span classlegend-color :style{ backgroundColor: item.color }/span span classlegend-name{{ item.name }}/span !-- span classlegend-value{{ item.value }}/span -- span classlegend-percent{{ item.percent }}/span /div /div /div /template script import * as THREE from three const DEFAULT_DATA [ { value: 1535, name: 数据项一紫, color: #cb3ff1 }, { value: 1378, name: 数据项二橙, color: #e27a35 }, { value: 1789, name: 数据项三青, color: #6dcde5 }, { value: 506, name: 数据项四绿, color: #00ff00 }, { value: 456, name: 数据项五蓝, color: #3558f3 }, // { value: 2456, name: 数据项五蓝, color: #3558f3 }, // { value: 256, name: 数据项五蓝, color: #30fff3 }, // { value: 156, name: 数据项五蓝, color: #ff58f3 }, // { value: 56, name: 数据项五蓝, color: #5158f3 }, ] export default { name: Platform3dPieCard, props: { titleName: { type: String, default: 3D饼图 }, data: { type: Array, default: () [] }, }, data() { return { scene: null, camera: null, renderer: null, animationId: null, group: null, } }, computed: { chartData() { const temp this.data.length 0 ? this.data : DEFAULT_DATA return temp.sort((a, b) b.value - a.value) }, legendData() { const data this.chartData const total data.reduce((acc, curr) acc curr.value, 0) return data.map(item ({ name: item.name, color: item.color, value: item.value, percent: ((item.value / total) * 100).toFixed(1) %, })) }, }, mounted() { // 初始化场景 this.initScene() // 初始化相机 this.initCamera() // 初始化渲染器 this.initRenderer() this.addLighting() // 创建饼图 this.createPieChart() // 创建基础 this.createBase() // 启动动画 this.startAnimation() // 监听窗口变化 window.addEventListener(resize, this.handleResize) }, beforeDestroy() { window.removeEventListener(resize, this.handleResize) this.stopAnimation() this.disposeResources() }, watch: { data: { handler() { this.updateChart() }, deep: true }, }, methods: { initScene() { this.scene new THREE.Scene() // 设置场景画布背景 this.scene.background new THREE.Color(0x000a20) }, initCamera() { const container this.$refs.container const width container.clientWidth || 400 const height container.clientHeight || 400 // 正投影相机OrthographicCamera (opens new window)和透视投影相机PerspectiveCamera // 60:视场角度, width / height:Canvas画布宽高比, 0.1:近裁截面, 1000远裁截面 this.camera new THREE.PerspectiveCamera(60, width / height, 0.1, 1000) // 设置相机位置 this.camera.position.set(-5,5, 10) // 相机看向原点 this.camera.lookAt(0, 0, 0) }, // 初始化 WebGL 渲染器 initRenderer() { const container this.$refs.container const width container.clientWidth || 400 const height container.clientHeight || 400 // 初始化 WebGL 渲染器 // antialias: true 开启抗锯齿, alpha: true 开启透明度 this.renderer new THREE.WebGLRenderer({ antialias: true, alpha: true }) // 设置渲染器大小 this.renderer.setSize(width, height) // 设置设备像素比。通常用于避免HiDPI设备上绘图模糊 this.renderer.setPixelRatio(window.devicePixelRatio) // 在场景中使用阴影贴图 this.renderer.shadowMap.enabled true // 使用Percentage-Closer Soft Shadows (PCSS) 算法来过滤阴影映射 BasicShadowMap、PCFShadowMap、PCFSoftShadowMap this.renderer.shadowMap.type THREE.PCFSoftShadowMap // 确保 canvas 在最底层 this.renderer.domElement.style.position absolute this.renderer.domElement.style.top 0 this.renderer.domElement.style.left 0 this.renderer.domElement.style.zIndex 1 container.appendChild(this.renderer.domElement) }, addLighting() { // 新增添加环境光没有特定方向只是整体改变场景的光照明暗 const ambientLight new THREE.AmbientLight(0xffffff, 0.7) this.scene.add(ambientLight) // 新增添加定向光 const directionalLight new THREE.DirectionalLight(0xffffff, 1.2) directionalLight.position.set(10, 15, 10) // 如果设置为 true 该平行光会产生动态阴影 directionalLight.castShadow true this.scene.add(directionalLight) // 新增添加点光源从一个点向各个方向发射的光源 const pointLight new THREE.PointLight(0xffffff, 0.8, 30) pointLight.position.set(-5, 8, 5) this.scene.add(pointLight) }, createPieChart() { // 获取图表数据已按值降序排序 const data this.chartData // 计算所有数据值的总和用于计算百分比 const total data.reduce((acc, curr) acc curr.value, 0) // 获取数据中的最大值用于计算高度比例 const maxValue Math.max(...data.map(item item.value)) // 饼图半径单位Three.js 场景单位 const radius 2.5 // 扇区最小高度确保即使值很小也能显示 const minHeight 0.1 // 扇区最大高度限制最高扇区的高度 const maxHeight 3 // 创建 THREE.js 组对象用于统一管理所有扇区和标签 this.group new THREE.Group() // 设置起始角度为 -90 度从正上方开始绘制 let startAngle -Math.PI / 2 data.forEach(segment { // 计算当前扇区的角度弧度 (当前值/总值) * 2π const angle (segment.value / total) * 2 * Math.PI // 计算当前扇区的结束角度 const endAngle startAngle angle // 计算高度比例0~1值越大比例越高 const heightRatio segment.value / maxValue // 根据比例计算实际高度范围 [minHeight, maxHeight] const height minHeight heightRatio * (maxHeight - minHeight) // 创建扇区几何体使用 ExtrudeGeometry 拉伸扇形 const geometry this.createPieSliceGeometry(radius, height, startAngle, endAngle) // 创建材质设置颜色、粗糙度和金属度 const material new THREE.MeshStandardMaterial({ color: segment.color, roughness: 0.15, metalness: 0.3, }) // 创建网格对象几何体 材质 const mesh new THREE.Mesh(geometry, material) // 开启阴影投射让扇区能产生阴影 mesh.castShadow true // 开启阴影接收让扇区能接收其他物体的阴影 mesh.receiveShadow true // 将扇区网格添加到组中 this.group.add(mesh) startAngle endAngle }) this.scene.add(this.group) }, createPieSliceGeometry(radius, height, startAngle, endAngle) { // 创建2D形状对象用于定义扇形轮廓 const shape new THREE.Shape() // 移动到原点扇形中心 shape.moveTo(0, 0) // 绘制圆弧从起点角度到终点角度逆时针方向 shape.absarc(0, 0, radius, startAngle, endAngle, false) // 从圆弧终点画线回到原点闭合形状 shape.lineTo(0, 0) // 使用ExtrudeGeometry将2D形状拉伸为3D几何体depth为拉伸高度 const geometry new THREE.ExtrudeGeometry(shape, { depth: height, bevelEnabled: false }) // 将几何体绕X轴旋转-90度使扇形底部对齐到Y0平面 geometry.rotateX(-Math.PI / 2) // 返回创建的几何体 return geometry }, createBase() { // 创建圆柱几何体作为底座下半径3上半径3.2高度0.164段 const baseGeometry new THREE.CylinderGeometry(3, 3.2, 0.1, 64) // 创建底座材质深蓝色高粗糙度低金属度 const baseMaterial new THREE.MeshStandardMaterial({ color: 0x1a2a4a, roughness: 0.8, metalness: 0.2 }) // 创建底座网格对象 const base new THREE.Mesh(baseGeometry, baseMaterial) // 设置底座Y位置稍微下沉使顶部与Y0对齐 base.position.y -0.05 // 开启阴影接收 base.receiveShadow true // 将底座添加到场景 this.scene.add(base) // 创建环形几何体作为发光效果内半径2.8外半径3.564段 const glowGeometry new THREE.RingGeometry(2.8, 3.5, 64) // 创建发光材质蓝色透明低透明度双面渲染 const glowMaterial new THREE.MeshBasicMaterial({ color: 0x0f4f90, transparent: true, opacity: 0.3, side: THREE.DoubleSide }) // 创建发光网格对象 const glow new THREE.Mesh(glowGeometry, glowMaterial) // 将环形旋转-90度使其水平放置 glow.rotation.x -Math.PI / 2 // 设置发光效果Y位置略高于底座 glow.position.y -0.04 // 将发光效果添加到场景 this.scene.add(glow) }, startAnimation() { const animate () { this.animationId requestAnimationFrame(animate) if (this.group) { this.group.rotation.y 0.001 // 稍微加快一点旋转速度 } this.renderer.render(this.scene, this.camera) } animate() }, stopAnimation() { if (this.animationId) { cancelAnimationFrame(this.animationId) this.animationId null } }, updateChart() { if (this.group) { this.scene.remove(this.group) this.group.traverse(obj { if (obj.geometry) {obj.geometry.dispose()} if (obj.material) {obj.material.dispose()} }) } this.createPieChart() }, handleResize() { const container this.$refs.container const width container.clientWidth || 400 const height container.clientHeight || 400 this.camera.aspect width / height this.camera.updateProjectionMatrix() this.renderer.setSize(width, height) }, disposeResources() { this.stopAnimation() if (this.group) { this.group.traverse(obj { if (obj.geometry) {obj.geometry.dispose()} if (obj.material) {obj.material.dispose()} }) } if (this.renderer) { this.renderer.dispose() const container this.$refs.container if (container this.renderer.domElement) { container.removeChild(this.renderer.domElement) } } }, }, } /script style scoped langless .platform-3d-pie-card { width: 100%; height: 100%; overflow: hidden; background: rgba(0, 10, 32, 0.8); position: relative; display: flex; } .pie-container { flex: 1; position: relative; } .pie-legend { width: 180px; padding: 16px; background: #000a20; border-left: none; overflow-y: auto; .legend-item { display: flex; align-items: center; padding: 8px 0; border-bottom: 1px solid rgba(255, 255, 255, 0.05); :last-child { border-bottom: none; } .legend-color { width: 12px; height: 12px; border-radius: 2px; margin-right: 10px; flex-shrink: 0; } .legend-name { flex: 1; color: #fff; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .legend-value { color: rgba(255, 255, 255, 0.6); font-size: 12px; margin-right: 12px; flex-shrink: 0; } .legend-percent { color: rgba(255, 255, 255, 0.8); font-size: 13px; font-weight: bold; flex-shrink: 0; } } } /style