1. 为什么需要动态六边形雷达图?
最近接手一个用户画像系统的需求,产品经理拿着某款热门游戏的六边形能力图对我说:"能不能把用户的6个维度评分也做成这样?要带生长动画的那种!"作为一个有追求的前端,我第一反应不是找现成库,而是思考如何用Canvas从零实现这个效果。
六边形雷达图相比传统雷达图有个明显优势:视觉重心更集中。普通雷达图用圆形坐标系,六个维度均匀分布,而六边形通过30°和60°的夹角变化,让相邻维度之间产生更紧密的视觉关联。这在展示个人能力评估、产品特性对比等场景时特别有用——比如LOL的英雄属性面板,六边形结构让强弱项一目了然。
但现成方案往往存在三个痛点:
- 定制性差:开源库的样式修改成本高
- 性能问题:某些库依赖SVG渲染,数据量大时卡顿
- 动画生硬:简单的透明度渐变缺乏专业感
这就引出了我们的解决方案:用Canvas手动实现。Canvas的逐帧绘制能力可以精准控制动画细节,硬件加速确保流畅度,更重要的是能完全掌控视觉呈现。下面我会从最基础的几何原理开始,带你完整走通这个技术路线。
2. 六边形的数学原理与坐标计算
2.1 正六边形的几何特性
先复习下初中几何知识:正六边形可以看作由6个等边三角形组成的图形。关键参数有两个:
- 边长(sideL):每条边的长度
- 外接圆半径(arcMaxR):中心到顶点的距离
这两个值其实相等——因为正六边形的边长刚好等于外接圆半径。这个特性让坐标计算变得简单:
const arcMaxR = 180; // 外接圆半径 const sideL = arcMaxR; // 边长等于半径2.2 顶点坐标推导
以画布中心为原点(arcXPoint, arcYPoint),六个顶点的位置可以通过三角函数计算:
顶部顶点(12点钟方向):
const point1 = { x: arcXPoint, y: arcYPoint - arcMaxR };右上顶点(2点钟方向):
const point2 = { x: arcXPoint + arcMaxR * Math.sin(Math.PI/3), y: arcYPoint - arcMaxR * Math.cos(Math.PI/3) };右下顶点(4点钟方向):
const point3 = { x: arcXPoint + arcMaxR * Math.sin(Math.PI/3), y: arcYPoint + arcMaxR * Math.cos(Math.PI/3) };
剩下三个顶点可以通过对称性得出。实际开发中我会预计算好所有顶点坐标:
const points = []; for(let i=0; i<6; i++) { const angle = Math.PI/2 - i * Math.PI/3; points.push({ x: arcXPoint + arcMaxR * Math.cos(angle), y: arcYPoint - arcMaxR * Math.sin(angle) }); }3. Canvas基础绘制实现
3.1 画布初始化
首先创建400x400像素的画布:
<canvas id="radar" width="400" height="400"></canvas>获取绘图上下文时有个细节要注意:关闭抗锯齿能让线条更锐利:
const ctx = canvas.getContext('2d', { antialias: false });3.2 绘制背景网格
背景由同心六边形和顶点连线组成。这里有个技巧:从外向内绘制可以避免重复计算:
function drawGrid() { ctx.clearRect(0, 0, canvas.width, canvas.height); // 绘制5层同心六边形 for(let i=5; i>0; i--) { const radius = arcMaxR * (i/5); drawHexagon(arcXPoint, arcYPoint, radius, '#E5EBEE'); } // 绘制顶点连线 ctx.strokeStyle = '#E5EBEE'; for(let i=0; i<3; i++) { ctx.beginPath(); ctx.moveTo(points[i].x, points[i].y); ctx.lineTo(points[i+3].x, points[i+3].y); ctx.stroke(); } }3.3 绘制数据区域
数据区域本质是另一个按比例缩放的六边形。假设各维度得分存储在scores数组中:
function drawData(scores) { ctx.beginPath(); // 计算每个顶点的实际位置 const dataPoints = points.map((p, i) => ({ x: arcXPoint + (p.x - arcXPoint) * (scores[i]/100), y: arcYPoint + (p.y - arcYPoint) * (scores[i]/100) })); // 连接顶点 dataPoints.forEach((p, i) => { if(i === 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y); }); ctx.closePath(); // 添加渐变填充 const gradient = ctx.createLinearGradient(...createGradientPoints(dataPoints)); gradient.addColorStop(0, 'rgba(76, 156, 246, 0.6)'); gradient.addColorStop(1, 'rgba(255, 255, 255, 0.3)'); ctx.fillStyle = gradient; ctx.strokeStyle = '#4C9CF6'; ctx.lineWidth = 2; ctx.fill(); ctx.stroke(); }4. 实现生长动画效果
4.1 动画基本原理
要让六边形"生长"出来,需要实现:
- 分段绘制:将动画分解为24帧(可配置)
- 插值计算:每帧只绘制对应比例的区域
- 时间控制:用requestAnimationFrame实现流畅动画
4.2 核心动画逻辑
function animate(scores, duration = 1000) { const startTime = performance.now(); const frameCount = 24; let currentFrame = 0; function update() { const elapsed = performance.now() - startTime; currentFrame = Math.min( frameCount - 1, Math.floor((elapsed / duration) * frameCount) ); drawGrid(); // 绘制当前帧对应的部分 const partialScores = scores.map(s => s * (currentFrame + 1) / frameCount); drawData(partialScores); if(currentFrame < frameCount - 1) { requestAnimationFrame(update); } } update(); }4.3 高级动画技巧
为了让动画更专业,我加入了两个增强效果:
弹性过渡:最后一帧添加overshoot效果
if(currentFrame === frameCount - 1) { const overshootScores = scores.map(s => s * 1.05); drawData(overshootScores); setTimeout(() => drawData(scores), 80); }渐变方向优化:根据最高分动态调整渐变方向
function createGradientPoints(points) { const maxIndex = scores.indexOf(Math.max(...scores)); return [ points[maxIndex].x, points[maxIndex].y, points[(maxIndex + 3) % 6].x, points[(maxIndex + 3) % 6].y ]; }
5. 完整组件封装
5.1 组件API设计
最终封装成RadarChart类,主要配置项:
const chart = new RadarChart({ canvas: '#radar', dimensions: ['攻击', '防御', '敏捷', '智力', '耐力', '暴击'], maxValue: 100, colors: { fill: 'rgba(76, 156, 246, 0.6)', line: '#4C9CF6' }, animation: { duration: 1200, frames: 24 } }); chart.update([80, 90, 70, 85, 60, 95]);5.2 性能优化技巧
离屏Canvas:将静态背景绘制到离屏Canvas缓存
const offscreen = document.createElement('canvas'); const offCtx = offscreen.getContext('2d'); // 绘制背景到offscreen... // 主绘制时直接复制 ctx.drawImage(offscreen, 0, 0);防抖处理:连续调用update时取消未完成动画
let animationId; function update(data) { cancelAnimationFrame(animationId); // ...开始新动画 }响应式适配:监听resize事件自动调整尺寸
window.addEventListener('resize', () => { canvas.width = container.clientWidth; canvas.height = container.clientHeight; chart.refresh(); });
6. 实际应用中的踩坑记录
第一个生产版本上线后,我们发现了几个问题:
高分重叠:当相邻维度都是高分时,文字标签会重叠。解决方案是动态调整标签位置:
function adjustLabelPosition() { // 检测碰撞 // 自动调整偏移量 }移动端模糊:Canvas在Retina屏幕会模糊。需要设置canvas的CSS尺寸为逻辑像素的两倍:
canvas { width: 200px; height: 200px; }同时设置画布本身为400x400物理像素。
数据突变:从[10,10,10]直接变为[90,90,90]时动画不自然。后来加入了中间过渡帧计算:
function getIntermediateValues(start, end, frames) { // 使用缓动函数计算中间值 }
这个项目让我深刻体会到,好的数据可视化不仅是技术实现,更需要考虑用户感知。一个流畅的动画能让枯燥的数据产生情感共鸣,这也是我们前端开发者的价值所在。