11|使用 canvs 实现分离饼图

关键设计思想 ​

  • 分离式饼图实现原理 ​​

    • 每个扇形沿中间角度方向外移
    • 用 progress 控制动画过渡(0→1 = 合并 → 分离)
    • 通过深度排序解决视觉重叠问题
  • 响应式设计 ​

    • 尺寸基于画布大小动态计算
    • 支持设备像素比适配(Retina 屏优化)
  • 交互反馈机制 ​​

    • 扇形悬停:放大+变亮
    • 图例悬停:文字变蓝

功能实现

1. 初始化 canvas 尺寸

初始化 Canvas 元素的尺寸,确保它能够正确适应父容器并处理高分辨率显示设备

const initCanvasSize = () => {
	// 1. 检查Canvas元素是否存在
	if (!canvas.value) return;

	// 2. 获取设备像素比(处理Retina等高分辨率屏幕)
	devicePixelRatio.value = window.devicePixelRatio || 1;

	// 3. 获取Canvas的父容器
	const container = canvas.value.parentElement;
	if (!container) return;

	// 4. 获取父容器的实际尺寸
	const width = container.clientWidth;
	const height = container.clientHeight;

	// 5. 设置Canvas的CSS尺寸(显示尺寸)
	canvas.value.style.width = `${width}px`;
	canvas.value.style.height = `${height}px`;
	// 6. 设置Canvas的绘制尺寸(实际像素数)
	canvas.value.width = width * devicePixelRatio.value;
	canvas.value.height = height * devicePixelRatio.value;
};
  • CSS 尺寸 vs 绘制尺寸
    • CSS 尺寸 - 控制元素在页面上的显示大小
    • 绘制尺寸 - 控制 Canvas 内部像素数量
属性 作用 示例 (200px 容器,2x 设备)
style.width 控制元素显示宽度 200px
width Canvas 内部像素宽度 400px

2. 核心绘制流程 (drawChart 函数)​​

  • 初始化与清理画布
    • 获取 Canvas 的 2D 上下文
    • 清除画布并启用高质量抗锯齿
    • 根据设备像素比(devicePixelRatio)缩放画布保证高清显示
const drawChart = () => {
	if (!canvas.value) return;

	// 1. 获取 Canvas 上下文​
	const ctx = canvas.value.getContext("2d");
	if (!ctx) return;

	// 2. 基础设置​
	const width = canvas.value.width; // 获取画布尺寸
	const height = canvas.value.height;
	ctx.clearRect(0, 0, width, height); // 清空画布
	ctx.imageSmoothingEnabled = true; // 启用抗锯齿
	ctx.imageSmoothingQuality = "high"; // 设置高质量渲染

	// 3. 设备像素比适配​​
	ctx.save(); // 保存当前 Canvas 的绘制状态
	ctx.scale(devicePixelRatio.value, devicePixelRatio.value); // 缩放坐标系(解决高清屏模糊问题)

	const logicalWidth = width / devicePixelRatio.value; // 获取实际显示尺寸
	const logicalHeight = height / devicePixelRatio.value;

	// 4. 分层绘制组件​​
	drawTitle(ctx, logicalWidth); // 绘制标题
	drawPieChart(ctx, logicalWidth, logicalHeight); // 绘制饼图
	drawLegend(ctx, logicalWidth, logicalHeight); // 绘制图例

	// 5. 恢复之前保存的 Canvas 状态​
	ctx.restore();
};

3. 标题绘制 (drawTitle)​​

  • ​ 位置 ​​:画布顶部居中(X 居中,Y=40)
  • ​ 样式 ​​:加粗 18px 字体,深灰色文字,居中对齐
  • 示例:ctx.fillText(props.title, width/2, 40)
const drawTitle = (ctx: CanvasRenderingContext2D, width: number) => {
	// 1. 保存当前 Canvas 的绘制状态(样式、变形等)
	ctx.save();

	// 2. 样式设置
	ctx.font = 'bold 18px "Helvetica Neue", Arial, sans-serif';
	ctx.fillStyle = "#333";
	ctx.textAlign = "center";
	ctx.textBaseline = "middle";
	ctx.fillText(props.title, width / 2, 40); // 在指定位置绘制文本内容

	// 3. 恢复之前保存的 Canvas 状态
	ctx.restore();
};

4. 饼图绘制 (drawPieChart)​

drawPieChart 绘制整个饼图
  1. 计算中心点和基本半径
  2. 动画进度处理
  3. 初始化起始角度
  4. 计算每个扇形的位置和属性
  5. 按距离排序(从远到近)
  6. 绘制所有扇形
const drawPieChart = (
	ctx: CanvasRenderingContext2D,
	width: number,
	height: number
) => {
	// 1. 计算饼图中心点和基础尺寸
	const centerX = width / 2;
	const centerY = height / 2;
	const baseRadius = Math.min(width, height) * 0.28; // 饼图半径
	const baseOffset = baseRadius * 0.15; // 分离效果的最大偏移量

	// 2. 处理动画进度
	let progress = animationProgress.value;

	// 特殊处理初始分离状态
	if (!animationState.value.animating && animationState.value.completed) {
		progress = props.isSplit ? 1 : 0; // 根据分离状态设置进度
	}

	// 3. 初始化角度变量
	let startAngle = -Math.PI / 2; // 从顶部开始(12点方向)
	const hoveredIndex = hoveredSector.value; // 当前悬停的扇形索引

	// 4. 计算每个扇形的位置和属性
	const sectors = displayData.value.map((item, i) => {
		// 计算当前扇形角度(占整个圆的百分比)
		const sliceAngle = (item.value / totalValue.value) * (Math.PI * 2);
		const midAngle = startAngle + sliceAngle / 2; // 扇形中心角度

		// 5. 计算分离偏移量
		const offset = props.isSplit ? baseOffset * progress : 0;

		// 6. 处理悬停效果
		const isHovered = i === hoveredIndex;
		const radius = isHovered ? baseRadius * 1.1 : baseRadius; // 悬停时放大扇形

		// 7. 创建扇形对象
		const sector = {
			x: centerX + Math.cos(midAngle) * offset, // 分离后的X坐标
			y: centerY + Math.sin(midAngle) * offset, // 分离后的Y坐标
			radius, // 扇形半径(悬停时更大)
			startAngle: startAngle, // 起始角度
			sliceAngle, // 扇形角度
			color: item.color, // 扇形颜色
			isHovered, // 是否悬停
			distance: Math.sqrt(
				// 计算扇形中心距离原始中心的距离(用于排序)
				Math.pow(centerX + Math.cos(midAngle) * offset - centerX, 2) +
					Math.pow(centerY + Math.sin(midAngle) * offset - centerY, 2)
			),
		};

		// 更新起始角度为下一个扇形的起点
		startAngle += sliceAngle;
		return sector;
	});

	// 8. 按距离排序(从远到近)
	sectors.sort((a, b) => b.distance - a.distance);

	// 9. 绘制所有扇形
	sectors.forEach((sector) => {
		drawSector(
			ctx,
			sector.x,
			sector.y,
			sector.radius,
			sector.startAngle,
			sector.sliceAngle,
			sector.color,
			sector.isHovered
		);
	});
};
drawSector 绘制单个扇形
const drawSector = (
	ctx: CanvasRenderingContext2D,
	x: number, // 扇形中心X坐标
	y: number, // 扇形中心Y坐标
	radius: number, // 扇形半径
	startAngle: number, // 起始角度
	sliceAngle: number, // 扇形角度
	color: string, // 扇形颜色
	isHovered: boolean // 是否悬停
) => {
	// 1. 开始绘制路径
	ctx.beginPath();
	// 2. 移动到中心点
	ctx.moveTo(x, y);
	// 3. 绘制圆弧
	ctx.arc(x, y, radius, startAngle, startAngle + sliceAngle);
	// 4. 闭合路径(回到中心点)
	ctx.closePath();
	// 5. 设置填充样式
	ctx.fillStyle = color;
	// 6. 填充扇形
	ctx.fill();
};

5. 图例绘制 (drawLegend)​​

  • 位置与样式
    • 从画布右下方开始绘制(72%宽度,88%高度)
    • 每个图例项垂直间距 15px
    • 色块尺寸:24x12px,带 4px 圆角
  • 图例交互准备 ​​
    • 记录每个图例项的包围盒坐标到 legendAreas
    • 悬停反馈 ​​:被选中的标签文字变为蓝色(#1a73e8)
const drawLegend = (
	ctx: CanvasRenderingContext2D,
	width: number,
	height: number
) => {
	// 1. 样式设置
	const startX = width * 0.72; // 设置图例起始位置(画布宽度的72%处,高度的88%处)
	const startY = height * 0.88;

	const colorBoxWidth = 24; // 定义颜色块尺寸
	const colorBoxHeight = 12;

	const textPadding = 10; // 文本与颜色块之间的间距

	const borderRadius = 4; // 颜色块的圆角半径

	// 2. 清空图例区域记录数组
	legendAreas.value = [];

	// 3. 保存当前 Canvas 的绘制状态
	ctx.save();

	// 4. 设置文本样式
	ctx.font = '12px "Helvetica Neue", Arial, sans-serif';
	ctx.textAlign = "left";
	ctx.textBaseline = "middle";

	// 5. 遍历所有数据项,绘制图例项
	displayData.value.forEach((item, index) => {
		// 5.1 计算当前图例项的Y坐标(从下往上排列)
		const itemY = startY - index * (colorBoxHeight + 15);
		// 5.2 测量文本宽度
		const textWidth = ctx.measureText(item.label).width;

		// 5.3 记录图例项的点击区域(用于交互)
		legendAreas.value.push({
			x1: startX, // 区域左上角X坐标
			y1: itemY - colorBoxHeight / 2, // 区域左上角Y坐标
			x2: startX + colorBoxWidth + textPadding + textWidth, // 区域右下角X坐标
			y2: itemY + colorBoxHeight / 2, // 区域右下角Y坐标
		});

		// 5.4 绘制颜色块
		ctx.fillStyle = item.color; // 设置颜色块填充色
		ctx.beginPath(); // 开始新路径

		// 5.5 绘制圆角矩形(现代Canvas API)
		ctx.roundRect(
			startX,
			itemY - colorBoxHeight / 2,
			colorBoxWidth,
			colorBoxHeight,
			borderRadius
		);
		// 5.6 填充颜色块
		ctx.fill();
		// 5.7 设置文本颜色(悬停时为蓝色,否则为深灰色)
		ctx.fillStyle = hoveredSector.value === index ? "#1a73e8" : "#555";
		// 5.8 绘制图例文本
		ctx.fillText(item.label, startX + colorBoxWidth + textPadding, itemY);
	});

	// 6. 恢复之前保存的 Canvas 状态
	ctx.restore();
};

6. 动画过渡控制器

实现饼图的展开 / 合并动画。通过控制 animationProgress 的值(从 0 到 1)来驱动动画效果,理解这个函数的关键在于理解它的双状态切换机制和进度计算逻辑。

  • 合并状态(animationProgress = 0):所有扇区聚集在一起,形成完整的饼图

  • 分离状态(animationProgress = 1):每个扇区向外偏移,形成分离效果

  • 循环控制:使用 requestAnimationFrame 递归调用 step,直到进度达到 1

const animateTransition = (immediate = false) => {
	// 1. 立即模式 -- 直接将饼图设置到目标状态(分离或合并),不执行动画
	if (immediate) {
		animationState.value.completed = true;
		animationProgress.value = props.isSplit ? 1 : 0; // 根据分离状态设置进度
		drawChart();
		return;
	}

	// 2. 动画模式
	// 初始化:设置动画状态为 “进行中”,并记录开始时间
	const duration = 600; // 动画持续时间(毫秒)
	const startTime = performance.now(); // 记录动画开始时间
	animationState.value.animating = true;
	animationState.value.completed = false;

	// 3. 动画循环函数
	const step = (timestamp: number) => {
		const elapsed = timestamp - startTime; // 已过去的时间
		animationProgress.value = Math.min(elapsed / duration, 1); // 计算0-1之间的进度值

		// 确保分离状态正确
		if (props.isSplit && animationState.value.direction === 0) {
			animationProgress.value = 1 - animationProgress.value;
		} else if (!props.isSplit && animationState.value.direction === 1) {
			animationProgress.value = 1 - animationProgress.value;
		}

		if (animationProgress.value < 1) {
			requestAnimationFrame(step); // 继续下一帧
		} else {
			animationState.value.animating = false;
			animationState.value.completed = true; // 标记动画完成
		}

		drawChart(); // 重绘图表
	};

	requestAnimationFrame(step); // 启动动画循环
};

7. 其它事件处理

1. 鼠标交互效果

实现鼠标与饼图交互的核心逻辑:主要负责监听鼠标移动、判断鼠标是否悬停在图例或扇形上,并触发相应的视觉反馈(如放大)

  • 整体功能实现
    • 鼠标位置跟踪:记录鼠标在饼图容器内的实时位置
    • 悬停检测:判断鼠标是否悬停在图例或扇形上
    • 状态更新:当悬停目标变化时,更新 hoveredSector 状态并重新绘制图表(触发放大效果)
    • 离开清理:鼠标离开容器时,重置悬停状态
handleMouseMove:跟踪鼠标位置并检测悬停
const handleMouseMove = (event: MouseEvent) => {
	if (!canvas.value) return;

	// 1. 计算鼠标在饼图容器内的相对位置
	const rect = canvas.value.getBoundingClientRect();
	mousePos.value = {
		x: event.clientX - rect.left,
		y: event.clientY - rect.top,
	};

	// 2. 检测当前悬停的目标(图例或扇形)
	const newHoveredSector = getHoveredIndex();

	// 3. 若悬停目标变化,更新状态并重绘图表
	if (newHoveredSector !== hoveredSector.value) {
		hoveredSector.value = newHoveredSector;
		drawChart(); // 重绘时会根据hoveredSector添加放大效果
	}
};
  • 核心作用:将鼠标在页面中的绝对位置,转换为相对于饼图容器的相对位置,并调用 getHoveredIndex 判断悬停目标
  • 性能优化:仅在悬停目标变化时才重绘图表,避免不必要的性能消耗
getHoveredIndex:判断鼠标悬停的目标索引
  • 这是交互逻辑的核心,分两步检测悬停目标(图例优先于扇形):
    1. 优先检测是否悬停在图例上
    2. 其次检测是否悬停在扇形上
  1. 优先检测是否悬停在图例上
  • 图例优先:因为图例是交互高频区域,且视觉上在饼图外部,所以优先检测,避免扇形检测干扰
  • legendAreas:是预定义的图例交互区域(每个图例的矩形范围),在绘制图例时生成
// 遍历所有图例的交互区域,判断鼠标是否在某图例范围内
for (let i = 0; i < legendAreas.value.length; i++) {
	const { x1, y1, x2, y2 } = legendAreas.value[i]; // 图例的矩形范围(预存在legendAreas中)
	// 鼠标坐标是否在当前图例的矩形范围内
	if (
		mousePos.value.x >= x1 &&
		mousePos.value.x <= x2 &&
		mousePos.value.y >= y1 &&
		mousePos.value.y <= y2
	) {
		return i; // 返回图例对应的扇形索引
	}
}
  1. 其次检测是否悬停在扇形上
  • 核心逻辑:通过距离检测(鼠标是否在饼图范围内)和角度检测(鼠标在哪个扇形的角度范围内),判断悬停的扇形索引
  • 角度计算:以饼图中心为原点,将鼠标位置转换为角度(从 12 点方向开始计算),再与每个扇形的角度范围比对
// 1. 计算饼图中心和半径
const cssWidth = canvas.value.clientWidth;
const cssHeight = canvas.value.clientHeight;
const centerX = cssWidth / 2; // 饼图中心X坐标
const centerY = cssHeight / 2; // 饼图中心Y坐标
const baseRadius = Math.min(cssWidth, cssHeight) * 0.28; // 饼图基础半径
const radius = baseRadius * (hoveredSector.value === null ? 1.0 : 1.2); // 悬停时扩大检测范围

// 2. 检测鼠标是否在饼图范围内(距离中心不超过半径)
const mouseX = mousePos.value.x;
const mouseY = mousePos.value.y;
const dx = mouseX - centerX; // 鼠标与中心的X距离
const dy = mouseY - centerY; // 鼠标与中心的Y距离
const distance = Math.sqrt(dx * dx + dy * dy); // 鼠标与中心的直线距离
if (distance > radius) return null; // 不在饼图范围内,返回null

// 3. 计算鼠标与饼图中心的连线角度(用于判断在哪个扇形上)
let angle = Math.atan2(dy, dx) + Math.PI / 2; // 计算角度并调整为从"12点方向"开始
if (angle < 0) angle += 2 * Math.PI; // 确保角度在0~2π范围内

// 4. 遍历所有扇形,判断角度是否在当前扇形的角度范围内
let currentStartAngle = 0; // 记录当前扇形的起始角度
for (let i = 0; i < displayData.value.length; i++) {
	const item = displayData.value[i];
	// 计算当前扇形的角度范围(占比 * 360°对应的弧度)
	const sectorAngle = (item.value / totalValue.value) * 2 * Math.PI;
	const currentEndAngle = currentStartAngle + sectorAngle; // 当前扇形的结束角度

	// 判断鼠标角度是否在当前扇形范围内
	let isInSector = false;
	if (currentStartAngle <= currentEndAngle) {
		// 普通情况:扇形不跨"0度"(如0~90度)
		isInSector = angle >= currentStartAngle && angle <= currentEndAngle;
	} else {
		// 特殊情况:扇形跨"0度"(如300~30度,即300~360度和0~30度)
		isInSector = angle >= currentStartAngle || angle <= currentEndAngle;
	}

	if (isInSector) {
		return i; // 返回当前扇形的索引
	}

	currentStartAngle = currentEndAngle; // 进入下一个扇形的检测
}

return null; // 未悬停在任何扇形上
handleMouseLeave:鼠标离开时重置状态
  • 作用:当鼠标完全离开饼图容器时,清除所有悬停状态,确保视觉效果正确
const handleMouseLeave = () => {
	hoveredSector.value = null; // 重置悬停状态
	drawChart(); // 重绘图表
};
2. 处理窗口大小变化
const handleResize = () => {
	// 初始化 canvas 尺寸
	initCanvasSize();
	// 重绘图表
	drawChart();
};
3.下载图片

实现了饼图的导出功能,允许用户将当前显示的图表保存为 PNG 图片。核心逻辑是创建一个临时 Canvas,将原图表内容复制到临时画布上,然后触发下载

  • 主要工作流程
    • 创建一个与原 Canvas 尺寸相同的临时 Canvas
    • 在临时 Canvas 上绘制白色背景(避免透明背景)
    • 将原 Canvas 的内容复制到临时 Canvas
    • 将临时 Canvas 转换为图片 URL,并创建下载链接
    • 触发下载并清理临时元素
function onDownloadImage() {
	if (!canvas.value) return;

	try {
		// 1. 创建临时canvas用于下载
		const tempCanvas = document.createElement("canvas");
		const tempCtx = tempCanvas.getContext("2d");
		if (!tempCtx) return;

		// 2. 设置临时canvas尺寸(与原始canvas相同)
		tempCanvas.width = canvas.value.width;
		tempCanvas.height = canvas.value.height;

		// 3. 绘制白色背景(确保下载的图片有背景)
		tempCtx.fillStyle = "white";
		tempCtx.fillRect(0, 0, tempCanvas.width, tempCanvas.height);

		// 4. 绘制原始canvas内容
		tempCtx.drawImage(canvas.value, 0, 0);

		// 5. 创建下载链接并触发下载
		const dataUrl = tempCanvas.toDataURL("image/png");
		const link = document.createElement("a");
		link.href = dataUrl;
		link.download = `筛查月统计图_${props.title}.png`;
		document.body.appendChild(link);
		link.click();
		document.body.removeChild(link);
	} catch (error) {
		console.error("下载图表失败:", error);
		ElMessage.error("下载图表失败,请重试");
	}
}
posted on 2025-07-22 10:16  pleaseAnswer  阅读(28)  评论(0)    收藏  举报