ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

小程序 Canvas 手绘五维雷达图:坐标计算、DPR适配与避坑

小程序 Canvas 手绘五维雷达图:坐标计算、DPR适配与避坑 1. 把五维图拆开看本质是五轴雷达图五维图在小程序里其实是个很尴尬的需求说它复杂它无非是五个坐标点连成一个多边形说它简单真动手在小程序 canvas 上画的时候各种坐标偏移、真机模糊、数据刷新重影的问题一个接一个。我最早接这个需求是在一个能力测评类小程序里用户答完题要看到自己在“沟通、执行、协作、学习、创新”五个维度上的得分分布。当时第一反应是找个图表库但翻了一圈发现小程序端成熟的雷达图组件要么包体大要么定制性差最后干脆自己用 canvas 画。这篇文章就围绕“小程序 canvas 绘制五维图”这件事把坐标计算、绘制流程、真机适配和踩过的坑一次讲透。不管你是刚接触小程序 canvas 的新手还是想把手写图表封装成通用组件的老手下面这些内容都能直接拿去改。1.1 五个维度怎么变成坐标点五维图在图形学上就是雷达图的一个特例五个轴从同一个圆心向外辐射相邻轴之间的夹角是 360° ÷ 5 72°。习惯上我们会让第一个轴朝正上方也就是从 -90° 开始这样图形看起来对称、稳定。每个维度有一个最大值比如满分 100用户得分 80那么归一化后的比例就是 0.8。这个比例乘以最大半径 R就得到该维度在对应轴上的落点距离圆心的长度。用数学表达就是对于第 i 个维度i 从 0 到 4角度 θ -90° i × 72°坐标 x cx R × ratio × cos(θ)y cy R × ratio × sin(θ)。其中 cx、cy 是圆心在 canvas 坐标系里的位置ratio value / maxValue。听起来很简单但实际写的时候容易在角度制和弧度制之间来回转错也容易忘记把 canvas 的 y 轴向下这件事考虑进去。我建议一开始就把角度转成弧度统一用 Math.PI 来表示 180°这样后面计算不容易乱。注意小程序 canvas 的坐标系原点在左上角y 轴向下。如果你习惯数学坐标系直觉上“上方”是 y 减小而 canvas 里“上方”是 y 减小没错但正角度旋转方向是顺时针。用 -90° 作为起点正好让第一个轴朝上这个细节第一次画的时候很容易搞反。1.2 为什么不用现成图表库很多人会问 echarts 不是有小程序版吗为什么还要手写我实际对比过几种方案。第一包体。 echarts 小程序版完整引入轻松超过 500KB对于本身就有包体限制的小程序来说为了一个五维图引入整套图表引擎性价比很低。第二定制成本。产品经理往往要求五维图的填充色跟随品牌色、标签要支持两行、点击某个维度要高亮对应的数据点这些用通用库反而要读源码、改配置时间不比手写少。第三canvas 2d 接口已经足够稳定画五维图的核心逻辑不到 200 行代码封装一次可以复用到六维、八维后续维护也完全可控。当然如果你项目里已经有图表库而且不止雷达图一种需求那继续用库没问题。但如果只是单一的五维图我建议手写。手写还有一个隐性好处你对每一根线、每一个文字的位置都有完全控制权遇到设计稿要求“标签不能压住网格线”这种细节时改起来非常快。1.3 五维图适合什么业务场景五维图最常见的落地场景是“多维能力评估”比如员工绩效、学生素质报告、游戏角色属性、商品多维度评分。它的优势在于一眼能看出“偏科”和“均衡”如果某条轴明显短说明这个维度是短板如果五边形接近正五边形说明各项发展均衡。在小程序里它通常出现在测评结果页、个人主页、数据看板中。由于小程序屏幕宽度有限五维图最好控制在 300px × 300px 以内周围留出标签空间所以半径 R 一般取 100px 到 120px 之间。如果数据维度超过六个标签会变得拥挤这时候要么缩小字号要么改成两行要么干脆换一种图表形式。我做过一个汽车 4S 店的积分小程序里面用五维图展示车主在“保养、维修、保险、置换、推荐”五个方面的活跃度。这种场景下用户不需要精确读数只需要直观感受所以网格线可以画得淡一点数据区域填充半透明色视觉效果比精确刻度更重要。这也说明五维图的绘制策略要跟着业务走不能一套参数打天下。2. 小程序 canvas 选型与初始化2.1 新旧 canvas 接口的取舍小程序里的 canvas 有两套接口旧版wx.createCanvasContext和 2d 模式type2d。旧版接口用起来简单但性能一般而且draw方法是异步的连续绘制时容易掉帧。2d 模式返回标准的 Canvas 节点可以直接用getContext(2d)支持createImage、requestAnimationFrame等能力也更接近 Web 标准。我现在的项目一律用 2d 模式除非要兼容非常老的基础库版本。2d 模式的 WXML 写法很简单canvas type2d idradarCanvas stylewidth: 320px; height: 320px; /canvas注意这里id在自定义组件里要保证唯一不要和页面其他 canvas 重名。样式里的宽高是 CSS 像素而 canvas 节点的width、height属性是实际像素两者需要区分开这也是后面 DPR 适配的关键。2.2 获取节点、适配 DPR 与尺寸2d 模式不能直接拿 context必须先通过createSelectorQuery获取节点。基础写法如下const query wx.createSelectorQuery().in(this) query.select(#radarCanvas) .fields({ node: true, size: true }) .exec((res) { const canvas res[0].node const ctx canvas.getContext(2d) const dpr wx.getWindowInfo().pixelRatio const width res[0].width const height res[0].height canvas.width width * dpr canvas.height height * dpr ctx.scale(dpr, dpr) // 后续绘制都用 CSS 像素坐标 drawRadar(ctx, { width, height, data: this.data.scores }) })这段代码里有两个关键点。第一wx.getWindowInfo()是新版接口老版本可以用wx.getSystemInfoSync().pixelRatio但新项目建议用新接口。第二ctx.scale(dpr, dpr)之后你所有的绘制坐标都按 CSS 像素来写不用再手动乘 dpr这样代码更干净。如果不做这一步canvas 在真机上会明显发虚尤其是文字和细线。实操心得有些开发者会把canvas.width设成 CSS 宽度然后奇怪为什么真机模糊。你要记住canvas 是一个位图它的实际像素尺寸决定了清晰度。CSS 宽度只是它在页面上占多大地方。DPR 是 2 或 3 的手机上实际像素要乘 DPR 才能让 1px 的线看起来锐利。2.3 组件化时的生命周期坑如果你想把这个五维图封装成自定义组件要注意 canvas 节点在attached生命周期里可能还拿不到。因为组件刚挂载时节点树还没渲染完createSelectorQuery会返回 null。稳妥的做法是在ready生命周期里获取节点或者用wx.nextTick延迟一下。另外如果组件外部用wx:if控制显示隐藏再显示后 canvas 节点会重新创建之前的 context 和尺寸设置都会丢失必须在显示后重新初始化。我踩过这个坑测评结果页先显示“加载中”数据回来后再显示图表结果 canvas 一直白屏后来发现是wx:if把节点销毁了改成hidden或者把初始化放到数据更新之后才解决。还有一种情况是页面滚动。小程序 canvas 2d 是原生组件默认层级最高但同层渲染在大多数机型上表现已经不错。如果五维图在scroll-view里要留意滚动时是否会闪烁。我的经验是给 canvas 外层包一个固定宽高的容器避免滚动过程中尺寸变化触发重新布局。3. 坐标计算与绘制流程3.1 角度与半径五个顶点的计算公式前面说过五个轴的角度间隔是 72°。把角度转成弧度的工具函数很简单const DEG_TO_RAD Math.PI / 180 function getAngle(index, total 5) { return (-90 index * (360 / total)) * DEG_TO_RAD }有了角度顶点坐标就好算了function getPoint(cx, cy, radius, angle) { return { x: cx radius * Math.cos(angle), y: cy radius * Math.sin(angle) } }这里cx、cy是圆心。如果 canvas 是 320px × 320px圆心一般放在(160, 160)稍微偏下一点因为顶部还要留标签空间。我通常把圆心 y 设为height * 0.55这样上面给标签留出约 45% 的高度下面留 45%视觉上比较平衡。最大半径 R 取Math.min(cx, cy) * 0.7左右保证标签不会超出 canvas。数据归一化也要小心如果某个维度的最大值不是统一的 100而是每个维度有自己的 max那就需要每个轴单独计算 ratio。比如“收入”满分 10000“满意度”满分 5直接混在一起画会严重失真。所以配置里最好支持每个维度独立的 max 值。3.2 网格、轴线与刻度的画法网格通常画 4 到 5 层同心五边形。以 5 层为例每层半径是R * (level / 5)level 从 1 到 5。每层都是一个闭合路径用ctx.beginPath()、moveTo、lineTo、closePath然后stroke。网格线颜色要淡比如rgba(0, 0, 0, 0.1)线宽 1px。轴线是从圆心到最外层五个顶点同样用淡色。刻度线一般不用画因为五维图不是用来精确读数的但如果产品要求可以在每根轴上用小短线标注 20%、40%、60%、80%。绘制顺序很重要先画网格和轴线再画数据区域最后画标签和点。这样数据区域才能盖住网格线视觉层次清晰。如果反过来网格线会压在数据区域上面看起来像划痕。function drawGrid(ctx, cx, cy, maxR, levels 5) { ctx.save() ctx.strokeStyle rgba(0, 0, 0, 0.12) ctx.lineWidth 1 for (let level 1; level levels; level) { const r maxR * (level / levels) ctx.beginPath() for (let i 0; i 5; i) { const angle getAngle(i) const p getPoint(cx, cy, r, angle) if (i 0) ctx.moveTo(p.x, p.y) else ctx.lineTo(p.x, p.y) } ctx.closePath() ctx.stroke() } // 画轴线 for (let i 0; i 5; i) { const angle getAngle(i) const p getPoint(cx, cy, maxR, angle) ctx.beginPath() ctx.moveTo(cx, cy) ctx.lineTo(p.x, p.y) ctx.stroke() } ctx.restore() }注意ctx.save()和ctx.restore()要成对使用尤其是在封装函数里。如果忘了 restore后续绘制的颜色、线宽会被污染出现“莫名其妙变了色”的问题。3.3 数据区域、描边与填充数据区域就是把五个归一化后的点连起来形成一个多边形。填充色用主色的半透明比如rgba(64, 158, 255, 0.3)描边用不透明的同色系线宽 2px。每个顶点再画一个小圆点半径 3px填充白色、描边主色这样数据点更醒目。function drawDataArea(ctx, cx, cy, maxR, values, maxValues) { const points [] for (let i 0; i 5; i) { const ratio Math.min(values[i] / maxValues[i], 1) const angle getAngle(i) const p getPoint(cx, cy, maxR * ratio, angle) points.push(p) } ctx.save() ctx.beginPath() points.forEach((p, i) { if (i 0) ctx.moveTo(p.x, p.y) else ctx.lineTo(p.x, p.y) }) ctx.closePath() ctx.fillStyle rgba(64, 158, 255, 0.28) ctx.fill() ctx.strokeStyle #409eff ctx.lineWidth 2 ctx.stroke() // 画顶点 points.forEach((p) { ctx.beginPath() ctx.arc(p.x, p.y, 3, 0, Math.PI * 2) ctx.fillStyle #ffffff ctx.fill() ctx.strokeStyle #409eff ctx.lineWidth 2 ctx.stroke() }) ctx.restore() }如果有多组数据对比比如“本次得分”和“平均得分”可以画两层数据区域用不同颜色区分。这时候填充透明度要更低避免颜色叠加后看不清。3.4 标签排版让文字不压线标签是五维图里最容易出问题的地方。五个标签分别位于五个轴的外侧但“外侧”的方向不一样顶部标签居中右上标签要左对齐或右对齐右下、左下、左上都要根据角度调整textAlign和textBaseline。我的经验是顶部-90°textAlign centertextBaseline bottomy 坐标再往上偏移 8px。右上-18°textAlign lefttextBaseline middle。右下54°textAlign lefttextBaseline top。左下126°textAlign righttextBaseline top。左上198°textAlign righttextBaseline middle。但这样仍然可能出现文字超出 canvas 边界。解决办法是先ctx.measureText(label)拿到宽度再判断是否超出超出就调整 x 坐标。如果标签太长可以在配置里支持换行比如每行最多 4 个字通过\n拆分后逐行绘制。实操心得标签颜色不要用纯黑用#333或#666更柔和。字号建议 12px 到 14px太小在真机上看不清太大又会挤占绘图区域。如果产品要求显示数值可以把数值放在标签下一行用更小的字号和更浅的颜色。3.5 交互高亮与数据更新五维图不需要复杂的交互但点击某个维度时高亮对应的轴和数据点体验会好很多。实现思路是在 canvas 上绑定touchstart拿到触摸点相对 canvas 的坐标然后计算它到五个顶点的距离取最近的那个如果距离小于 30px 就认为点中了该维度。高亮时重新绘制把被选中的轴加粗、标签变色。canvas.addEventListener(touchstart, (e) { const touch e.touches[0] const x touch.x const y touch.y let minDist Infinity let hitIndex -1 points.forEach((p, i) { const dist Math.hypot(p.x - x, p.y - y) if (dist minDist) { minDist dist hitIndex i } }) if (minDist 30) { this.setData({ activeIndex: hitIndex }) redraw() } })数据更新时不要重新创建 canvas而是用ctx.clearRect(0, 0, width, height)清空后重绘。如果数据变化频繁比如滑动条实时调整分数建议用requestAnimationFrame节流避免每一帧都重绘导致卡顿。4. 代码落地一个可复用的五维图绘制函数4.1 配置参数设计一个好的五维图函数应该把可变部分都抽成配置比如参数类型说明默认值canvasIdStringcanvas 节点 id必填widthNumberCSS 宽度320heightNumberCSS 高度320labelsArray五个维度名称必填valuesArray五个维度当前值必填maxValuesArray五个维度最大值[100,100,100,100,100]levelsNumber网格层数5colorsObject主色、填充色、网格色见下showValueBoolean是否显示数值false这样封装后页面里只要传数据和标签其他都不用管。配置设计的关键是“默认值要合理”让调用方少写代码。比如colors默认给一套蓝色系如果品牌色不同再覆盖。4.2 核心绘制代码拆解完整的绘制函数按顺序做五件事清空画布、计算圆心和半径、画网格和轴线、画数据区域、画标签和数值。下面是一个精简版的骨架function drawRadar(ctx, options) { const { width, height, labels, values, maxValues, levels 5, showValue false } options ctx.clearRect(0, 0, width, height) const cx width / 2 const cy height * 0.55 const maxR Math.min(cx, cy) * 0.72 drawGrid(ctx, cx, cy, maxR, levels) const points drawDataArea(ctx, cx, cy, maxR, values, maxValues) ctx.save() ctx.font 13px sans-serif ctx.fillStyle #333 for (let i 0; i 5; i) { const angle getAngle(i) const labelPoint getPoint(cx, cy, maxR 18, angle) const align getLabelAlign(angle) ctx.textAlign align.textAlign ctx.textBaseline align.textBaseline ctx.fillText(labels[i], labelPoint.x, labelPoint.y) if (showValue) { ctx.font 11px sans-serif ctx.fillStyle #999 ctx.fillText( values[i], labelPoint.x, labelPoint.y (align.textBaseline bottom ? -16 : 16) ) ctx.font 13px sans-serif ctx.fillStyle #333 } } ctx.restore() }这里getLabelAlign根据角度返回对齐方式逻辑和前面说的一样。maxR 18是为了让标签和图形之间留出空隙。4.3 在页面中调用与更新在页面里先获取 canvas 节点并初始化然后调用drawRadar。如果数据来自接口可以在onLoad里请求数据拿到后setData再在setData回调里绘制。注意不要直接在setData之前绘制因为那时候 canvas 可能还没准备好。我一般把初始化封装成initCanvas在里面获取节点、设置 DPR、保存ctx和尺寸之后数据变化只调用redraw。Page({ data: { scores: [80, 65, 90, 70, 85], labels: [沟通, 执行, 协作, 学习, 创新] }, onReady() { this.initCanvas() }, initCanvas() { wx.createSelectorQuery() .select(#radarCanvas) .fields({ node: true, size: true }) .exec((res) { const canvas res[0].node const ctx canvas.getContext(2d) const dpr wx.getWindowInfo().pixelRatio const { width, height } res[0] canvas.width width * dpr canvas.height height * dpr ctx.scale(dpr, dpr) this.canvas canvas this.ctx ctx this.size { width, height } this.redraw() }) }, redraw() { if (!this.ctx) return drawRadar(this.ctx, { width: this.size.width, height: this.size.height, labels: this.data.labels, values: this.data.scores, maxValues: [100, 100, 100, 100, 100] }) } })如果分数会实时变化比如用户拖动滑块可以在滑块change事件里更新scores然后调用redraw。但setData是异步的如果redraw里直接读this.data.scores可能拿到的是旧值。稳妥做法是在setData的回调里调用redraw或者把新值作为参数传给redraw。5. 常见问题排查与避坑清单5.1 图形模糊、偏移、显示不全真机模糊几乎都是 DPR 没处理。检查三件事canvas.width是否等于 CSS 宽度乘 DPR是否调用了ctx.scale(dpr, dpr)绘制坐标是否用的是 CSS 像素。如果这三步都对线还是模糊看看是不是在ctx.scale之后又手动乘了 DPR导致重复放大。显示不全通常是半径太大或标签超出边界。可以打印一下五个标签的measureText宽度看看最左和最右是否超出0到width的范围。如果超出要么减小 maxR要么缩小字号要么把对齐方式改成center并用换行。现象可能原因解决办法整体模糊未适配 DPR设置 canvas.width CSS宽 × DPR并 scale图形偏左/偏上圆心计算错误检查 cx、cy 是否按宽高比例计算标签被截断半径或字号过大减小 maxR或缩短标签文字线条粗细不一未使用 save/restore每段绘制前后成对调用数据区域超出网格未做归一化确保 ratio value / maxValue 且 ≤ 15.2 真机白屏与层级问题白屏最让人头疼。先看控制台有没有报错常见的是res[0]为 null说明选择器没找到节点。检查 id 是否写对、canvas 是否在wx:if隐藏区域、初始化是否在onReady之前执行。如果是自定义组件记得在createSelectorQuery后面加.in(this)。层级问题一般出现在 canvas 和普通组件重叠时。小程序 canvas 2d 支持同层渲染但部分安卓机型上仍然会盖住普通 view。如果必须重叠可以把要显示在上层的元素用cover-view或者干脆避免重叠布局。我在一个项目里把提示文字放在 canvas 上方结果被盖住了后来改成用 canvas 自己绘制文字问题就消失了。5.3 数据刷新后重影、闪烁重影是因为没有清空画布。每次重绘前必须ctx.clearRect(0, 0, width, height)注意这里的 width、height 是 CSS 像素不是 canvas 实际像素。如果用了ctx.scale(dpr, dpr)clearRect 的参数也按 CSS 像素写因为变换已经生效。闪烁通常是因为setData触发页面重新渲染和 canvas 重绘不同步。解决办法是把数据更新和绘制分开先setData更新 UI 上的数字等回调触发后再重绘 canvas。如果数据量很大不要把用于绘制的数据放在data里直接放在页面实例的普通属性上减少setData的负担。5.4 性能与内存五维图的绘制量不大一般不会成为性能瓶颈。但如果你在一个页面里放多个五维图或者数据高频变化就要注意了。每一帧都创建新的路径、设置样式会频繁触发 GPU 上传。优化手段包括复用路径对象、减少save/restore次数、把静态的网格画到离屏 canvas 上缓存。2d 模式支持wx.createOffscreenCanvas可以把网格和轴线画一次之后每次只重绘数据区域和标签。另外页面隐藏时记得取消requestAnimationFrame否则后台还在绘制浪费电量和内存。在onHide里把动画标志置为 falseonShow时再恢复。6. 实操心得与扩展玩法6.1 几个省时间的计算技巧第一个技巧把角度和单位向量预先算好存成常量数组。五维图的角度只有五个没必要每次都Math.cos、Math.sin。初始化时算一次后面直接用const UNIT_VECTORS [ { x: Math.cos(-90 * DEG_TO_RAD), y: Math.sin(-90 * DEG_TO_RAD) }, { x: Math.cos(-18 * DEG_TO_RAD), y: Math.sin(-18 * DEG_TO_RAD) }, { x: Math.cos(54 * DEG_TO_RAD), y: Math.sin(54 * DEG_TO_RAD) }, { x: Math.cos(126 * DEG_TO_RAD), y: Math.sin(126 * DEG_TO_RAD) }, { x: Math.cos(198 * DEG_TO_RAD), y: Math.sin(198 * DEG_TO_RAD) } ]第二个技巧标签对齐不用 if-else 堆直接根据cos(angle)的正负来判断。cos大于 0.1 就左对齐小于 -0.1 就右对齐中间就居中。这样扩展到任意维度都能用。第三个技巧用roundRect或者arc画点的时候半径最好取偶数比如 4px这样在不同 DPR 下渲染更均匀。6.2 从五维扩到多维五维图改成六维、八维核心逻辑不用变只要把角度间隔从 72° 改成360 / n。但维度一多标签就挤了。六维还能勉强放下八维就需要把标签做成交替上下排列或者干脆只显示图标不显示文字。另外数据区域的顶点多了之后可能会有自交这时候建议用ctx.fill(evenodd)或者不填充只描边。我试过八维图在 320px 的 canvas 上标签已经小到 10px 才勉强放得下所以超过七维就不建议用雷达图了换成柱状图更清晰。6.3 导出图片与业务落地五维图经常需要保存分享。2d canvas 可以通过wx.canvasToTempFilePath导出图片注意要把 canvas 节点传进去wx.canvasToTempFilePath({ canvas: this.canvas, success: (res) { wx.saveImageToPhotosAlbum({ filePath: res.tempFilePath }) } })导出的图片默认是 canvas 实际像素尺寸也就是乘过 DPR 的清晰度足够。如果分享图需要加背景色可以在绘制前先ctx.fillRect填充白色或品牌色。落地到业务时我建议把五维图做成一个独立的自定义组件暴露values、labels、colors等属性页面通过properties传入数据组件内部监听变化自动重绘。这样测评结果页、个人主页、数据看板都能复用。另外记得在组件detached时把 canvas 引用置空避免内存泄漏。我自己的习惯是每做一个五维图需求都会先画一张草图标出五个标签的位置和最大半径确认不会超出边界再开始写代码。这样比写完再调省事得多。如果你也在小程序里画五维图不妨先从五个点的坐标计算开始把网格画出来再逐步叠加数据区域和标签一步一步来比一次性写完所有逻辑更容易定位问题。
返回列表