资讯动态

Canvas 圆形绘制全攻略:从 arc() 参数到数据可视化实战

发布时间:2026/10/17 18:18:57 来源:尧图企业网站定制
有人可能会觉得画圆不就是arc()一行代码的事吗等真正动手做数据可视化的时候才发现一个圆里能藏的门道比想象中多得多。饼图怎么让每个扇区精确衔接、圆环的留白怎么算、仪表盘的指针动画为什么在低端机上卡顿、Canvas 在高分屏上为什么总是发虚……这些问题我前前后后都踩过一遍。把实际项目里沉淀下来的经验整理了一下从 Canvas 的坐标系和参数细节开始一路聊到扇区绘制、动画交互、数据适配最后到可视化图表的工程落地希望能帮你少走一次弯路。1. 画圆之前必须吃透 Canvas 的坐标与 API 细节很多人一上来就写ctx.arc(x, y, r, 0, Math.PI * 2)圆是画出来了但根本不清楚每个参数背后的坐标系规则。真到了要画“半个圆”“四分之三圆”“带缺口的环形”时就开始靠猜。1.1 先认识 Canvas 坐标系Canvas 的坐标系和数学课上的直角坐标系最大的区别在于y 轴方向是向下的。原点在画布左上角(0, 0)x 轴向右y 轴向下。这个差异直接影响你对角度正负的判断。在数学坐标系里角度按逆时针方向增大在 Canvas 里由于 y 轴翻转arc()方法的默认角度扫描方向就变成了顺时针。很多人一开始画扇区总觉得方向反了本质上就是因为坐标系这一下翻转。你只要记住Canvas 里角度按默认方向画的时候0 度在正右方三点钟方向然后顺着顺时针走。想要逆时针得给arc()的最后一个参数传true。再说角度单位。圆弧接口用的不是度数而是弧度。一个完整圆是2 * Math.PI弧度。度数转弧度的公式是弧度 角度 * Math.PI / 180我习惯在代码里写一个辅助函数function degToRad(deg) { return deg * Math.PI / 180; }有了这个函数画一个 90 度的扇形就可以写成ctx.arc(x, y, r, degToRad(-90), degToRad(0))语义清晰也避免在复杂图表逻辑里反复做心算。1.2 arc() 六个参数逐个拆解arc()的基本签名是ctx.arc(x, y, radius, startAngle, endAngle, counterclockwise?)逐个说x、y圆心坐标。radius半径。startAngle起始角度弧度制0 对应正右方。endAngle结束角度。counterclockwise?可选的布尔值false或省略表示按顺时针方向从起始角转向结束角true表示逆时针。这里有个非常容易踩的坑起始角和结束角并不代表“永远从 start 直连 end”。比如你想画一个从 90 度到 270 度的半圆传(0, Math.PI)是上半部分但如果你传(Math.PI, 0)且不传逆时针参数则会默认按顺时针从 180 度绕一大圈回到 0 度画出来的反而是“超过半圆”的效果。方向参数和角度顺序一旦组合错图形就会完全不受控制。还要特别注意一个细节arc()只是一个“路径规划”不是“可见图形”。如果你不调用fill()或stroke()屏幕上什么都看不到。另外如果你在调用arc()之前当前路径里已经有了其他子路径它会在起点和圆弧起始点之间自动连一条直线。这条“隐形的线”经常导致图形上出现莫名其妙的多余线条。1.3 别忽略 beginPath() 的隔离作用这是个老生常谈但还是要着重强调每次开始画一个新图形之前务必调用ctx.beginPath()。假定你要画三个半径不同的同心圆代码结构像下面这样ctx.fillStyle #ff0000; ctx.beginPath(); ctx.arc(100, 100, 80, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(100, 100, 50, 0, Math.PI * 2); ctx.fill();如果不加这两个beginPath()第二个圆绘制时路径里还残留着第一个圆的轮廓信息。虽然对于闭合圆来说可能只是边缘重叠的问题但在画折线、多边形、多段弧线组合图形时这种残留会让最终图形的边框重叠变深甚至出现不可控的连线。我再分享一个小习惯每次beginPath()之后顺手把fillStyle、strokeStyle、lineWidth等样式重新设置一遍。因为 Canvas 的样式设置是“有记忆的”上一个图形设置的样式会一直生效。在复杂图表中忘记重置样式导致的颜色错乱是高频 bug。2. 从单个圆到复杂图形圆环、扇形与文本组合基础 API 熟悉之后就可以开始处理真实需求中更常见的复合图形了。圆环进度条、扇形饼图、圆形仪表盘它们看起来长得不一样但底层就是对arc()的几种组合变形。2.1 圆环两个同心圆的减法圆环环形进度条的实现方式有两种一种是先画一个大圆再叠加一个小圆盖住中心另一种是设置lineWidth直接用描边圆弧来画。第一种方式适合做中心有内容的圆环背景因为中心区域可以被小圆遮挡后再填入文本或图片。比如常见的环形百分比图可以用大圆fill()作为底色然后beginPath()画小圆用背景色填充覆盖中心最后在中心绘制百分比文本。但这种方式有一个缺点中心区域覆盖后如果想显示出背景色之外的图案如地图、照片就做不到了。第二种方式更优雅直接利用描边的线宽来构造环。ctx.lineWidth 12; ctx.strokeStyle #4f8ff7; ctx.beginPath(); ctx.arc(150, 150, 100, 0, Math.PI * 1.5); ctx.stroke();这段代码画的就是一个半径 100、线宽 12 的圆弧。这里有个关键点在线宽比较粗的描边中圆弧的内外边缘会同时扩展如果半径是 100线宽是 12那么圆弧最外边缘在 106最内侧在 94。在设计布局时这个“外扩一半线宽”的偏移很容易让人把环的位置估错。2.2 扇形弧线收尾与连线逻辑饼图的基础单位是扇形。画扇形最常用的套路是三步ctx.beginPath(); ctx.moveTo(centerX, centerY); ctx.arc(centerX, centerY, radius, startAngle, endAngle); ctx.closePath(); ctx.fill();第一步moveTo(centerX, centerY)把画笔移到圆心第二步arc()会从圆心自动连一条线到圆弧起点然后沿弧线走到终点第三步closePath()把圆弧终点和圆心连回形成闭合的扇形区域。这里的closePath()值得强调。如果你不调用它fill()其实也会自动闭合图形完成填充因为填充操作会默认从最后一个路径点连线到第一个路径点。但对于描边场景区别就大了不调用closePath()时描边不会把缺口补上扇形的轮廓会缺一条半径方向的线。所以如果你要同时描边和填充一定记得先closePath()再stroke()。如果你画的是“环形扇区”也叫环形图的分段则不能用闭合扇形的方式而是要用两条圆弧拼成一个环段。核心思路是先在外半径上画一段弧再切换方向在内半径上画一段反向弧然后闭合ctx.beginPath(); ctx.arc(cx, cy, outerR, startAngle, endAngle); ctx.arc(cx, cy, innerR, endAngle, startAngle, true); ctx.closePath();第二个arc()传入true表示反向画弧这样就能顺滑地从外圈终点走到内圈终点。这个套路是实现环形占比图的基础务必掌握。2.3 圆内文本居中排版的基本算法把文字放在圆的正中心最简单的方案是结合textAlign和textBaselinectx.textAlign center; ctx.textBaseline middle; ctx.fillText(75%, cx, cy);这样文字会以(cx, cy)为中心点对齐。如果想在圆形仪表盘上放多行文本比如数值在上、标签在下就得手动计算每行的 y 坐标。我给一个可以直接用的经验值每行文字高度按你已经设定好的fontSize来计算行间距设定为fontSize * 1.2两行内容之间的间隔再加fontSize * 0.4左右。比如字体 16px两行内容的 y 坐标分别取cy - 10和cy 12视觉上就比较均衡。具体数值可以微调重点是别盲目用middle一次性解决所有“居中”因为多行文字时它只会把整块内容当作一个整体来对中行与行的间距控制不到。3. 动起来基于 Canvas 的圆形动画与交互技巧静态图形画好之后下一步是让它“动起来”。进度条从 0 生长到 75%仪表盘指针从 0 旋转到目标角度粒子圆从中心扩散。这类动效的底层逻辑都涉及“分段绘制”和“帧循环”。3.1 按进度实时绘制圆弧实现一个从 0 到 75% 的环形进度条最直观的思路是让结束角度随时间变化。可以定义一个进度值progress每次更新后重绘let progress 0; function animate() { progress 0.01; if (progress 0.75) progress 0.75; ctx.clearRect(0, 0, canvas.width, canvas.height); // 背景环 ctx.beginPath(); ctx.lineWidth 12; ctx.strokeStyle #eee; ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke(); // 进度环 ctx.beginPath(); ctx.strokeStyle #4f8ff7; ctx.lineWidth 12; ctx.lineCap round; ctx.arc(cx, cy, r, degToRad(-90), degToRad(-90) Math.PI * 2 * progress); ctx.stroke(); requestAnimationFrame(animate); }这里我特意把起始角设为degToRad(-90)也就是从 12 点钟方向开始画。如果不这样做进度条会从三点钟方向开始走视觉上不符合多数仪表盘的习惯。lineCap round会让圆弧两端变成圆头视觉细节比默认的平头更好。但要留个心眼圆头两端会在线宽半径上各向外延伸一部分如果进度环到达 100%两端圆头会重合多出一点视觉上的“溢出”介意的话需要在收尾阶段做特殊处理或者改用平头。3.2 点击命中圆与圆环的数学判定图形可视化里经常需要知道“用户点了哪个扇区”。Canvas 本身不维护图形对象所以要做数学判定。点击位置(mouseX, mouseY)相对于圆心(cx, cy)的偏移为const dx mouseX - cx; const dy mouseY - cy; const distance Math.sqrt(dx * dx dy * dy); const angle Math.atan2(dy, dx);判定逻辑如下如果distance outerR点在圆外直接排除。如果distance innerR点在环形中心空洞里直接排除。如果innerR distance outerR说明点在环内。然后根据angle的值判断它落在哪个扇区角度区间中。注意atan2返回的角度范围是[-Math.PI, Math.PI]而你的扇区角度范围可能是从-90度开始的两者需要统一换算。我这里提供一个简单的“角度归一化”技巧function normalizeAngle(angle) { while (angle 0) angle Math.PI * 2; while (angle Math.PI * 2) angle - Math.PI * 2; return angle; }统一之后再和扇区区间比较就不会出现“角度在 -30 度和 330 度应该相等但程序判定不等”的尴尬。另外补充一个适合低性能场景的优化方向如果扇区数量多、且每个扇区形状复杂不要每个扇区都实时做数学判定。可以提前把每个扇区的角度区间构建成一个有序数组用二分查找快速确定命中项。对于环形图几十个扇区时性能差距不明显但到了上百个扇区时二分查找的优势就体现出来了。3.3 动画调度与性能边界画圆动效看似简单但性能问题在低端移动设备上会非常明显。核心优化原则有三条第一每隔一帧重绘一次最坏情况只使用一帧需要的requestAnimationFrame这是最可靠的方案。不要用setInterval固定 16msrequestAnimationFrame会跟随屏幕刷新率自动调整还有内置的暂停优化。第二避免重复设置无关的 Canvas 状态。比如每次重绘都调用ctx.save()和ctx.restore()虽然功能正确但频繁保存和恢复状态会带来额外开销。相比之下绘制前显式设置需要改变的属性比盲目保存全局状态高效得多。第三小尺寸离屏 Canvas 是圆形动效的常用优化手段。如果动画涉及非常多的圆比如粒子特效可以预先用一个小尺寸 Canvas 画好一个圆形纹理然后通过drawImage()批量绘制这比每次都为每个圆调用一次arc()路径构建要快得多。同样是画 1000 个圆路径法可能要卡顿而纹理法可以保持流畅。4. 数据可视化实战从原始数据到圆形图表组件工具层面的技巧说完回到可视化本身。一个真正可用的饼图或环形占比图组件绝不只是把数据硬编码成几个角度。现实中的数据可能是接口返回的数字、可能含有百分比小数、可能需要对异常值做处理。下面分享一个相对完整的工程化思考路径。4.1 数据准备与占比换算假设接口返回的数据结构是这样的const rawData [ { label: A产品, value: 120 }, { label: B产品, value: 80 }, { label: C产品, value: 200 }, { label: D产品, value: 50 } ];第一步是计算总和const total rawData.reduce((sum, item) sum item.value, 0);第二步是把每个数值转换成对应的角度区间。注意如果直接拿value / total * 360转成度数再逐个画扇区会出现“由于浮点数精度问题最后一个扇区的终点角度不等于 2π”的情况。这个误差虽然肉眼几乎看不出来但当最后一个扇区恰好非常小时会产生一段不可忽视的“视觉缺口”。更稳健的做法是使用“累计角度”方式不直接依赖每个扇区的独立角度let currentAngle degToRad(-90); // 从12点方向开始 const sectors rawData.map(item { const angle (item.value / total) * Math.PI * 2; const sector { ...item, startAngle: currentAngle, endAngle: currentAngle angle }; currentAngle angle; return sector; });这样最后一个扇区的结束角度一定严格等于degToRad(-90) Math.PI * 2只要把所有扇区的角度累加完圆弧就能完美闭合。4.2 颜色分配与样式策略饼图每个扇区要有不同颜色。颜色分配可以简单用预设色板轮询也可以基于 HSL 做均匀分布。预设色板比较可控适合企业级应用因为品牌色和语义色是固定的。一个实用的色板是 5-8 个颜色左右太少会重复太多会让人难以区分。超过 8 个扇区时考虑把“其他”类目合并成一个灰色扇区是比较合理的处理方案。基于 HSL 均匀分布适合动态类目数不需要维护色板function generateColor(index, total) { const hue (index * 360) / total; return hsl(${hue}, 70%, 55%); }这种方案的优点是色彩跨度均匀、视觉上比较“平衡”缺点是色调可能不够协调。实际项目中我更倾向于把两种方案结合主类目用品牌预置色板扩展类目用 HSL 兜底。再多说一个细节如果扇区之间需要留出分隔线不要试图通过扇区减小角度来实现。应该先绘制所有扇区再用一条较细的描边在每个扇区边界处覆盖。或者更简单直接把整个饼图绘制完后再遍历每个扇区的边界线段用背景色轻轻描一条线。手写时可以把这种“分隔线”封装成一个参数sliceGap角度间隙在计算扇区时把间隙均分到相邻扇区两端。但实现复杂度会上升建议在单页应用里先不做等确实有设计需求再上。4.3 圆环图的完整示例把前面所有技巧整合成一个圆环图中带图例和中心数值的完整组件。这里给一个浓缩的参考实现function drawDonutChart(canvas, data, options {}) { const ctx canvas.getContext(2d); const { cx, cy, outerR, innerR, colors } options; const total data.reduce((s, item) s item.value, 0); ctx.clearRect(0, 0, canvas.width, canvas.height); let currentAngle degToRad(-90); data.forEach((item, index) { const sliceAngle (item.value / total) * Math.PI * 2; ctx.beginPath(); ctx.arc(cx, cy, outerR, currentAngle, currentAngle sliceAngle); ctx.arc(cx, cy, innerR, currentAngle sliceAngle, currentAngle, true); ctx.closePath(); ctx.fillStyle colors[index % colors.length]; ctx.fill(); currentAngle sliceAngle; }); // 中心文字 ctx.textAlign center; ctx.textBaseline middle; ctx.fillStyle #333; ctx.font bold 24px sans-serif; ctx.fillText(${Math.round((data[0].value / total) * 100)}%, cx, cy); }这个示例已经把环形图拆成了可配置的函数比零散写arc()调用要好维护得多。实际项目中还可以把图例绘制、tooltip、动画过渡都加进来形成完整的图表组件。5. 常见问题排查锯齿、缩放与坐标偏移写 Canvas 圆形图踩过的坑很多都有固定解法。整理成速查表可以直接收藏现象可能原因解决方式画出的圆边缘发虚、有明显锯齿Canvas 尺寸与 CSS 显示尺寸不一致尤其是在高分屏上用window.devicePixelRatio对 Canvas 做物理像素缩放0 度开始的进度条位置不对默认 0 度在正三点钟方向起始角减Math.PI / 2改从 12 点方向画多个扇区之间出现缝隙或重叠浮点数角度累加误差用累计角度方式传参不要独立计算每个扇区圆环内部中空区域被背景色填不上覆盖中心区域时把外部圆环也覆盖了优先用描边方案画环或者用路径裁剪文字位置总偏一点没有设置textAlign和textBaseline统一设成center与middle再绘制动画在低端手机上卡顿每帧重建大量路径使用离屏 Canvas 缓存圆纹理或减少每帧重绘的圆数量点击圆环命中区域不准没有判断distance是否在内外半径之间用距离公式先过滤再判断角度区间弧形边缘有细线相邻扇区共用同一条边描边叠加了两次统一描边后再统一填充或调低描边透明度5.1 高分屏适配是首要排查对象提到锯齿就必须单独聊高分屏适配。很多开发者先把 Canvas 宽度设成 CSS 宽度然后发现文字和圆边缘都像蒙了一层纱。这是因为 Canvas 的位图分辨率等于 CSS 像素而设备物理像素是它的 2 倍或 3 倍浏览器只能拉伸导致模糊。标准适配方法是const dpr window.devicePixelRatio || 1; const rect canvas.getBoundingClientRect(); canvas.width rect.width * dpr; canvas.height rect.height * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0);注意setTransform这行设置很关键它让后续的绘图坐标仍然按照 CSS 像素逻辑来使用。否则你画一个半径为 100 的圆在 2 倍屏上实际看起来只有原始大小的一半。最好在每次resize或初始化时都做一遍这个处理并配合canvas.style.width rect.width px之类的样式设置。5.2 弧度方向的统一管理另一个隐藏的坑是Canvas 的arc()默认顺时针方向但很多可视化库里angle的正方向是逆时针数学坐标系。如果在同一个项目中混合使用两种直觉很容易在对接外部数据或工具库的时候写反。我建议的方法是在项目内部约定一个统一的“角度工具函数层”例如始终以“弧度 0 度正右方 顺时针为正”为准并提供以下两类封装degToRad(deg)度数转弧度normalizeAngle(rad)角度归一化到[0, 2π)区间封装之后业务逻辑里不要直接出现未经处理的Math.atan2返回值也不要直接用Math.PI魔法数字满天飞。统一之后排查角度问题时思路会清晰很多。6. 经验与扩展圆画好了还能往哪些方向走如果把这一套圆形绘制技术吃透能做的就不只是饼图和环图了还有很多有意思的扩展方向。但每次扩展之前都要先回到“数据 布局 交互”这个基本框架上审视一遍想清楚你画的这个圆到底承担的是什么信息角色。6.1 圆形粒子与星座图圆形是粒子系统里最省性能的图形元素因为它天然具备几何对称性。模拟一个圆的渐变扩散动画时可以用多个不同半径、不同透明度的圆叠加形成“光晕”效果。这在实现加载动画、地图标记、气泡图表时非常实用。之前我做过一个地图数据点位展示每个点位都是一个动态半径的圆。本来想用 Canvas 每帧重绘所有点后来发现点位数量一旦上千路径构建开销很大。改成方案是预先把一个带渐变的光晕圆绘制在一个 64x64 的离屏 Canvas 上每帧只用drawImage()把纹理平移到对应坐标。处理速度立刻提升了一个数量级。圆形绘制的性能优化思路借助离屏纹理是一个关键突破口。6.2 仪表盘与环形进度条的进一步封装如果还想更进一步可以把前面讲的圆环图扩展成仪表盘背景是一个大圆弧数值是一个按进度增长的圆弧额外加一条指针线。指针的旋转可以通过ctx.translate(cx, cy)和ctx.rotate(angle)来实现省去手工计算指针端点坐标的麻烦。封装的时候建议把所有可配置项统一收敛到一个config对象里例如起始角、结束角、方向、线宽、颜色、动画时长、动画缓动函数。这样在重复使用仪表盘时只需要维护配置文件不需要改动绘制逻辑。这也是组件化思维在 Canvas 图形开发中的体现。6.3 工具链与调试建议开发 Canvas 圆形功能时有一些顺手的小工具能减少纠错成本。比如想快速观察角度位置可以在圆心和圆弧终点之间画一条临时直线或者把圆弧的起点和终点用小的填充方块标出来。这种“可视化调试辅助线”虽然不会上线但在开发中能省去大量心算时间。再比如用ctx.getImageData检查像素颜色来验证某个扇区是否覆盖正确区域虽然听起来专业但实际用起来很慢。我一般更倾向于在开发阶段把每个扇区的关键信息打印成表格包括起始角、结束角、颜色、占比检查数据层是否合理再切换到视觉层看渲染。分层排查比在黑盒里瞎猜要高效得多。还有一点想提醒的是如果要实现复杂的交互动效比如 hover 高亮、选中扇形偏移分离Canvas 的事件命中检测是一门独立技术。基础方案是“数学判定 重绘”进阶方案可以参考“离屏 Canvas 逐像素命中”的思路但那种方案内存开销大只有在图形复杂到无法用数学描述时才会考虑。多数业务场景下数学判定就足够用了。最后分享一个我在实操中的体会画圆这件事看似是基本功一旦和数据结合每个细节都会暴露设计上的仓促。不要迷信“一个arc()走天下”的简化答案把坐标系、方向、线宽、角度归一再理解透往后做任何圆形可视化都能少走弯路。这个方向后续还能延伸出极坐标图、雷达图、螺旋图等万变不离其宗核心还是把“圆”和“数据”的映射关系想清楚。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑