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 绘制整个饼图
- 计算中心点和基本半径
- 动画进度处理
- 初始化起始角度
- 计算每个扇形的位置和属性
- 按距离排序(从远到近)
- 绘制所有扇形
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:判断鼠标悬停的目标索引
- 这是交互逻辑的核心,分两步检测悬停目标(图例优先于扇形):
- 优先检测是否悬停在图例上
- 其次检测是否悬停在扇形上
- 优先检测是否悬停在图例上
- 图例优先:因为图例是交互高频区域,且视觉上在饼图外部,所以优先检测,避免扇形检测干扰
- 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; // 返回图例对应的扇形索引
}
}
- 其次检测是否悬停在扇形上
- 核心逻辑:通过距离检测(鼠标是否在饼图范围内)和角度检测(鼠标在哪个扇形的角度范围内),判断悬停的扇形索引
- 角度计算:以饼图中心为原点,将鼠标位置转换为角度(从 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("下载图表失败,请重试");
}
}
浙公网安备 33010602011771号