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)
    • 响应式布局(窗口大小变化自适应)

认识用到的三方工具

  1. import * as THREE from 'three';
  • 作用 ​​:导入 Three.js 核心库

  • 重要性 ​​:

    • 提供创建 3D 场景的基础组件(场景、相机、渲染器)
    • 包含几何体、材质、光照等核心 3D 对象
    • 是构建任何 Three.js 应用的基础
  • 关键组件 ​​:

    • Scene:3D 场景容器
    • PerspectiveCamera:透视相机
    • WebGLRenderer:WebGL 渲染器
    • Mesh:网格对象(3D 模型)
    • Geometry 和 Material:几何体和材质
  1. import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer';
  • 作用 ​​:导入 CSS2D 渲染器和对象
  • ​ 重要性 ​​:
    • 允许在 3D 场景中使用 HTML/CSS 元素
    • 创建高性能的 2D 标签和 UI 元素
    • 保持 HTML 元素与 3D 对象的位置同步
  • 关键用途 ​​:
    • 饼图标签和标题
    • 交互式图例
    • 数据提示和信息框
    • 用户界面控件
  1. import TWEEN from '@tweenjs/tween.js';
  • ​ 作用 ​​:导入 Tween.js 动画库
  • ​ 重要性 ​​:
    • 提供平滑的动画过渡效果
    • 简化复杂动画的实现
    • 支持缓动函数使动画更自然
  • 关键应用 ​​:
    • 饼图分离/合并动画
    • 扇形悬停效果
    • 相机移动动画
    • 颜色和透明度过渡
  1. import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
  • 作用 ​​:导入轨道控制器
  • 重要性 ​​:
    • 允许用户通过鼠标交互控制 3D 场景
    • 支持旋转、缩放和平移操作
    • 提供阻尼效果使运动更平滑
  • 关键功能 ​​:
    • 鼠标左键拖动:旋转场景
    • 鼠标滚轮:缩放场景
    • 鼠标右键拖动:平移场景
    • 自动旋转选项
  1. 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. 初始化场景
  1. 创建场景对象:new THREE.Scene()

    • 场景是 Three.js 中所有对象的容器,包括灯光、相机、物体等
  2. 创建环境光:new THREE.AmbientLight(0xffffff, 0.6)

    • 环境光会均匀照亮场景中所有物体,没有方向性
    • 参数 1:光的颜色(白色)
    • 参数 2:光的强度(0.6,60%强度)
  3. 创建方向光: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 场景的观察视角

  1. 创建透视投影相机:new THREE.PerspectiveCamera(35, width / height, 0.1, 1000)

    • 参数 1:视野角度
      • 单位是度。控制相机能看到的范围:值越大视野越宽;值越小:视野越窄;45-60° 是自然视野
    • 参数 2:宽高比(根据容器尺寸动态计算)
      • 动态计算容器的宽高比例,确保渲染内容不会变形
    • 参数 3:近裁切面
      • 相机能看到的最小距离,小于此距离的物体不会被渲染
    • 参数 4:远裁切面
      • 相机能看到的最大距离,大于此距离的物体不会被渲染
  2. 设置观察视角: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 渲染器

  1. 创建 WebGL 渲染器:new THREE.WebGLRenderer({ .. })

    • 设置设备像素比 setPixelRatio(window.devicePixelRatio)
      • 自动适配 Retina 等高清屏幕
      • 避免在高 DPI 设备上模糊
  2. 创建 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. 创建扇形
  • 扇形创建过程:
    1. 路径绘制:从圆心开始 → 直线到圆弧起点 → 绘制圆弧 → 返回圆心
    2. 3D 成型:将 2D 路径挤出为有厚度的 3D 几何体
    3. 材质设置:
      • 使用 MeshPhongMaterial 实现逼真光照效果
      • 设置高光(shininess)增强 3D 立体感
    4. 元数据存储:保存扇形关键信息用于后续交互
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 场景的动态效果和渲染更新

  1. 执行动画:requestAnimationFrame(animate)

    • 作用:告诉浏览器你希望执行动画,并请求浏览器在下一次重绘之前调用指定的回调函数(即 animate)
    • 优势:
      • 与屏幕刷新率(通常 60FPS)同步,避免不必要的渲染,节省性能
      • 浏览器标签隐藏时自动暂停,减少资源消耗
      • 循环机制:animate 函数内部递归调用 requestAnimationFrame,形成持续循环
  2. 更新所有动画实例:tweenInstances.forEach((tween) => tween.update())

    • Tween 原理:TWEEN.js 通过插值计算实现平滑动画(如位置、旋转、缩放变化)。每次调用 update()时,Tween 会根据时间进度计算当前帧的值并应用到目标对象
  3. 渲染场景:

    • 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 使用的归一化设备坐标
    1. 获取容器边界矩形:container.value.getBoundingClientRect()
      • 返回元素在视口中的位置和尺寸
    2. 计算鼠标在容器内的相对位置
      // 鼠标X坐标相对于容器左边界的位置(范围: 0到容器宽度)
      const relativeX = event.clientX - rect.left;
      // 鼠标Y坐标相对于容器上边界的位置(范围: 0到容器高度)
      const relativeY = event.clientY - rect.top;
      
    3. 归一化处理(将像素值转换为 0-1 范围)
      // X坐标归一化: 相对位置 / 容器宽度
      const normalizedX = relativeX / rect.width;
      // Y坐标归一化: 相对位置 / 容器高度
      const normalizedY = relativeY / rect.height;
      
    4. 转换为 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
      
// 处理鼠标移动
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();
});
posted on 2025-07-22 10:14  pleaseAnswer  阅读(123)  评论(0)    收藏  举报