用Three.js實(shí)現(xiàn)3D圓環(huán)圖的思路及實(shí)例代碼
最近做大屏,碰到個挺煩的問題:ECharts 和highCharts的 3D 圓環(huán)圖在特定角度下會有透視錯位,在網(wǎng)上找了多個例子 基本都有這個問題。
折騰了一下午,突然想到three.js這個3d庫,于是干脆用 Three.js 實(shí)現(xiàn),效果還不錯,簡單記錄下思路。




【實(shí)現(xiàn)思路】
其實(shí)核心邏輯就幾步,沒想象中那么復(fù)雜:
1. 搞定幾何體 (Geometry)
核心是利用ExtrudeGeometry將二維圓環(huán)平面擠壓成三維實(shí)體。具體代碼步驟如下:
(1) 繪制二維圓環(huán)面 (THREE.Shape):
- 實(shí)例化一個Shape對象。 利用.absarc(0, 0, outerRadius, startAngle, endAngle,
false) 方法繪制外圓?。鏁r(shí)針)。 - 創(chuàng)建一個 Path 對象,同樣利用 .absarc(0, 0, innerRadius, startAngle, endAngle, true) 繪制內(nèi)圓?。槙r(shí)針),這代表圓環(huán)中間的“洞”。
- 將內(nèi)圓弧 Path 加入到Shape.holes 數(shù)組中,這就構(gòu)成了一個封閉的二維圓環(huán)面。
(2) 擠壓成型 (ExtrudeGeometry):
- 配置擠壓參數(shù) settings:核心是 depth (高度)。我們將數(shù)據(jù)數(shù)值映射為 depth,數(shù)值越大擠壓越高,形成階梯視覺。
- 關(guān)鍵設(shè)置:必須設(shè)置 bevelEnabled: false。ECharts 的 3D 餅圖通常帶倒角 (Bevel),導(dǎo)致拼接處有縫隙。關(guān)閉倒角后,扇區(qū)之間是純粹的幾何體貼合,嚴(yán)絲合縫。
- 最后調(diào)用 new THREE.ExtrudeGeometry(shape, settings) 生成三維幾何體。
2. 解決“遮擋”問題
剛做完發(fā)現(xiàn)個坑:大扇面把小扇面擋住了。
因?yàn)?3D 視角通常是俯視+側(cè)視,如果一個很高的扇形在正前方 (Camera 看來),后面的數(shù)據(jù)如果較小根本就看不到。
解決辦法:簡單粗暴,把數(shù)據(jù)排個序。渲染前先把數(shù)據(jù)按數(shù)值 從大到小 (Desc) 排序。
原理:Three.js 逆時(shí)針繪制。第一塊最大的數(shù)據(jù)會占據(jù) 0° (右側(cè)) 到 90°+ (后方) 的區(qū)域。
結(jié)果:最高的“墻”被甩到了最后面,最矮的小扇區(qū)最后繪制,剛好落在 270° (前方)。形成了“前低后高”的劇院式布局,完美解決遮擋。
3. 標(biāo)簽 (Label) 怎么搞?
Three.js 自帶的 TextGeometry 生成漢字不僅包大,還容易有鋸齒。推薦用 CSS2DRenderer。簡單說就是把 DOM 節(jié)點(diǎn)映射到 3D 坐標(biāo)上。
優(yōu)勢:直接用 CSS 寫樣式,文字永遠(yuǎn)正對屏幕,不會因?yàn)樾D(zhuǎn)而變形。
細(xì)節(jié):圖表是深色的,文字也是深色的,容易看不清。我給文字加了一圈白色的 Halo (光暈) 描邊 (text-shadow),看起來就清晰了。
計(jì)算一下扇區(qū)的中心點(diǎn)坐標(biāo) (Math.cos/sin),把 div 定位過去就行。
【總結(jié)】
雖然代碼量比配置 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' 用于控制標(biāo)簽顯示格式
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() {
// 類型切換時(shí)更新標(biāo)簽,不需要完全重建幾何體,但為了簡單起見,這里重建標(biāo)簽或整體
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) // 調(diào)整相機(jī)位置以獲得良好的俯視 3D 視角
this.camera.lookAt(0, 0, 0)
// Lights - 柔和光照
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6) // 降低環(huán)境光,避免過曝
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' // 允許鼠標(biāo)穿透到下方 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 // 禁用縮放,避免穿?;蛱h(yuǎn)
this.controls.autoRotate = false // 不自動旋轉(zhuǎn),由用戶控制
this.controls.minPolarAngle = 0 // 限制垂直旋轉(zhuǎn)角度,避免看穿底部
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 // 高度差異倍數(shù)
}
// 找到最大值用于歸一化高度
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 返回的是字符串,需要轉(zhuǎn)為數(shù)字
percentVal = Number(floatSub(100, accumulatedPercent))
// 防止浮點(diǎn)數(shù)誤差出現(xiàn)負(fù)數(shù)極小值
if (percentVal < 0) percentVal = 0
} else {
// 計(jì)算占比:(amount / total) * 100
// 保留2位小數(shù),避免精度問題導(dǎo)致 gap
const ratio = floatDiv(item.amount, total)
const p = floatMul(ratio, 100)
percentVal = Number(p.toFixed(2))
accumulatedPercent = floatAdd(accumulatedPercent, percentVal)
}
// 根據(jù)占比計(jì)算角度 ( percentVal / 100 * 2PI )
const angleLength = (percentVal / 100) * Math.PI * 2
// 最后一個扇形強(qiáng)制閉合到 2PI
const endAngle = isLast ? Math.PI * 2 : startAngle + angleLength
// 1. 創(chuàng)建 Shape
const shape = new THREE.Shape()
// 繪制圓環(huán)截面
shape.absarc(0, 0, CONFIG.outerRadius, startAngle, endAngle, false)
shape.absarc(0, 0, CONFIG.innerRadius, endAngle, startAngle, true) // 內(nèi)圓反向
// 自動閉合 shape.closePath() 被 ExtrudeGeometry 處理
// 2. 計(jì)算擠壓設(shè)置
const itemRatio = item.amount / maxAmount
const extrusionDepth = CONFIG.baseHeight + (itemRatio * CONFIG.heightScale)
const extrudeSettings = {
steps: 1,
depth: extrusionDepth,
bevelEnabled: false, // 禁用倒角,解決扇區(qū)交界處的重疊問題
bevelThickness: 0.2, // 減小倒角使其看起來更銳利,像ECharts
bevelSize: 0.2,
bevelSegments: 2,
curveSegments: 32 // 平滑度
}
const geometry = new THREE.ExtrudeGeometry(shape, extrudeSettings)
geometry.computeBoundingBox()
// 使用 PhysicalMaterial 增加質(zhì)感
// 移除反光,回歸 ECharts 風(fēng)格的啞光質(zhì)感
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)
// 旋轉(zhuǎn) Mesh 使其平躺,高度方向變?yōu)?Y 軸 (原 Extrude 方向?yàn)?Z)
mesh.rotation.x = -Math.PI / 2
// 調(diào)整位置,使其底面位于 Y=0 (原 Z=0 變?yōu)?Y=0)
// 此時(shí)扇形中心在 (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. 添加標(biāo)簽
this.addLabel(startAngle, endAngle, CONFIG.outerRadius, extrusionDepth, item, percentVal.toFixed(2) + '%')
startAngle = endAngle
})
// 整體居中一點(diǎn)
this.meshGroup.position.set(0, -5, 0)
},
addLabel(startAngle, endAngle, radius, height, item, ratioText) {
// 計(jì)算角度中點(diǎn)
const midAngle = startAngle + (endAngle - startAngle) / 2
// 計(jì)算標(biāo)簽在 XZ 平面上的位置 ( Mesh 旋轉(zhuǎn)前是 XY,旋轉(zhuǎn)后對應(yīng) XZ )
// 因?yàn)槲覀儼?Mesh 繞 X 旋轉(zhuǎn)了 -90度,所以原 Mesh 的 (x, y, z) -> 新的 (x, z, -y)
// Extrude 的 Z 變成了 場景的 Y
// 標(biāo)簽半徑稍微大一點(diǎn)
const labelRadius = radius + 4
const x = Math.cos(midAngle) * labelRadius
const z = Math.sin(midAngle) * labelRadius // 對應(yīng)原系的 y
const y = height + 2 // 標(biāo)簽高度浮在柱體上方
// 創(chuàng)建 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, // 稍微低一點(diǎn),不要浮太高
-Math.sin(midAngle) * labelRadius
)
this.meshGroup.add(label)
// 繪制引導(dǎo)線 (Line)
// 從柱體中心點(diǎn)連到標(biāo)簽點(diǎn)
const points = []
// 起點(diǎn):柱體頂部邊緣
const startP = new THREE.Vector3(
Math.cos(midAngle) * radius,
height,
-Math.sin(midAngle) * radius
)
// 終點(diǎn):標(biāo)簽位置
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) {
// 恢復(fù)上一個
if (this.hoveredIndex !== -1) {
this.resetHighlight()
}
this.hoveredIndex = object.userData.index
// 高亮當(dāng)前
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') {
// 重置時(shí)恢復(fù)原始透明度
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>
總結(jié)
到此這篇關(guān)于用Three.js實(shí)現(xiàn)3D圓環(huán)圖的文章就介紹到這了,更多相關(guān)Three.js實(shí)現(xiàn)3D圓環(huán)圖內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
JavaScript函數(shù)及其prototype詳解
這篇文章主要介紹了JavaScript函數(shù)及其prototype詳解的相關(guān)資料,需要的朋友可以參考下2023-03-03
JS中async/await實(shí)現(xiàn)異步調(diào)用的方法
這篇文章主要介紹了async/await實(shí)現(xiàn)異步調(diào)用的方法,本文給大家介紹的非常詳細(xì),具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2019-08-08
layui實(shí)現(xiàn)動態(tài)和靜態(tài)分頁
本篇文章通過實(shí)例給大家分享了layui實(shí)現(xiàn)動態(tài)和靜態(tài)分頁的詳細(xì)方法,以及效果展示,有需要的朋友可以跟著參考學(xué)習(xí)下。2018-04-04
詳解如何使用JavaScript獲取自動消失的聯(lián)想詞
前幾天在做數(shù)據(jù)分析時(shí),我嘗試獲取某網(wǎng)站上輸入搜索詞后的聯(lián)想詞,輸入搜索詞后會彈出一個顯示聯(lián)想詞的框,有趣的是,輸入框失去焦點(diǎn)后,聯(lián)想詞彈框就自動消失了,這種情況下該怎么辦呢,所以本文給大家介紹了如何使用JavaScript獲取自動消失的聯(lián)想詞,需要的朋友可以參考下2024-06-06
深入理解JavaScript系列(26):設(shè)計(jì)模式之構(gòu)造函數(shù)模式詳解
這篇文章主要介紹了深入理解JavaScript系列(26):設(shè)計(jì)模式之構(gòu)造函數(shù)模式詳解,本文講解了基本用法、構(gòu)造函數(shù)與原型、只能用new嗎?、強(qiáng)制使用new、原始包裝函數(shù)等內(nèi)容,需要的朋友可以參考下2015-03-03
JavaScript實(shí)現(xiàn)網(wǎng)頁對象拖放功能的方法
這篇文章主要介紹了JavaScript實(shí)現(xiàn)網(wǎng)頁對象拖放功能的方法,涉及javascript針對瀏覽器的判斷、事件愛你的添加與移除等相關(guān)操作技巧,非常具有實(shí)用價(jià)值,需要的朋友可以參考下2015-04-04
PHP實(shí)現(xiàn)基于Redis的MessageQueue隊(duì)列封裝操作示例
這篇文章主要介紹了PHP實(shí)現(xiàn)基于Redis的MessageQueue隊(duì)列封裝操作,結(jié)合實(shí)例形式分析了Redis的PHP消息隊(duì)列封裝與使用相關(guān)操作技巧,需要的朋友可以參考下2019-02-02

