1. 五维图到底在画什么:先想清楚再动手
小程序里做五维图,第一次接这个需求的时候我以为是半小时的活,结果在一个像素比上磨了一下午。所谓五维图,本质就是雷达图的一个特例——把五个维度的数值映射到五条从中心射出的轴线上,再把五个数据点连成一个多边形。它跟六维、八维雷达图在数学上没有任何区别,只是维度恰好是五个,形状是一个正五边形。小程序 canvas 五维图这个组合之所以常见,是因为小程序端的测评类、能力模型类、门店对比类页面实在太多,而这类页面最喜欢用五维图来一次讲清楚"这个人在五个方面各是什么水平"。
1.1 五个维度的业务含义与数据形态
落到具体业务里,五维图的输入通常长这样:一个长度为 5 的维度名称数组,加一个长度为 5 的数值数组。维度名称一般是短语,比如"专业能力""沟通协作""执行落地""学习成长""创新意识",控制在 2 到 6 个字最好,太长了标签会挤到画布外面。数值这块就是坑最多的地方:有的业务给的是 5 分制评分,有的是 100 分制打分,有的是 0 到 1 的百分比,还有的是原始累计值(比如"累计答题 138 道")。这些量纲混在一起直接画,结果就是某个维度顶到最外圈、其他四个挤在中心糊成一团。
我的处理原则是:绘制层只认归一化之后的 0 到 1,所有量纲转换放在数据准备阶段完成。如果五个维度都是同一套评分体系,那就统一除以满分;如果不同维度量纲差异明显,那就各自按该维度的合理上限做归一化,同时在页面上用文字说明"各维度已按各自满分换算",避免用户误读。这一步不做,后面画得再漂亮都是错图。
1.2 为什么不用现成图表库
小程序生态里能画雷达图的库不少,ec-canvas 那套用得最多,功能也确实全。但我最后选择用原生 canvas 2d 手写,理由有三个。第一是包体:主包 2 MB 的限制摆在那里,图表库加上依赖动辄几百 KB 到 1 MB 出头,而我这张图只需要五边形、五条轴、一层渐变填充和一串标签,手写大概两百行代码就够,压缩后几乎不占体积。第二是交互定制:需求里要点击某一维高亮出数值气泡、要支持两组数据叠加对比、要做入场生长动画,用库去改这些反而要啃它的源码。第三是可控性:字体、颜色、留白、动画曲线全在自己手里,设计师改一版稿我改几个常量就行。
代价当然也有,数学得自己算,像素比得自己处理,真机差异得自己踩。这篇文章接下来讲的就是这些"自己"的部分。
1.3 几何模型:五边形其实就是一个极坐标函数
把五维图拆开看,它只有一种基本运算:已知圆心、半径、角度,求圆周上的点。五个顶点就是把圆周五等分,每个间隔 72 度。为了让最上面的顶点正对屏幕上方,起始角度取负 90 度,也就是-Math.PI / 2。写成函数就是一个极坐标转直角坐标的映射:
function polar(cx, cy, r, index, total) { const angle = -Math.PI / 2 + (Math.PI * 2 * index) / total; return { x: cx + r * Math.cos(angle), y: cy + r * Math.sin(angle), angle }; }这四个参数里,cx cy是圆心,r是半径,index是第几个维度,total是维度总数。注意这里必须用total而不是写死 5,因为之后如果你要扩展成六维、八维,改一个配置就行,绘制逻辑一行都不用动。网格的每一圈、轴线端点、数据点、标签位置,全部复用这一个函数,这也是我坚持把五维图先抽象成"任意维雷达图"的原因——多做一步抽象,后面少改三次代码。
2. 画布初始化:拿节点、算像素比、躲尺寸坑
小程序里画 canvas,最容易被低估的就是初始化这一步。我见过太多人写完绘制逻辑,真机一看图形是糊的、或者干脆一片空白,最后发现全是初始化的锅。
2.1 新版 Canvas 2D 取节点的正确姿势
小程序有两种 canvas 接口:旧的wx.createCanvasContext和新的 Canvas 2D。新旧差异很大,旧接口的draw()是异步提交绘制指令,动画做起来很别扭;新接口的getContext('2d')拿到的就是一个标准 2D 上下文,requestAnimationFrame、createRadialGradient、measureText全都能用。所以 WXML 里第一件事是加type="2d":
<canvas type="2d" id="radar" class="radar" bindtouchstart="onTouchStart"></canvas>取节点必须用选择器查询,而且必须在节点渲染完成之后。放到onReady里是最稳的时机,页面onLoad时节点还不存在:
wx.createSelectorQuery() .select('#radar') .fields({ node: true, size: true }) .exec((res) => { const info = res && res[0]; if (!info || !info.node) { console.warn('canvas 节点还没就绪'); return; } // info.node 是 canvas 实例,info.width / info.height 是它的 CSS 尺寸 });这里的fields({ node: true, size: true })是关键,只写node: true拿不到尺寸,只写size: true拿不到实例。另外如果你把 canvas 封装在自定义组件里,查询必须加.in(this)限定组件范围,否则会查到别的页面节点上去。
注意:如果 canvas 所在容器用了
wx:if控制显隐,切换回来之后必须重新走一遍查询流程,旧的 canvas 实例尺寸会变成 0,直接在上面绘制就是白屏。
2.2 devicePixelRatio 与 ctx.scale 的配合
这是糊图问题的唯一元凶。canvas 有两个尺寸概念:一个是它在页面上占的 CSS 尺寸(比如 350 × 280 逻辑像素),另一个是它的位图尺寸(物理像素)。如果你不设置位图尺寸,canvas 默认是 300 × 150,然后被浏览器拉伸到 350 × 280 显示,结果就是模糊。正确做法是让位图尺寸等于 CSS 尺寸乘以设备像素比:
const dpr = (wx.getWindowInfo ? wx.getWindowInfo().pixelRatio : wx.getSystemInfoSync().pixelRatio) || 1; canvas.width = Math.floor(info.width * dpr); canvas.height = Math.floor(info.height * dpr); const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr);举个数:CSS 宽度 350,dpr 取 3(不少安卓旗舰是 3 甚至更高),位图宽度就是 1050。设置完之后画布实际有 1050 个物理像素可用,清晰度拉满。
关键在于后面那句ctx.scale(dpr, dpr)。它把坐标系整体放大 dpr 倍,于是你后续所有绘制代码都可以用逻辑像素来写,不用到处乘 dpr。比如你画半径 100 的圆,实际占 300 个物理像素,正好填满 350 逻辑宽度的一半多点。如果忘了这一步,画出来的图形会缩到右下角四分之一区域,看着像是被裁掉了。反过来说,如果你实在不想用 scale,那就得把每个坐标手乘 dpr,代码会变得非常难维护,我不推荐。
2.3 布局留白:给标签留出呼吸空间
五维图跟普通条形图不一样,标签是围着圆心分布的,上下左右都得留空间,尤其是"沟通协作"这类四字标签,宽度能有 50 逻辑像素。如果半径直接按min(width, height) / 2算,标签必然被裁掉一部分。
我的做法是先测量所有标签的宽度,取最大值再加一点余量作为 padding:
computePadding(ctx, dims) { const size = this.options.labelSize; ctx.font = `${size}px sans-serif`; let max = 0; dims.forEach((d) => { const w = ctx.measureText(d).width; if (w > max) max = w; }); return Math.max(max + this.options.labelGap, 36); }measureText是同步返回的,成本可以忽略,而且它返回的是逻辑像素宽度,跟ctx.scale不冲突,可以直接拿来参与计算。最终的绘制半径就是:
const radius = Math.min(cssWidth, cssHeight) / 2 - padding;这样不管标签是两个字还是六个字,图都不会被挤出画布。容器高度建议给到 560rpx 以上,太扁的容器里五维图会被压成椭圆感,视觉上很难看。
3. 分四层把五维图"搭"出来
绘制顺序直接决定成品观感。我的习惯是固定四层:网格层、数据层、标签层、高亮层。每层一个独立函数,好处是调试时可以单独注释掉某一层看效果,也方便后面加主题换肤。
3.1 底层:五边形网格与五条轴线的参数化
网格是 5 层同心五边形,最里面那层的半径是radius / levels,最外层正好等于radius。每一层的顶点都用同一个polar函数算,闭合路径用closePath收尾。这里有个细节:beginPath一定要写在每一层循环里面,如果写在循环外面,五次描边会连成一条乱七八糟的折线,这是新手最容易犯的路径污染错误。
for (let l = 1; l <= levels; l++) { const r = (radius * l) / levels; ctx.beginPath(); for (let i = 0; i < total; i++) { const p = polar(cx, cy, r, i, total); if (i === 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y); } ctx.closePath(); ctx.strokeStyle = ringColor; ctx.stroke(); }轴线我习惯一次beginPath把所有 5 条线画完再统一描边,因为它们的颜色和线宽完全一致,合并成一次绘制调用在渲染开销上更划算。moveTo(cx, cy)是每条线的起点,lineTo到顶点,五条线的 moveTo 中间不需要 beginPath。
网格颜色选浅灰蓝(比如#E6EBF2),比纯灰更有质感,跟浅色背景更融洽。线宽统一 1 个逻辑像素就够了,setLineDash在小程序 Canvas 2D 上可以画虚线,但不同机型表现略有差异,如果求稳,用浅色实线代替虚线是更保险的选择。
3.2 数据层:归一化、渐变填充与顶点圆点
数据层是整张图的主角。先把数值统一映射到 0 到 1,再乘半径。这里必须做上下限钳制,因为真实业务数据出现负数或者超出满分的情况一点都不罕见:
const points = values.map((v, i) => { let ratio = v / max; if (ratio < 0) ratio = 0; if (ratio > 1) ratio = 1; return polar(cx, cy, radius * ratio * progress, i, total); });progress是给入场动画用的插值系数,静态绘制时传 1。
填充用径向渐变而不是纯色,视觉层次立刻就不一样了。小程序 Canvas 2D 支持createRadialGradient,从圆心到最外圈做一个由浓到淡的过渡:
const grad = ctx.createRadialGradient(cx, cy, radius * 0.08, cx, cy, radius); grad.addColorStop(0, 'rgba(58,122,255,0.45)'); grad.addColorStop(1, 'rgba(58,122,255,0.06)'); ctx.fillStyle = grad; ctx.fill();注意渐变的两个圆都得以圆心为基准,如果内圆圆心写偏了,填充会出现奇怪的方向感。描边用饱和度高一点的主题色,线宽 2,再把五个顶点画成白心蓝边的小圆点,视觉焦点就出来了。圆点半径建议 3.5 到 4 逻辑像素,太小了看不见,太大了抢戏。
实操心得:
ctx.fill()和ctx.stroke()一定要在路径构造完成之后依次调用,不要在两个顶点之间穿插fill,否则会出现多边形内部的三角填充痕迹。
3.3 文本层:标签对齐要靠三角函数判断
标签对齐是五维图最考验细节的地方。五个标签分布在五个方向:正上、右上、右下、左下、左上。对齐方式不能用固定值,要根据顶点相对圆心的位置动态判断:
const cos = Math.cos(p.angle); const sin = Math.sin(p.angle); if (Math.abs(cos) < 0.2) ctx.textAlign = 'center'; else ctx.textAlign = cos > 0 ? 'left' : 'right'; if (Math.abs(sin) < 0.2) ctx.textBaseline = 'middle'; else ctx.textBaseline = sin > 0 ? 'top' : 'bottom';为什么这么写?因为 canvas 的 y 轴向下为正。正上方那个顶点角度是 -90 度,sin等于 -1,所以textBaseline取bottom,意思是"文字的底边贴在锚点上方",文字自然出现在顶点正上方。正下方角度是 90 度,sin等于 1,取top,文字出现在顶点下方。左右两侧的textAlign逻辑同理。
设置ctx.font有个坑:小程序里必须写完整的 font 简写,`${size}px sans-serif`,只设ctx.fontSize是无效的。字体族如果写PingFang SC,安卓端没有这个字体,会静默回退,所以直接写sans-serif让系统自己选,反而更统一。字号 11 到 13 之间比较合适,再小在真机上看不清。
3.4 动画层:用 canvas.requestAnimationFrame 做入场
入场动画是所有测评类页面的标配,直接把progress从 0 补间到 1 就行。缓动函数我用三次方的 ease-out,前段快后段收,有种"数据长出来"的手感:
runEnterAnimation() { const canvas = this.canvas; const duration = this.options.duration; let startTs = 0; const tick = (ts) => { if (!startTs) startTs = ts; const raw = Math.min(1, (ts - startTs) / duration); const eased = 1 - Math.pow(1 - raw, 3); this.render(eased); if (raw < 1) canvas.requestAnimationFrame(tick); }; canvas.requestAnimationFrame(tick); }这里有两个要点。第一,用canvas.requestAnimationFrame而不是全局的requestAnimationFrame,把帧调度绑定到 canvas 实例上,页面切走时更容易随组件一起停掉。第二,时间基准用回调传入的ts而不是Date.now(),因为首帧时间戳和当前时间通常不等,用Date.now()会导致第一帧就跳一大段。动画结束后不需要额外清理,raw到 1 自然停止,不会一直空转耗电。
整个动画时长控制在 600 到 700 毫秒,再长用户会觉得卡,再短就看不出生长的感觉了。缓动曲线如果想要更有弹性,可以换成1 - Math.pow(1 - t, 4),但不要用弹跳(back)曲线,五维图这种正经数据图弹来弹去显得不专业。
4. 交互扩展:点击高亮、多组对比、导出分享图
基础图形画完之后,产品一般还会提三件事:点一下要看详情、要能对比两组数据、要能存成图片分享出去。这三件事都不难,但都有各自的细节。
4.1 手指落在哪一维:角度最近邻命中检测
命中检测的思路很朴素:算出手指点相对圆心的距离和角度,距离超出半径就不响应,否则用角度除以 72 度再四舍五入,得到最近的维度索引。
const dx = x - centerX; const dy = y - centerY; const dist = Math.sqrt(dx * dx + dy * dy); if (dist > radius + 20) { this.setData({ activeIndex: -1 }); return; } let angle = Math.atan2(dy, dx) + Math.PI / 2; if (angle < 0) angle += Math.PI * 2; const step = (Math.PI * 2) / total; const idx = Math.round(angle / step) % total;Math.atan2返回的是 -π 到 π 的弧度,加Math.PI / 2是为了把零点从"正右方"挪到"正上方",跟绘制时的起始角度对齐,这段偏移如果不加,点正上方的维度会命中相邻的维度。最后的取模是为了处理index溢出到 5 的情况。
触摸坐标要用e.touches[0].x,这个坐标已经是相对 canvas 左上角的逻辑像素,可以直接参与计算。但我在个别机型上遇到过x为 undefined 的情况,兜底可以先拿e.touches[0].pageX减去 canvas 的boundingClientRect.left:
const rect = await this.getCanvasRect(); const x = touch.x !== undefined ? touch.x : touch.pageX - rect.left; const y = touch.y !== undefined ? touch.y : touch.pageY - rect.top;getCanvasRect用wx.createSelectorQuery().select('#radar').boundingClientRect()拿,结果可以缓存在this上,不用每次触摸都查一遍。
还有一个必须记住的点:setData只更新了数据,不会自动重绘 canvas。必须在setData之后手动调一次render,否则你点半天页面毫无反应。这是我当年排查了半小时的"灵异事件"。
4.2 两组数据对比怎么画才不糊
需求从单人测评变成"我和团队平均"对比的时候,直接叠两层半透明填充是行不通的,因为交叠区域颜色会变浑浊,完全看不出谁是谁。我的做法是:两组都用描边为主、极淡填充为辅,第一组蓝色(#3A7AFF),第二组橙色(#FF8A3D),填充透明度压到 0.12 以下,只保留描边和顶点圆点的辨识度。
如果两组对比是主需求,还可以进一步简化:完全去掉填充,只留两条带圆点的折线,视觉上更干净。另外在图下方加一行图例,用小色块 + 文字说明哪条是谁,别指望用户能从颜色猜出来。
多组绘制的时候要注意绘制顺序:先画填充面积大的那组,再画小的那组,这样小的那组描边不会被盖住。如果两组面积差不多,那就按固定顺序画,保证每次渲染结果一致,不会出现动画过程中两条线跳动的情况。
4.3 导出图片与分享卡片
导出图片用wx.canvasToTempFilePath,Canvas 2D 模式下要传 canvas 实例而不是旧的 context:
wx.canvasToTempFilePath({ canvas: this.canvas, x: 0, y: 0, width: this.cssWidth, height: this.cssHeight, destWidth: this.cssWidth * dpr, destHeight: this.cssHeight * dpr, success: (res) => { wx.previewImage({ urls: [res.tempFilePath] }); }, fail: (err) => { console.error('导出失败', err); } });这里的destWidth和destHeight要乘 dpr,否则导出图会比你屏幕上看到的小一圈,分享出去糊成一片。另外导出之前要确保动画已经跑完,如果用户在动画进行中点了分享,导出的图就是一个残缺的多边形,处理办法是导出前先render(1)强制画一帧完整状态。
如果是要做成分享卡片,建议单独准备一个更大的离屏画布(比如 750 × 1000 CSS 像素),把五维图居中画上去,再加上标题、分数、二维码等元素。用同一套polar绘制函数就能复用,只需要把圆心和半径参数换掉,这也是前面抽象维度参数带来的好处。
顺带提一句,切换维度或者切换对比对象时,可以用wx.setNavigationBarTitle同步更新导航栏标题,比如从"个人能力画像"变成"团队能力对比",用户感知会清晰很多,这个调用成本极低但体验提升明显。
5. 常见问题与排查技巧实录
下面这些坑我基本上一个不落地踩过一遍,整理成速查表,遇到问题先对号入座。
5.1 模糊、白屏、拉伸:三个最高频故障
| 现象 | 根因 | 处理方式 |
|---|---|---|
| 图形发虚、边缘有毛边 | 没处理 dpr,位图尺寸等于或小于 CSS 尺寸 | 设置canvas.width = cssW * dpr,并执行ctx.scale(dpr, dpr) |
| 完全白屏,什么也没有 | 查询节点时机太早,或 canvas 在wx:if里刚显示 | 移到onReady,wx:if切换后用nextTick重新查询 |
| 图形被拉伸变形 | 位图宽高比例与 CSS 比例不一致 | 两个方向都乘同一个 dpr,不要只乘宽度 |
| 图形只占右下角一小块 | 设置了位图尺寸但漏了ctx.scale | 补上 scale,或手动给所有坐标乘 dpr |
| 每次重绘都有残影叠加 | 没有清空画布 | 每帧开头ctx.clearRect(0, 0, cssW, cssH) |
| 标签被切掉一半 | padding 不够 | 用measureText动态计算 padding |
| 导出图比屏幕小 | destWidth没乘 dpr | 导出参数补乘 dpr |
重点说说残影这一条。因为它跟别的图不一样,五维图的填充区域是半透明的,叠加两层你未必看得出来,叠加四层之后颜色就明显变深了,很多人会误以为是"渐变参数写错了"。判断方法很简单:故意把填充色改成完全不透明的纯红,如果红色越来越深,那就是没清画布。
5.2 真机差异与兼容细节
开发工具上跑得好好的,真机上出问题的场景主要有三类。
第一类是坐标差异。开发工具的 canvas 尺寸是真机渲染尺寸的模拟值,如果你在代码里对宽度做了某些硬编码假设(比如默认就是 375 宽),到了 414 或 360 宽的机型上,圆心位置就偏了。所以圆心必须是cssWidth / 2动态算出来的,不能写死。
第二类是触摸坐标缺失。前面提过,个别机型touch.x可能拿不到,一定要写 pageX 兜底的逻辑。另外如果 canvas 外层套了scroll-view,滑动和点击会打架,建议 canvas 上加catchtouchstart阻止冒泡,或者干脆把画布放在不滚动的固定区域。
第三类是层级问题。Canvas 2D 在较新的基础库上支持同层渲染,普通的view可以覆盖在它上面。但如果你要覆盖的是cover-view,或者项目基础库版本较低,就可能出现覆盖元素被 canvas 挡住的情况。最保险的方案是把数值气泡直接用 canvas 画出来(fillRect加圆角加文字),完全不依赖原生层级,我这几年都这么干,从没出过岔子。
5.3 性能与内存的那些小事
五维图本身元素很少,正常情况下不会成为性能瓶颈,但有两种写法会把它拖垮。
第一种是每个顶点圆点都beginPath完立刻fill一次,同时每次填充都新建一个渐变对象。实测下来,把渐变对象在动画循环外创建一次并缓存起来复用,能省掉不少开销。因为渐变的坐标是固定的,只有progress在变,完全没有必要每帧重建。
第二种是在动画循环里调setData。setData涉及逻辑层到视图层的通信,一秒钟调六十次会让整个页面明显卡顿。我的原则是:canvas 的绘制完全不走setData,只有需要改变页面其他元素(比如选中维度的文字详情)时才调一次。这样即使动画播放过程中,页面也不会有任何通信压力。
内存方面,页面卸载时记得把canvas引用、ctx引用、缓存的 rect 都置为 null。我遇到过在长列表页面里反复进出一百多次后内存持续上涨的情况,最后定位到就是组件里持有的 canvas 引用没释放。加上onUnload里的清理逻辑之后曲线就平了。
6. 一套可复用的封装与参数速查
把上面的东西串起来,我做成了一个可直接跑的页面,把可变的都抽成配置项,换业务只需要改配置和数值。
6.1 完整可运行代码
WXML:
<canvas type="2d" id="radar" class="radar" bindtouchstart="onTouchStart" ></canvas>WXSS:
.radar { display: block; width: 100%; height: 560rpx; }JS:
const DEFAULT_OPTIONS = { dimensions: ['专业能力', '沟通协作', '执行落地', '学习成长', '创新意识'], max: 100, levels: 5, labelSize: 12, labelGap: 16, labelColor: '#5A6478', ringColor: '#E6EBF2', axisColor: '#E6EBF2', strokeColor: '#3A7AFF', fillTop: 'rgba(58,122,255,0.45)', fillBottom: 'rgba(58,122,255,0.06)', pointFill: '#FFFFFF', duration: 640 }; function polar(cx, cy, r, index, total) { const angle = -Math.PI / 2 + (Math.PI * 2 * index) / total; return { x: cx + r * Math.cos(angle), y: cy + r * Math.sin(angle), angle }; } Page({ data: { activeIndex: -1 }, onReady() { this.options = Object.assign({}, DEFAULT_OPTIONS); this.values = [92, 78, 85, 70, 88]; this.queryCanvas(); }, onUnload() { this.canvas = null; this.ctx = null; }, queryCanvas() { wx.createSelectorQuery() .select('#radar') .fields({ node: true, size: true }) .exec((res) => { const info = res && res[0]; if (!info || !info.node) return; const canvas = info.node; const dpr = (wx.getWindowInfo ? wx.getWindowInfo().pixelRatio : wx.getSystemInfoSync().pixelRatio) || 1; canvas.width = Math.floor(info.width * dpr); canvas.height = Math.floor(info.height * dpr); const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr); this.canvas = canvas; this.ctx = ctx; this.dpr = dpr; this.cssWidth = info.width; this.cssHeight = info.height; this.padding = this.computePadding(ctx); this.runEnterAnimation(); }); }, computePadding(ctx) { const opt = this.options; ctx.font = `${opt.labelSize}px sans-serif`; let max = 0; opt.dimensions.forEach((d) => { const w = ctx.measureText(d).width; if (w > max) max = w; }); return Math.max(max + opt.labelGap, 36); }, runEnterAnimation() { const canvas = this.canvas; const duration = this.options.duration; let startTs = 0; const tick = (ts) => { if (!startTs) startTs = ts; const raw = Math.min(1, (ts - startTs) / duration); const eased = 1 - Math.pow(1 - raw, 3); this.render(eased); if (raw < 1) canvas.requestAnimationFrame(tick); }; canvas.requestAnimationFrame(tick); }, render(progress) { const ctx = this.ctx; const opt = this.options; const total = opt.dimensions.length; const w = this.cssWidth; const h = this.cssHeight; ctx.clearRect(0, 0, w, h); const cx = w / 2; const cy = h / 2; const radius = Math.min(w, h) / 2 - this.padding; this.center = { cx, cy }; this.radius = radius; this.drawGrid(ctx, cx, cy, radius, total); this.drawData(ctx, cx, cy, radius, total, progress); this.drawLabels(ctx, cx, cy, radius, total); this.drawHighlight(ctx, cx, cy, radius, total); }, drawGrid(ctx, cx, cy, radius, total) { const opt = this.options; ctx.save(); ctx.lineWidth = 1; for (let l = 1; l <= opt.levels; l++) { const r = (radius * l) / opt.levels; ctx.beginPath(); for (let i = 0; i < total; i++) { const p = polar(cx, cy, r, i, total); if (i === 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y); } ctx.closePath(); ctx.strokeStyle = opt.ringColor; ctx.stroke(); } ctx.beginPath(); for (let i = 0; i < total; i++) { const p = polar(cx, cy, radius, i, total); ctx.moveTo(cx, cy); ctx.lineTo(p.x, p.y); } ctx.strokeStyle = opt.axisColor; ctx.stroke(); ctx.restore(); }, drawData(ctx, cx, cy, radius, total, progress) { const opt = this.options; const points = this.values.map((v, i) => { let ratio = v / opt.max; if (ratio < 0) ratio = 0; if (ratio > 1) ratio = 1; return polar(cx, cy, radius * ratio * progress, i, total); }); if (!this.gradient) { const grad = ctx.createRadialGradient(cx, cy, radius * 0.08, cx, cy, radius); grad.addColorStop(0, opt.fillTop); grad.addColorStop(1, opt.fillBottom); this.gradient = grad; } 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 = this.gradient; ctx.fill(); ctx.lineWidth = 2; ctx.strokeStyle = opt.strokeColor; ctx.stroke(); ctx.lineWidth = 2; points.forEach((p) => { ctx.beginPath(); ctx.arc(p.x, p.y, 3.5, 0, Math.PI * 2); ctx.fillStyle = opt.pointFill; ctx.fill(); ctx.strokeStyle = opt.strokeColor; ctx.stroke(); }); ctx.restore(); }, drawLabels(ctx, cx, cy, radius, total) { const opt = this.options; ctx.save(); ctx.font = `${opt.labelSize}px sans-serif`; ctx.fillStyle = opt.labelColor; for (let i = 0; i < total; i++) { const p = polar(cx, cy, radius + opt.labelGap, i, total); const cos = Math.cos(p.angle); const sin = Math.sin(p.angle); ctx.textAlign = Math.abs(cos) < 0.2 ? 'center' : cos > 0 ? 'left' : 'right'; ctx.textBaseline = Math.abs(sin) < 0.2 ? 'middle' : sin > 0 ? 'top' : 'bottom'; ctx.fillText(opt.dimensions[i], p.x, p.y); } ctx.restore(); }, drawHighlight(ctx, cx, cy, radius, total) { const idx = this.data.activeIndex; if (idx < 0) return; const opt = this.options; const end = polar(cx, cy, radius, idx, total); const value = this.values[idx]; let ratio = value / opt.max; if (ratio < 0) ratio = 0; if (ratio > 1) ratio = 1; const dot = polar(cx, cy, radius * ratio, idx, total); ctx.save(); ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(end.x, end.y); ctx.lineWidth = 2; ctx.strokeStyle = opt.strokeColor; ctx.stroke(); ctx.beginPath(); ctx.arc(dot.x, dot.y, 5, 0, Math.PI * 2); ctx.fillStyle = opt.strokeColor; ctx.fill(); ctx.font = 'bold 12px sans-serif'; const text = String(value); const bw = ctx.measureText(text).width + 16; const bh = 22; let bx = dot.x - bw / 2; let by = dot.y - bh - 10; if (bx < 4) bx = 4; if (bx + bw > this.cssWidth - 4) bx = this.cssWidth - 4 - bw; if (by < 4) by = dot.y + 12; ctx.beginPath(); if (typeof ctx.roundRect === 'function') ctx.roundRect(bx, by, bw, bh, 6); else ctx.rect(bx, by, bw, bh); ctx.fillStyle = 'rgba(30,38,54,0.88)'; ctx.fill(); ctx.fillStyle = '#FFFFFF'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(text, bx + bw / 2, by + bh / 2); ctx.restore(); }, onTouchStart(e) { const touch = (e.touches && e.touches[0]) || (e.changedTouches && e.changedTouches[0]); if (!touch || !this.center) return; const x = touch.x !== undefined ? touch.x : touch.pageX; const y = touch.y !== undefined ? touch.y : touch.pageY; const { cx, cy } = this.center; const total = this.options.dimensions.length; const dx = x - cx; const dy = y - cy; if (Math.sqrt(dx * dx + dy * dy) > this.radius + 20) { this.setData({ activeIndex: -1 }, () => this.render(1)); return; } let angle = Math.atan2(dy, dx) + Math.PI / 2; if (angle < 0) angle += Math.PI * 2; const step = (Math.PI * 2) / total; const idx = Math.round(angle / step) % total; this.setData({ activeIndex: idx }, () => this.render(1)); } });6.2 参数速查表
| 参数 | 作用 | 建议取值 | 调整影响 |
|---|---|---|---|
dimensions | 维度名称数组 | 5 个,每个 2 到 6 字 | 长度直接决定图形边数和标签宽度 |
max | 数值上限 | 与业务评分体系一致 | 影响归一化结果,填错图形会失真 |
levels | 网格层数 | 4 到 6 | 越多越密,超过 6 层会显得杂乱 |
labelSize | 标签字号 | 11 到 13 | 影响 padding 计算,太大会压缩图形 |
labelGap | 标签与顶点的距离 | 12 到 20 | 太小标签贴边,太大图形缩小 |
strokeColor | 数据描边色 | 主题色 | 多组对比时需换成不同色系 |
duration | 入场动画时长 | 600 到 700 毫秒 | 太短看不出效果,太长显拖沓 |
6.3 后续还能怎么扩展
这套结构留了几个自然的扩展口子。维度数量改成 4 或 6,只要改dimensions数组长度,绘制逻辑自动适配,不需要动一行数学代码。要加第二组对比数据,把values换成二维数组,在drawData外面套一层循环,换一下颜色和透明度即可。要做"点击后跳转到详情页",就在onTouchStart里把索引抛给业务层处理。
再远一点,如果要做成头像挂件或者小程序码分享图,可以复用同一套polar和绘制函数,在一个尺寸更大的离屏 canvas 上先画背景、再画五维图、最后画文字和图形码,导出成一张完整的分享卡片。我做过一版,整个过程大概多写四十行代码,因为核心的几何计算已经完全复用了。
最后说两句
我在小程序里画过不下十种图表,五维图是性价比最高的一个——代码量小、视觉冲击力强、产品经理还特别爱用。真正花时间的从来不是绘制逻辑,而是像素比、测量时机、真机差异这些"环境问题"。我的经验是,把一个polar函数和一套 dpr 初始化流程先打磨稳,后面无论画三角形、六边形还是十二边形,都只是改一个参数的事。
还有个小技巧分享给同样要在小程序里折腾 canvas 的朋友:每次改完绘制代码,别只看开发工具的模拟器,一定要用一台安卓机和一台 iPhone 各扫一遍。我遇到过的三次"开发工具正常、真机异常",全部都是在这两台设备的差异里暴露出来的。多花那两分钟,能省下后面几个小时的返工。