最新国产好看的视频,伊人天堂AV在线,国产Aaaaaa视频,蜜臀视频在线观看一区,人妻av色图,密臀久久久精品影片,青青视频免费观看毛片,久草在线观看视,国产三级精品色情在线

用Three.js實(shí)現(xiàn)3D圓環(huán)圖的思路及實(shí)例代碼

 更新時(shí)間:2026年02月06日 08:30:31   作者:Iam0830  
作為一個(gè)數(shù)據(jù)可視化中經(jīng)久不衰的經(jīng)典圖表,3D餅圖不僅能更直觀地展示數(shù)據(jù)比例關(guān)系,還能通過(guò)立體的視覺(jué)效果增強(qiáng)整體的視覺(jué)沖擊力,這篇文章主要介紹了用Three.js實(shí)現(xiàn)3D圓環(huán)圖的思路及實(shí)例代碼,需要的朋友可以參考下

最近做大屏,碰到個(gè)挺煩的問(wèn)題:ECharts 和highCharts的 3D 圓環(huán)圖在特定角度下會(huì)有透視錯(cuò)位,在網(wǎng)上找了多個(gè)例子 基本都有這個(gè)問(wèn)題。

折騰了一下午,突然想到three.js這個(gè)3d庫(kù),于是干脆用 Three.js 實(shí)現(xiàn),效果還不錯(cuò),簡(jiǎn)單記錄下思路。

【實(shí)現(xiàn)思路】

其實(shí)核心邏輯就幾步,沒(méi)想象中那么復(fù)雜:

1. 搞定幾何體 (Geometry)

核心是利用ExtrudeGeometry將二維圓環(huán)平面擠壓成三維實(shí)體。具體代碼步驟如下:

(1) 繪制二維圓環(huán)面 (THREE.Shape):

  • 實(shí)例化一個(gè)Shape對(duì)象。 利用.absarc(0, 0, outerRadius, startAngle, endAngle,
    false) 方法繪制外圓弧(逆時(shí)針)。
  • 創(chuàng)建一個(gè) Path 對(duì)象,同樣利用 .absarc(0, 0, innerRadius, startAngle, endAngle, true) 繪制內(nèi)圓?。槙r(shí)針),這代表圓環(huán)中間的“洞”。
  • 將內(nèi)圓弧 Path 加入到Shape.holes 數(shù)組中,這就構(gòu)成了一個(gè)封閉的二維圓環(huán)面。

(2) 擠壓成型 (ExtrudeGeometry):

  • 配置擠壓參數(shù) settings:核心是 depth (高度)。我們將數(shù)據(jù)數(shù)值映射為 depth,數(shù)值越大擠壓越高,形成階梯視覺(jué)。
  • 關(guān)鍵設(shè)置:必須設(shè)置 bevelEnabled: false。ECharts 的 3D 餅圖通常帶倒角 (Bevel),導(dǎo)致拼接處有縫隙。關(guān)閉倒角后,扇區(qū)之間是純粹的幾何體貼合,嚴(yán)絲合縫。
  • 最后調(diào)用 new THREE.ExtrudeGeometry(shape, settings) 生成三維幾何體。

2. 解決“遮擋”問(wèn)題

剛做完發(fā)現(xiàn)個(gè)坑:大扇面把小扇面擋住了。

因?yàn)?3D 視角通常是俯視+側(cè)視,如果一個(gè)很高的扇形在正前方 (Camera 看來(lái)),后面的數(shù)據(jù)如果較小根本就看不到。

解決辦法:簡(jiǎn)單粗暴,把數(shù)據(jù)排個(gè)序。渲染前先把數(shù)據(jù)按數(shù)值 從大到小 (Desc) 排序。

原理:Three.js 逆時(shí)針繪制。第一塊最大的數(shù)據(jù)會(huì)占據(jù) 0° (右側(cè)) 到 90°+ (后方) 的區(qū)域。

結(jié)果:最高的“墻”被甩到了最后面,最矮的小扇區(qū)最后繪制,剛好落在 270° (前方)。形成了“前低后高”的劇院式布局,完美解決遮擋。

3. 標(biāo)簽 (Label) 怎么搞?

Three.js 自帶的 TextGeometry 生成漢字不僅包大,還容易有鋸齒。推薦用 CSS2DRenderer。簡(jiǎn)單說(shuō)就是把 DOM 節(jié)點(diǎn)映射到 3D 坐標(biāo)上。

優(yōu)勢(shì):直接用 CSS 寫樣式,文字永遠(yuǎn)正對(duì)屏幕,不會(huì)因?yàn)樾D(zhuǎn)而變形。

細(xì)節(jié):圖表是深色的,文字也是深色的,容易看不清。我給文字加了一圈白色的 Halo (光暈) 描邊 (text-shadow),看起來(lái)就清晰了。

計(jì)算一下扇區(qū)的中心點(diǎn)坐標(biāo) (Math.cos/sin),把 div 定位過(guò)去就行。

【總結(jié)】

雖然代碼量比配置 ECharts / HighCharts 多,但是效果很好,完全沒(méi)有錯(cuò)位問(wèn)題,并且十分流暢。

代碼放到下方,需要用的朋友自取

<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)簽,不需要完全重建幾何體,但為了簡(jiǎn)單起見(jiàn),這里重建標(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)境光,避免過(guò)曝
      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 // 不自動(dòng)旋轉(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) {
          // 最后一個(gè)扇形: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ù),避免精度問(wèn)題導(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
        
        // 最后一個(gè)扇形強(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)圓反向
        // 自動(dòng)閉合 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ū)交界處的重疊問(wèn)題
          bevelThickness: 0.2, // 減小倒角使其看起來(lái)更銳利,像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)后對(duì)應(yīng) XZ )
      // 因?yàn)槲覀儼?Mesh 繞 X 旋轉(zhuǎn)了 -90度,所以原 Mesh 的 (x, y, z) -> 新的 (x, z, -y)
      // Extrude 的 Z 變成了 場(chǎng)景的 Y
      
      // 標(biāo)簽半徑稍微大一點(diǎn)
      const labelRadius = radius + 4
      
      const x = Math.cos(midAngle) * labelRadius
      const z = Math.sin(midAngle) * labelRadius // 對(duì)應(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 (資金來(lái)源分布圖)
      // 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)
      
      // 只檢測(cè) 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ù)上一個(gè)
          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)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!

相關(guān)文章

最新評(píng)論

禹州市| 浦东新区| 托克托县| 稷山县| 沈丘县| 延吉市| 浏阳市| 万荣县| 周口市| 唐河县| 浪卡子县| 成武县| 南华县| 岳普湖县| 株洲县| 武鸣县| 紫阳县| 盱眙县| 黔江区| 黄龙县| 宿松县| 梅河口市| 铜鼓县| 澄城县| 庆阳市| 毕节市| 郁南县| 平乡县| 青铜峡市| 额敏县| 怀远县| 乐东| 铁力市| 中牟县| 交口县| 八宿县| 夏邑县| 阿尔山市| 松潘县| 宿迁市| 天门市|