10|使用 three.js 实现立体饼图和分离立体饼图
认识 three.js
Q:什么是 Three.js?
-
Three.js 是一个用于在浏览器中创建和渲染 3D 图形的 js 库。它建立在 WebGL 之上,提供了更易于理解和操作的 API,大大降低了创建复杂 3D 应用的难度。
-
Three.js 的主要功能:
- 3D 模型:创建、导入和操作复杂的 3D 模型。
- 灯光和阴影:支持多种灯光类型和复杂的阴影效果。
- 材质和纹理:允许应用多种材质和纹理,为场景增加真实感。
- 相机控制:轻松设置和操控 3D 场景中的相机视角。
- 动画:支持对象动画,创建流畅的 3D 过渡效果。
核心功能分析
-
3D 饼图渲染:
- 使用 Three.js 的 ExtrudeGeometry 创建扇形 3D 模型
- 每个扇形根据数据比例计算角度
- 支持分离/合并动画效果(通过 isSplit 属性控制)
-
交互功能:
- 鼠标悬停高亮扇形(颜色变化 + 轻微放大)
- 图例悬停交互(高亮对应扇形)
-
数据可视化:
- 动态标题显示
- 彩色图例展示数据标签
- 响应式数据更新
-
实用功能:
- 导出图表为 PNG 图片(使用 html2canvas)
- 响应式布局(窗口大小变化自适应)
认识用到的三方工具
import * as THREE from 'three';
-
作用 :导入 Three.js 核心库
-
重要性 :
- 提供创建 3D 场景的基础组件(场景、相机、渲染器)
- 包含几何体、材质、光照等核心 3D 对象
- 是构建任何 Three.js 应用的基础
-
关键组件 :
- Scene:3D 场景容器
- PerspectiveCamera:透视相机
- WebGLRenderer:WebGL 渲染器
- Mesh:网格对象(3D 模型)
- Geometry 和 Material:几何体和材质
import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer';
- 作用 :导入 CSS2D 渲染器和对象
- 重要性 :
- 允许在 3D 场景中使用 HTML/CSS 元素
- 创建高性能的 2D 标签和 UI 元素
- 保持 HTML 元素与 3D 对象的位置同步
- 关键用途 :
- 饼图标签和标题
- 交互式图例
- 数据提示和信息框
- 用户界面控件
import TWEEN from '@tweenjs/tween.js';
- 作用 :导入 Tween.js 动画库
- 重要性 :
- 提供平滑的动画过渡效果
- 简化复杂动画的实现
- 支持缓动函数使动画更自然
- 关键应用 :
- 饼图分离/合并动画
- 扇形悬停效果
- 相机移动动画
- 颜色和透明度过渡
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
- 作用 :导入轨道控制器
- 重要性 :
- 允许用户通过鼠标交互控制 3D 场景
- 支持旋转、缩放和平移操作
- 提供阻尼效果使运动更平滑
- 关键功能 :
- 鼠标左键拖动:旋转场景
- 鼠标滚轮:缩放场景
- 鼠标右键拖动:平移场景
- 自动旋转选项
import html2canvas from 'html2canvas';
- 作用 :导入 HTML 转 Canvas 库
- 重要性 :
- 将 HTML 内容转换为图像
- 支持导出图表为图片
- 保留所有视觉样式和效果
- 关键用途 :
- 导出饼图为 PNG 图片
- 保存图表快照
- 生成可分享的图表图像
管理 Three.js 3D 场景中的对象和状态
1. 容器
const container = ref<HTMLElement | null>(null);
- 作用:存储 DOM 元素的引用,通常是 Three.js 渲染器挂载的容器(如一个 div 元素)
2. Three.js 核心对象
Q:为什么使用 shallowRef?
- Three.js 对象具有复杂内部结构,使用常规 ref 会导致 Vue 深度代理整个对象
- 深度代理会干扰 Three.js 内部操作,导致性能问题和潜在错误
- shallowRef 只跟踪引用变化,不深度代理内部属性,避免这些问题
const scene = shallowRef<THREE.Scene | null>(null);
const camera = shallowRef<THREE.PerspectiveCamera | null>(null);
const renderer = shallowRef<THREE.WebGLRenderer | null>(null);
const cssRenderer = shallowRef<CSS2DRenderer | null>(null);
const controls = shallowRef<OrbitControls | null>(null);
const pieGroup = shallowRef<THREE.Group | null>(null);
const titleObject = shallowRef<CSS2DObject | null>(null);
const raycaster = shallowRef<THREE.Raycaster | null>(null);
各对象作用
| 变量 | 类型 | 作用 |
|---|---|---|
| scene | THREE.Scene | 3D 场景容器,包含所有对象 |
| camera | THREE.PerspectiveCamera | 3D 相机,控制视图 |
| renderer | THREE.WebGLRenderer | WebGL 渲染器,负责绘制 3D 场景 |
| pieGroup | THREE.Group | 饼图容器组,便于整体控制 |
| raycaster | THREE.Raycaster | 光线投射器,用于鼠标交互检测 |
| cssRenderer | CSS2DRenderer | CSS2D 渲染器,用于 HTML 标签 |
| titleObject | CSS2DObject | 标题文本的 CSS2D 对象 |
| controls | OrbitControls | 轨道控制器,实现交互旋转/缩放 |
3. 常规 ref 的数组对象
const sectors = ref<THREE.Mesh[]>([]);
const legendObjects = ref<CSS2DObject[]>([]);
Q:为什么使用常规 ref?
-
这些数组需要响应式更新(添加/删除元素时触发视图更新)
-
Vue 能高效处理数组的响应式变化
各对象作用
| 变量 | 类型 | 作用 |
|---|---|---|
| sectors | THREE.Mesh[] | 存储所有饼图扇形的网格对象 |
| legendObjects | CSS2DObject[] | 存储所有图例标签对象 |
4. Tween 实例管理
let tweenInstances: TWEEN.Tween<any>[] = [];
- 作用:
- 存储所有活动的 Tween.js 动画实例
- 便于统一管理和清理动画
Q:为什么不是响应式?
- Tween 实例是临时对象,不需要响应式特性
- 避免不必要的响应式开销
- 手动管理生命周期更高效
总结
- 这种结构设计确保了:
- Three.js 对象不受 Vue 响应式系统干扰
- 必要的响应式更新(如数组变化)仍然有效
- 清晰的类型定义和代码组织
- 高效的资源管理和清理
初始化场景、相机、渲染器
1. 初始化场景
-
创建场景对象:
new THREE.Scene()- 场景是 Three.js 中所有对象的容器,包括灯光、相机、物体等
-
创建环境光:
new THREE.AmbientLight(0xffffff, 0.6)- 环境光会均匀照亮场景中所有物体,没有方向性
- 参数 1:光的颜色(白色)
- 参数 2:光的强度(0.6,60%强度)
-
创建方向光:
new THREE.DirectionalLight(0xffffff, 0.8)- 方向光类似于太阳光,具有方向性
- 参数 1:光的颜色(白色)
- 参数 2:光的强度(0.8,80%强度)
const initScene = () => {
// 1. 创建场景对象
scene.value = new THREE.Scene();
scene.value.background = new THREE.Color(0xffffff);
// 2. 添加环境光
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.value.add(ambientLight); // 将创建的环境光添加到场景中
// 3. 添加定向光源
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(1, 1, 1); // 设置方向光的位置,表示光源位于右前上方
scene.value.add(directionalLight); // 将创建的方向光添加到场景中
};
2. 初始化相机
初始化 Three.js 相机函数,用于设置 3D 场景的观察视角
-
创建透视投影相机:
new THREE.PerspectiveCamera(35, width / height, 0.1, 1000)- 参数 1:视野角度
- 单位是度。控制相机能看到的范围:值越大视野越宽;值越小:视野越窄;45-60° 是自然视野
- 参数 2:宽高比(根据容器尺寸动态计算)
- 动态计算容器的宽高比例,确保渲染内容不会变形
- 参数 3:近裁切面
- 相机能看到的最小距离,小于此距离的物体不会被渲染
- 参数 4:远裁切面
- 相机能看到的最大距离,大于此距离的物体不会被渲染
- 参数 1:视野角度
-
设置观察视角:
camera.value.lookAt(0, 0, 0)- 使相机对准场景原点 (0,0,0),此时饼图将位于画面正中央
const initCamera = () => {
// 1. 确保容器元素存在
if (!container.value) return;
// 2. 获取容器尺寸
const width = container.value.clientWidth;
const height = container.value.clientHeight;
// 3. 创建透视投影相机
camera.value = new THREE.PerspectiveCamera(35, width / height, 0.1, 1000);
// 4. 设置相机位置
camera.value.position.set(0, 0, 40);
// 5. 设置相机朝向
camera.value.lookAt(0, 0, 0);
};
3. 初始化渲染器
初始化了 Three.js 的渲染器,包括一个 WebGL 渲染器和一个 CSS2D 渲染器
-
创建 WebGL 渲染器:
new THREE.WebGLRenderer({ .. })- 设置设备像素比
setPixelRatio(window.devicePixelRatio)- 自动适配 Retina 等高清屏幕
- 避免在高 DPI 设备上模糊
- 设置设备像素比
-
创建 CSS2D 渲染器:
new CSS2DRenderer()cssRenderer.value.domElement.style.pointerEvents = "none"- 允许鼠标事件穿透到下层 WebGL 画布
- 确保 3D 交互不受上层 DOM 元素阻挡
const initRenderer = () => {
// 1. 确保容器、场景和相机都已存在
if (!container.value || !scene.value || !camera.value) return;
// 2. 获取容器尺寸
const width = container.value.clientWidth;
const height = container.value.clientHeight;
// 3. 创建WebGL渲染器(主3D渲染器)
renderer.value = new THREE.WebGLRenderer({
antialias: true, // 消除图形锯齿,提升视觉质量
alpha: true, // 允许透明背景(与容器背景融合)
preserveDrawingBuffer: true, // 保留绘制缓冲区(用于截图)
});
// 4. 配置WebGL渲染器
renderer.value.setSize(width, height); // 设置渲染器尺寸
renderer.value.setPixelRatio(window.devicePixelRatio); // 设置设备像素比(适应高清屏)
container.value.appendChild(renderer.value.domElement); // 将渲染器的DOM元素(canvas)添加到容器中
// 5. 创建CSS2D渲染器(用于HTML标签)
cssRenderer.value = new CSS2DRenderer();
cssRenderer.value.setSize(width, height); // 设置渲染器尺寸
// 设置CSS2D渲染器样式
cssRenderer.value.domElement.style.position = "absolute";
cssRenderer.value.domElement.style.top = "0";
cssRenderer.value.domElement.style.pointerEvents = "none";
cssRenderer.value.domElement.style.zIndex = "10";
container.value.appendChild(cssRenderer.value.domElement); // 将CSS2D渲染器的DOM元素添加到容器中
};
双渲染器系统解析
| 渲染器类型 | 功能 | 输出元素 | 用途 |
|---|---|---|---|
| WebGLRenderer | 渲染 3D 图形 | <canvas> |
绘制饼图 3D 模型 |
| CSS2DRenderer | 渲染 HTML 标签 | <div> |
显示图例、标题等 UI 元素 |
Q:为什么需要两个渲染器?
-
性能优化:
- WebGL 渲染器专注高性能 3D 渲染
- CSS2D 渲染器处理易变的 UI 元素
-
功能互补:
- WebGL:擅长处理复杂 3D 图形和光照
- CSS2D:完美支持 HTML/CSS 文本样式
-
交互体验:
- CSS 标签可原生响应 CSS 动画和交互
- 鼠标事件穿透确保 3D 交互不受影响
创建饼图
实现饼图的创建、图例生成、分离动画和资源管理
设置立体饼图半径、高度
const radius = 8;
const height = 3;
1. 创建扇形
- 扇形创建过程:
- 路径绘制:从圆心开始 → 直线到圆弧起点 → 绘制圆弧 → 返回圆心
- 3D 成型:将 2D 路径挤出为有厚度的 3D 几何体
- 材质设置:
- 使用 MeshPhongMaterial 实现逼真光照效果
- 设置高光(shininess)增强 3D 立体感
- 元数据存储:保存扇形关键信息用于后续交互
const createSector = (
startAngle: number,
endAngle: number,
radius: number,
height: number,
color: string,
index: number
): THREE.Mesh => {
// 1. 创建2D形状
const shape = new THREE.Shape();
const center = new THREE.Vector2(0, 0); // 创建一个二维原点向量对象
shape.moveTo(center.x, center.y); // 移动到圆心
shape.lineTo(radius * Math.cos(startAngle), radius * Math.sin(startAngle)); // 到圆弧起点
shape.absarc(center.x, center.y, radius, startAngle, endAngle, false); // 画弧线
shape.lineTo(center.x, center.y); // 回到圆心
// 2. 将二维形状拉伸成三维几何体
const geometry = new THREE.ExtrudeGeometry(shape, {
depth: height, // 饼图厚度
bevelEnabled: false, // 无倒角 保持直角边缘
curveSegments: 32, // 曲线精度
});
// 3. 创建材质
const material = new THREE.MeshPhongMaterial({
color: new THREE.Color(color), // 基础颜色
emissive: new THREE.Color(0x111111), // 自发光色
specular: new THREE.Color(0xffffff), // 高光色
shininess: 30, // 高光强度
transparent: true,
});
// 4. 创建网格对象
const mesh = new THREE.Mesh(geometry, material);
// 标签位置:扇形中心点
const midAngle = (startAngle + endAngle) / 2;
const centerPos = new THREE.Vector3(
radius * 0.7 * Math.cos(midAngle),
radius * 0.7 * Math.sin(midAngle),
height / 2
);
// 5. 存储扇形元数据
mesh.userData = {
index, // 数据索引
center: centerPos, // 标签位置
originalColor: color, // 原始颜色
hoverColor: new THREE.Color(color).offsetHSL(0, 0.1, 0.1).getHexString(), // 悬停颜色
originalPosition: new THREE.Vector3(0, height / 2 - 2, 0), // 合并状态位置
};
pieGroup.value?.add(mesh); // 添加到饼图组
return mesh;
};
2. 饼图拼装
- 饼图拼装关键步骤:
- 角度计算:
(value/total)*pi转换为弧度 - 3D 姿态设置:
- X 轴旋转 45 度:创建等角透视效果
- Z 轴旋转 90 度:调整起始位置
- 分离向量计算:
- 先计算扇形中心方向向量
- 应用当前旋转四元数得到世界坐标系方向
- 缩放 1.1 倍作为分离偏移量
- 分组管理:使用 THREE.Group 组织所有饼图元素
- 角度计算:
const createPie = () => {
// 1. 清理旧资源
disposeScene();
if (!scene.value) return;
// 2. 重新创建饼图组
pieGroup.value = new THREE.Group();
scene.value?.add(pieGroup.value);
const data = props.data;
if (data.length === 0) return;
// 3. 计算数据总和
const total = data.reduce((sum, item) => sum + item.value, 0);
// 4. 遍历数据创建扇形
let startAngle = 0;
data.forEach((item, index) => {
// 计算扇形角度
const angle = (item.value / total) * Math.PI * 2;
const endAngle = startAngle + angle;
const midAngle = (startAngle + endAngle) / 2;
// 饼图扇形中心的指向方向
const direction = new THREE.Vector3(
radius * 0.7 * Math.cos(midAngle),
radius * 0.7 * Math.sin(midAngle),
height / 2
).normalize();
// 创建扇形
const sector = createSector(startAngle, endAngle, item.color, index);
// 设置扇形初始姿态
sector.rotation.x = -Math.PI / 4; // 倾斜45度
sector.rotation.z = -Math.PI / 2; // 旋转90度
sector.position.y = height / 2 - 2; // 垂直位置
// 计算分离位置(考虑旋转影响)
const rotation = new THREE.Quaternion();
// 注意顺序:先X旋转,再Z旋转
rotation.setFromEuler(new THREE.Euler(-Math.PI / 4, 0, -Math.PI / 2));
// 将四元数应用到方向向量
const worldDirection = direction.applyQuaternion(rotation);
// 存储分离向量(1.1倍长度)
sector.userData.explodedPosition = worldDirection.multiplyScalar(1.1);
sectors.value.push(sector);
startAngle = endAngle;
});
// 5. 创建图例和标题
createLegends();
createTitle();
// 6. 应用初始状态
if (props.isSplit) {
explodePie();
} else {
mergePie();
}
};
3. 创建图例
- 图例系统特点:
- 纯 HTML 实现:使用 CSS2DRenderer 渲染
- 自动布局:
- 垂直排列在饼图右侧
- 间距 1.5 单位(约 30px)
- 交互绑定:悬停图例会高亮对应扇形
- 样式可控:通过 CSS 类名.legend-item 自定义样式
// 创建图例
const createLegend = (data: PieData, index: number): CSS2DObject => {
// 1. 创建DOM元素
const container = document.createElement("div");
const colorBox = document.createElement("div");
const label = document.createElement("div");
// 2. 设置样式和内容
container.className = "legend-item";
colorBox.className = "legend-color";
colorBox.style.backgroundColor = data.color;
label.className = "legend-label";
label.textContent = data.label;
container.appendChild(colorBox);
container.appendChild(label);
// 3. 添加交互事件
container.addEventListener("mouseenter", () => highlightSector(index));
container.addEventListener("mouseleave", () =>
resetSectorAppearance(sectors.value[index])
);
// 4. 创建CSS2D对象
const cssObject = new CSS2DObject(container);
cssObject.element.style.pointerEvents = "auto";
pieGroup.value?.add(cssObject); // 添加到饼图组
return cssObject;
};
// 更新图例内容
const createLegends = () => {
// 1. 计算布局位置
const legendXOffset = radius + 4; // 饼图右侧
const legendYStart = -10; // 垂直起始位置
// 2. 创建并定位图例
props.data.forEach((item, index) => {
const legend = createLegend(item, index);
legend.position.set(legendXOffset, legendYStart + index * 1.5, 0);
legendObjects.value.push(legend);
});
};
4. 创建标题
const createTitle = () => {
// 1. 创建DOM元素
const titleElement = document.createElement("div");
// 2. 设置样式和内容
titleElement.className = "pie3d-title";
titleElement.textContent = props.title;
// 3. 创建CSS2D对象
titleObject.value = new CSS2DObject(titleElement);
titleObject.value.position.set(0, radius + 3, 0);
pieGroup.value?.add(titleObject.value); // 添加到饼图组
};
5. 动画系统:分离饼图、合并饼图
- 动画特性:
- 物理缓动:使用 Quintic.Out 实现自然减速效果
- 独立动画:每个扇形有自己的动画轨迹
- 800ms 时长:提供足够时间感知运动
function explodePie() {
sectors.value.forEach((sector, index) => {
// 目标位置 = 原始位置 + 分离偏移
const targetPosition = new THREE.Vector3().addVectors(
sector.userData.originalPosition,
sector.userData.explodedPosition
);
// 创建补间动画
const tween = new TWEEN.Tween(sector.position)
.to(targetPosition, 800)
.easing(TWEEN.Easing.Quintic.Out)
.start();
tweenInstances.push(tween);
});
}
function mergePie() {
sectors.value.forEach((sector, index) => {
// 回到原始位置
const tween = new TWEEN.Tween(sector.position)
.to(sector.userData.originalPosition, 800)
.easing(TWEEN.Easing.Quintic.Out)
.start();
tweenInstances.push(tween);
});
}
渲染循环&资源管理
负责驱动整个 3D 场景的动态效果和渲染更新
-
执行动画:
requestAnimationFrame(animate)- 作用:告诉浏览器你希望执行动画,并请求浏览器在下一次重绘之前调用指定的回调函数(即 animate)
- 优势:
- 与屏幕刷新率(通常 60FPS)同步,避免不必要的渲染,节省性能
- 浏览器标签隐藏时自动暂停,减少资源消耗
- 循环机制:animate 函数内部递归调用 requestAnimationFrame,形成持续循环
-
更新所有动画实例:
tweenInstances.forEach((tween) => tween.update())- Tween 原理:TWEEN.js 通过插值计算实现平滑动画(如位置、旋转、缩放变化)。每次调用 update()时,Tween 会根据时间进度计算当前帧的值并应用到目标对象
-
渲染场景:
- WebGL 渲染:renderer 使用 WebGL 绘制 3D 场景
- CSS2D 渲染:cssRenderer 用于渲染 2D 标签(如图例、标题),这些元素跟随 3D 对象但使用 HTML/CSS 渲染
- 执行顺序:两者都需要在每一帧更新,确保 3D 和 2D 元素同步
- 动画循环的工作原理
- 帧循环:浏览器每 16.7ms(60FPS)调用一次 animate 函数
- 状态更新:
- Tween 动画更新对象的属性(如位置、旋转)
- 控制器(如 OrbitControls)更新相机位置
- 渲染:将更新后的场景渲染到画布上
- 视觉反馈:渲染结果显示在屏幕上,形成流畅动画
const animate = () => {
// 1. 执行动画
requestAnimationFrame(animate);
// 2. 更新所有动画实例
tweenInstances.forEach((tween) => tween.update());
// 3. 渲染场景
if (renderer.value && scene.value && camera.value) {
renderer.value.render(scene.value, camera.value);
}
if (cssRenderer.value && scene.value && camera.value) {
cssRenderer.value.render(scene.value, camera.value);
}
};
2. 资源管理:重新渲染前清理掉所有内容
- 资源管理关键点:
- 显式释放:Three.js 不会自动释放 GPU 资源
- 完全清理:
- 几何体(geometry.dispose())
- 材质(material.dispose())
- DOM 元素(element.remove())
- 防止内存泄漏:每次数据更新前彻底清理旧对象
const disposeScene = () => {
// 1. 清理动画
tweenInstances.forEach((tween) => tween.remove());
tweenInstances = [];
// 2. 清理所有扇形资源
sectors.value.forEach((sector) => {
// 三维几何体
sector.geometry.dispose();
// 材质
const material = Array.isArray(sector.material)
? sector.material
: [sector.material];
material.forEach((m) => m.dispose());
});
sectors.value = [];
// 3. 清理所有图例资源
legendObjects.value.forEach((legend) => {
if (legend.element.parentNode) {
legend.element.parentNode.removeChild(legend.element);
}
});
legendObjects.value = [];
// 4. 清理标题资源
if (titleObject.value) {
if (titleObject.value.element.parentNode) {
titleObject.value.element.parentNode.removeChild(
titleObject.value.element
);
}
titleObject.value = null;
}
// 5. 移除场景对象
if (pieGroup.value) {
scene.value?.remove(pieGroup.value);
pieGroup.value = null;
}
};
其它事件处理
1. 鼠标交互效果
实现了 3D 饼图的鼠标交互功能,包括扇形的高亮效果和状态恢复
关键点:
- 计算归一化设备坐标:将鼠标的屏幕坐标转换为 Three.js 使用的归一化设备坐标
- 获取容器边界矩形:
container.value.getBoundingClientRect()- 返回元素在视口中的位置和尺寸
- 计算鼠标在容器内的相对位置
// 鼠标X坐标相对于容器左边界的位置(范围: 0到容器宽度) const relativeX = event.clientX - rect.left; // 鼠标Y坐标相对于容器上边界的位置(范围: 0到容器高度) const relativeY = event.clientY - rect.top; - 归一化处理(将像素值转换为 0-1 范围)
// X坐标归一化: 相对位置 / 容器宽度 const normalizedX = relativeX / rect.width; // Y坐标归一化: 相对位置 / 容器高度 const normalizedY = relativeY / rect.height; - 转换为 NDC 坐标(将 0-1 范围映射到 - 1 到 1)
- 为什么 Y 轴是负的?
- 屏幕坐标系:原点在左上角,Y 轴向下为正。
- NDC 坐标系:原点在中心,Y 轴向上为正。
// X坐标: [0,1] → [-1,1] mouse.x = normalizedX * 2 - 1; // Y坐标: [0,1] → [1,-1](注意Y轴方向相反) mouse.y = -(normalizedY * 2 - 1); // 等价于 -normalizedY * 2 + 1 - 为什么 Y 轴是负的?
- 获取容器边界矩形:
// 处理鼠标移动
const onMouseMove = (event: MouseEvent) => {
// 1. 安全校验(确保所需对象已初始化)
if (!container.value || !raycaster.value || !camera.value || !scene.value)
return;
// 2. 计算归一化设备坐标 (NDC)
const rect = container.value.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
// 3. 设置射线检测器
raycaster.value.setFromCamera(mouse, camera.value);
// 4. 检测与扇形的交点
const intersects = raycaster.value.intersectObjects(sectors.value);
// 5. 处理交互状态
if (intersects.length > 0) {
const sector = intersects[0].object as THREE.Mesh;
// 切换到新扇形
if (sector !== hoveredSector) {
// 恢复之前悬停的扇形
if (hoveredSector) {
resetSectorAppearance(hoveredSector);
}
// 高亮当前悬停的扇形
highlightSector(sector.userData.index);
hoveredSector = sector;
}
// 无悬停对象时恢复状态
} else if (hoveredSector) {
resetSectorAppearance(hoveredSector);
hoveredSector = null;
}
};
扇形高亮效果
const highlightSector = (index: number) => {
const sector = sectors.value[index];
if (!sector) return;
const material = sector.material as THREE.MeshPhongMaterial;
// 1. 更改颜色
material.color.set(sector.userData.hoverColor);
// 2. 添加放大动画
const tween = new TWEEN.Tween(sector.scale)
.to({ x: 1.05, y: 1.05, z: 1.05 }, 200)
.easing(TWEEN.Easing.Back.Out)
.start();
tweenInstances.push(tween);
};
恢复扇形状态
const resetSectorAppearance = (sector: THREE.Mesh) => {
const material = sector.material as THREE.MeshPhongMaterial;
// 1. 恢复原始颜色
material.color.set(sector.userData.originalColor);
// 2. 添加缩小动画
const tween = new TWEEN.Tween(sector.scale)
.to({ x: 1, y: 1, z: 1 }, 200)
.easing(TWEEN.Easing.Back.Out)
.start();
tweenInstances.push(tween);
};
2. 处理窗口大小变化
处理窗口大小变化时调整 3D 渲染场景的函数:确保当容器尺寸改变时,Three.js 的渲染器、相机和 CSS2D 渲染器能够适应新的尺寸,保持正确的宽高比和显示效果
const onResize = () => {
// 1. 安全校验:确保所需元素已初始化
if (
!container.value ||
!camera.value ||
!renderer.value ||
!cssRenderer.value
)
return;
// 2. 获取容器当前尺寸
const width = container.value.clientWidth;
const height = container.value.clientHeight;
// 3. 更新相机参数
camera.value.aspect = width / height; // 设置新的宽高比
camera.value.updateProjectionMatrix(); // 应用新的投影矩阵
// 4. 更新WebGL渲染器尺寸
renderer.value.setSize(width, height);
// 5. 更新CSS2D渲染器尺寸
cssRenderer.value.setSize(width, height);
};
3.下载图片
将 3D 饼图导出为高清 PNG 图片
function onDownloadImage() {
// 1. 确保场景渲染到最新状态
renderer.value.render(scene.value, camera.value);
// 2. 获取图表容器DOM元素
const chartContainer = document.querySelector(
".pie3d-container"
) as HTMLElement;
// 3. 使用html2canvas转换DOM为图片
html2canvas(chartContainer, {
backgroundColor: "#ffffff", // 设置白色背景
scale: 2, // 2倍高清截图
useCORS: true, // 处理跨域资源
logging: false, // 禁用控制台日志
}).then((canvas) => {
// 4. 获取图片数据URL
const imageData = canvas.toDataURL("image/png");
// 5. 创建下载链接并触发下载
const link = document.createElement("a");
link.href = imageData;
link.download = `筛查月统计图_${props.title}.png`;
link.click();
});
}
数据监听
watch(
() => props.data,
() => {
createPie();
},
{ deep: true }
);
watch(
() => props.title,
(newTitle) => {
if (titleObject.value) {
titleObject.value.element.textContent = newTitle;
}
}
);
watch(
() => props.isSplit,
(newValue) => {
if (newValue) {
explodePie();
} else {
mergePie();
}
}
);
组件挂载和销毁
onMounted 生命周期钩子,负责整个 3D 饼图的创建、渲染和交互设置
onMounted(() => {
// 1. 确保容器元素存在
if (!container.value) return;
// 2. 初始化Three.js核心对象
mouse = new THREE.Vector2(); // 鼠标位置跟踪器
raycaster.value = new THREE.Raycaster(); // 3D射线检测器
hoveredSector = null; // 当前悬停的扇形
// 3. 初始化Three.js核心系统
initScene(); // 创建场景和灯光
initCamera(); // 设置相机
initRenderer(); // 初始化渲染器
// 4. 创建饼图
createPie(); // 生成3D饼图和UI元素
// 5. 启动动画循环
animate(); // 开始持续渲染场景
// 6. 绑定事件监听器
container.value.addEventListener("mousemove", throttle(onMouseMove)); // 鼠标移动交互
window.addEventListener("resize", throttle(onResize)); // 响应式布局
});
onBeforeUnmount 生命周期钩子,所有资源被正确释放,避免内存泄漏和其他潜在问题
onBeforeUnmount(() => {
// 1. 移除事件监听器
if (container.value) {
container.value.removeEventListener("mousemove", onMouseMove);
}
window.removeEventListener("resize", onResize);
// 2. 清理WebGL渲染器
if (renderer.value) {
renderer.value.dispose();
}
// 3. 清理CSS2D渲染器
if (cssRenderer.value) {
cssRenderer.value.domElement.remove();
}
// 4. 深度清理场景资源
disposeScene();
});
浙公网安备 33010602011771号