用Canvas手写雷达图+摇杆图,打造灵活的职业测评可视化
简介面向Qt/C开发者的自定义控件示例整合了雷达图与摇杆图两种常用组件方向键支持按需扩展适合游戏摇杆、雷达扫描或数据可视化等界面场景。项目由turbolove基于VS2019与Qt 5.15.2开发全程采用CMake构建不依赖第三方库源码注释详细能帮助读者理解QPainter绘图、鼠标交互以及控件封装方法。压缩包内共10个文件包含5个cpp实现主窗口、雷达/摇杆交互和入口逻辑4个h头文件用于声明类与接口另有CMakeLists.txt构建脚本确保工程可直接编译包体仅13KB轻量易读整体结构清晰便于二次开发。目前已有521人学习或下载作者后续还会发布配套讲解文章。radargpu等变体还提供了GPU绘制对照便于拓展性能优化思路无论是入门还是参考都是不错的Qt自定义绘图范例。 在做职业规划类小工具的时候我遇到一个挺实际的需求既要直观展示一个人的综合能力结构又要把“适合往哪个方向走”这种带有倾向性的结果表达清楚。看了很多现成的可视化方案最后还是决定自己动手——用雷达图展示能力维度用一个类似游戏摇杆的环形图来展示方向偏好。这个组合做下来效果意外地好尤其是摇杆图方向数量可以根据业务需求随意增减比想象中灵活得多。这篇文章就把我实现“雷达图 摇杆图”的完整过程拆开来讲包括数据结构怎么设计、Canvas 绘制的核心算法、方向动态扩展的机制以及我在实际开发中踩过的坑。适合正在做数据可视化、个人能力测评工具、游戏角色属性面板或者职业规划类应用的同学参考。代码基于原生 JavaScript Canvas 实现不依赖任何图表库理解起来更透彻改造起来也更自由。1. 方案思路为什么是雷达图搭配摇杆图先说清楚一个容易被忽视的问题雷达图并不是万能的。在很多测评类产品里雷达图被用来展示“结果维度”比如沟通能力、逻辑思维、执行力这些抽象指标。雷达图的本质是“多维度对比”它能很直观地表现出各项指标之间的均衡度——如果某个人的能力画像在图上呈现出明显的尖角就说明他在某个维度上有突出优势。但雷达图有个天然的短板它只能表达“静态的维度值”表达不了“动态的方向选择”。打个比方雷达图能告诉你一个人的力量、敏捷、智力各有多少分但它没法告诉你“这个人更适合走战士路线还是法师路线”。所以我在职业测评工具里引入了第二个图标——摇杆图。摇杆图的设计灵感来自于游戏手柄的摇杆一个原点向四周发散出多条方向轴每条轴代表一个可以选择的“方向”数据点落在哪个方向上、距离中心多远就代表这个方向的优先级和强度。和雷达图相比摇杆图最大的优势是“方向轴可以被动态增删”今天业务需要 4 个方向配置数组里放 4 个元素明天改成 8 个方向配置数组里加 4 个元素代码逻辑完全不用动。这两张图放在一起恰好形成一个完整的叙事闭环雷达图回答“你拥有什么”摇杆图回答“你适合去哪里”。雷达图的维度是能力项摇杆图的方向是职业路径或发展倾向两者的数据可以在底层打通比如摇杆图每个方向的评分可以由雷达图的若干权重维度加权计算得出。2. 数据结构与布局设计2.1 雷达图的数据组织雷达图的数据组织其实很简单核心就是一个“维度名 数值”的映射集合。我习惯用数组包对象的形式来组织而不是用纯对象因为数组可以天然保持顺序绘制时按顺序遍历即可不需要关心对象的键序遍历问题。const radarData [ { name: 逻辑思维, value: 82 }, { name: 沟通表达, value: 67 }, { name: 执行力, value: 90 }, { name: 创新能力, value: 74 }, { name: 团队协作, value: 88 }, { name: 学习能力, value: 79 } ];这里有一个容易被忽略的设计点维度顺序会影响雷达图的观感。如果维度之间没有内在关联绘制出来的多边形会显得很乱尖角到处乱窜。我自己在实践中的做法是把同类的维度尽量排在一起比如把“逻辑思维”和“创新能力”这种偏向思维侧的维度放在相邻位置把“执行力”和“团队协作”这种偏向行动侧的维度放在相邻位置这样画出来的图形更有区块感阅读体验更好。另一个需要注意的地方是归一化。雷达图的半径通常是固定的像素值比如 150px但原始业务数据可能是百分制、十分制甚至五分制所以有必要在绘制前做一个统一的数据归一化处理将所有维度的值映射到 0 到 1 之间再乘以半径得到实际的像素坐标。归一化逻辑我放在数据预处理阶段而不是绘图阶段这样绘图函数只关心画图不需要关心业务量纲职责更清晰。2.2 摇杆图的方向扩展机制摇杆图的“方向扩展”是这次设计的重点。早期版本我把方向写死成“左上、右上、左下、右下”四个后来产品反馈说需要新增“管理方向”和“专业方向”改代码改得我头大。重构之后我把方向全部数据化用一个配置数组来驱动渲染。const joystickConfig { centerText: 职业倾向, directions: [ { key: dev, name: 技术研发, weight: 0.8 }, { key: mgmt, name: 管理路线, weight: 0.5 }, { key: product, name: 产品设计, weight: 0.7 }, { key: marketing, name: 市场运营, weight: 0.4 }, { key: sales, name: 商务销售, weight: 0.35 }, { key: edu, name: 教育培训, weight: 0.6 } ] };每个方向只需要提供 key、name 和 weight 三个字段key 用于关联业务逻辑name 用于展示weight 是显示在摇杆图上的数值。绘制时我先根据 directions 数组的长度算出每个方向的夹角360 度 / 方向数量然后从圆心出发按照当前角度绘制轴线、刻度和数据标记。这样只要修改配置数组就能控制方向数量和方向名称从 4 个方向扩展到 12 个方向不需要动任何绘图代码。这个数据驱动的思路是这次实现中最值得借鉴的部分。说白了就是把“图形结构”和“业务数据”彻底解耦配置数组是业务层和图形层之间的唯一契约。后续如果接到其他项目要复用这套代码只需要改 directions 数组的字段名和业务映射即可几乎零成本。3. 实操从零手写一个雷达图3.1 画布准备与基础几何计算我先用原生 Canvas 实现了雷达图没有引入 ECharts 这类重型库原因有二一是雷达图的绘制逻辑并不复杂核心就是坐标换算和多边形填充手写反而更可控二是在定制化极高的测评工具里需求经常会变比如加个背景网格动画、点击维度高亮等等用原生 Canvas 改起来完全没有心理负担。画布的准备很简单一个 canvas 元素一个 context 对象然后设置好中心点和半径即可。关键的计算在于要把一个维度的索引值 i 转换成画布上的坐标点。角度计算使用 Math.PI 的倍率因为 Canvas 的弧度制和数学里的弧度制一致0 弧度指向三点钟方向顺时针为正。function getRadarPoint(centerX, centerY, radius, index, total, valueRatio 1) { const angle (Math.PI * 2 / total) * index - Math.PI / 2; const x centerX Math.cos(angle) * radius * valueRatio; const y centerY Math.sin(angle) * radius * valueRatio; return { x, y }; }我在角度计算里减去了 Math.PI / 2也就是把起始角度从三点钟方向改为十二点钟方向这样第一个维度会显示在正上方更符合阅读习惯。如果你希望起始位置在正上方偏左 30 度只需要把减去的偏移量调整一下即可。这个偏移量的选择是个细节问题但它直接决定了图表的可读性。网格线的层数也需要提前决定。我通常画 4 层网格也就是从中心向外均匀分布 4 个等比例缩放的多边形。每层网格本质上就是调用同样的 getRadarPoint 函数把 valueRatio 设成 0.25、0.5、0.75、1然后把各层的顶点依次连线即可。网格的作用不仅仅是好看它给用户提供了对比数值大小的参照系尤其在看多组数据叠加时网格几乎必不可少。3.2 网格线、数据区与标签的绘制要领具体绘制顺序我建议是先画网格层再画数据区最后画标签文字。这个顺序不能乱否则网格线会覆盖数据区标签会被数据多边形遮挡。网格绘制时我用了两层结构外圈的多边形边框用浅灰色实线内部的分割线用更淡一点的虚线。这样既能看清层级又不会喧宾夺主。数据区是多边形填充我用半透明填充色rgba叠加实线边框既能看到数据形状又不会完全遮挡网格。最后在数据区顶点上画小圆点颜色和边框一致让数据点更醒目。标签文字的绘制有一个需要注意的点Canvas 默认的文字对齐方式是 “start”如果你直接把文字画在顶点坐标处左边和上边的标签会超出画布边界。我的处理方式是根据角度判断文字所属的区域左侧区域的文字右对齐右侧区域的文字左对齐顶部和底部的文字居中。这个逻辑听起来复杂实现起来其实就是一组成对的条件判断在下方的示例代码中你可以看到我的处理方式。function drawRadar(ctx, config, data) { const { centerX, centerY, radius, layers 4 } config; const total data.length; for (let layer 1; layer layers; layer) { const ratio layer / layers; ctx.beginPath(); for (let i 0; i total; i) { const point getRadarPoint(centerX, centerY, radius, i, total, ratio); if (i 0) ctx.moveTo(point.x, point.y); else ctx.lineTo(point.x, point.y); } ctx.closePath(); ctx.strokeStyle #d0d7de; ctx.lineWidth 1; ctx.stroke(); } ctx.beginPath(); for (let i 0; i total; i) { const point getRadarPoint(centerX, centerY, radius, i, total, data[i].normalized); if (i 0) ctx.moveTo(point.x, point.y); else ctx.lineTo(point.x, point.y); } ctx.closePath(); ctx.fillStyle rgba(59, 130, 246, 0.2); ctx.fill(); ctx.strokeStyle rgba(59, 130, 246, 0.8); ctx.lineWidth 2; ctx.stroke(); data.forEach((item, index) { const point getRadarPoint(centerX, centerY, radius, index, total, item.normalized); ctx.beginPath(); ctx.arc(point.x, point.y, 4, 0, Math.PI * 2); ctx.fillStyle #3b82f6; ctx.fill(); }); }这段代码是核心绘制逻辑简洁但完整。你在实际项目里可以直接复用只需要根据业务场景替换 data 数组的组装方式和颜色主题即可。我建议把整个绘制封装成函数参数只传配置对象和数据数组这样在 React 或 Vue 里调用时非常方便组件只需要在 didMount 和 data 变化时调用一次即可。4. 实操摇杆图的方向扩展实现4.1 环形布局与坐标换算摇杆图的布局和雷达图有一点像但设计目标不同。雷达图的核心是“多边形面积”摇杆图的核心是“方向上的矢量”。我把它设计成一个圆盘中心区域显示标题文字向外发散方向轴每个方向轴上有刻度线和当前数值的标记点标记点距离圆心的远近代表权重高低。数据标记到圆心的连线形成一条条“拨杆”看起来就像游戏手柄的摇杆向不同方向推动了不同的幅度。环形布局的关键还是坐标换算基本原理和雷达图一致角度同样是 360 度除以方向数。但摇杆图有一个额外需求方向轴是“有向线段”从圆心指向外圈所以需要同时计算内端点圆心附近和外端点圆盘边缘然后连线。function getDirectionPoint(centerX, centerY, maxRadius, index, total, scale 1) { const angle (Math.PI * 2 / total) * index - Math.PI / 2; const x centerX Math.cos(angle) * maxRadius * scale; const y centerY Math.sin(angle) * maxRadius * scale; return { x, y, angle }; }scale 参数很有意思它使得同一个函数既能算出轴线外端点又能算出数据标记点位置。轴线绘制时 scale 固定为 0.9留一点边距给标签数据标记点绘制时 scale 就是 weight 归一化后的值。一次封装多处复用这是我在写 Canvas 工具时的通用习惯。4.2 如何自由增减方向自由度是这个摇杆图组件最核心的卖点也是我在标题里强调“方向可以根据你自己的需要增加”的原因。实现自由增删方向的关键在于我全程没有写死任何方向数量相关的魔法数字从角度计算到标签旋转偏移全部基于 directions 数组的长度动态推导。比如方向的标签文字我默认把文字放在轴线末端的外侧但如果方向数量增加到 8 个以上相邻标签就可能互相重叠。我采取的方案是当方向数量小于等于 6 时标签全部放在轴线末端外侧当方向数量大于 6 时奇数方向的标签放在末端外侧偶数方向的标签向内偏移到轴线中部外侧。这样做的原理是交错布局避免相邻标签在狭小空间里挤压碰撞。另一个需要考虑的点是方向轴的颜色。方向较多时如果所有轴线都是一个颜色用户很难快速区分不同方向。我在配置里给每个方向增加了一个可选的 color 字段如果没填就用内置的默认色板按顺序取色。色板我用了一组高对比度的颜色保证在白色背景下有足够的辨识度。function drawJoystick(ctx, config) { const { centerX, centerY, maxRadius, directions } config; const total directions.length; const labelOffset total 6 ? 26 : 40; directions.forEach((dir, index) { const axisEnd getDirectionPoint(centerX, centerY, maxRadius, index, total, 0.9); ctx.beginPath(); ctx.moveTo(centerX, centerY); ctx.lineTo(axisEnd.x, axisEnd.y); ctx.strokeStyle dir.color || #94a3b8; ctx.lineWidth 1.5; ctx.stroke(); const dataPoint getDirectionPoint(centerX, centerY, maxRadius, index, total, dir.weight); ctx.beginPath(); ctx.arc(dataPoint.x, dataPoint.y, 6, 0, Math.PI * 2); ctx.fillStyle dir.color || #3b82f6; ctx.fill(); const labelPoint getDirectionPoint(centerX, centerY, maxRadius labelOffset, index, total, 1); const labelConfig getLabelAlign(labelPoint, centerX, centerY); ctx.fillStyle #334155; ctx.font 13px sans-serif; ctx.textAlign labelConfig.align; ctx.textBaseline labelConfig.baseline; ctx.fillText(dir.name, labelPoint.x labelConfig.dx, labelPoint.y labelConfig.dy); }); ctx.beginPath(); ctx.arc(centerX, centerY, 28, 0, Math.PI * 2); ctx.fillStyle #1e293b; ctx.fill(); ctx.fillStyle #ffffff; ctx.font bold 12px sans-serif; ctx.textAlign center; ctx.textBaseline middle; ctx.fillText(config.centerText || , centerX, centerY); }这段代码里的 getLabelAlign 是我用来计算文字对齐方式的辅助函数它会根据地标点相对于圆心的位置象限返回 textAlign 和 textBaseline 的配置。具体实现思路可以描述为地标点 x 小于圆心 x 时 textAlign 为 right大于时为 left约等于时为 centery 方向的判断逻辑同理。有了这个辅助函数无论方向数量怎么变标签文字总能自然贴合图形不会出现文字跑到画布外或者和轴线重叠的情况。5. 场景落地职业能力评估中的组合玩法5.1 雷达图定义能力底座把两张图组合起来之后我给自己做了一个“职业能力体检”的小应用。雷达图展示的是六个底层能力维度的评估结果这六项能力是通用的不绑定具体职业相当于你的能力底座。在这个应用里雷达图的维度值不是用户自己随便填的而是通过一段行为测评问卷计算得出的综合分数。测评问卷大概有二十道题每道题对应一个能力维度作答后按维度加权汇总输出 0 到 100 的原始分。比如“沟通表达”维度对应的题目可能包括公开演讲频率、说服他人成功的经验、书面表达的自信心等。所有题目作答完毕后系统对每个维度分别计算加权平均分再归一化到 0 到 1 的范围最终喂给雷达图渲染。这个设计模式非常适合推广到其他场景。比如企业人才盘点时雷达图可以用来展示员工的管理能力、专业能力、协作能力等维度游戏角色创建时雷达图可以用来展示力量、敏捷、智力、耐力等属性。只要数据格式符合“维度名 数值”的结构这套雷达图组件就能直接复用。5.2 摇杆图映射职业倾向摇杆图在这个应用里的角色是“职业倾向预测”。每个方向轴代表一个大类职业方向方向轴上的数据点远近代表用户在该方向上的匹配度。匹配度的计算逻辑是把用户的能力底座数据和每个职业方向的“能力权重模型”做余弦相似度计算生成 0 到 1 的匹配分。举个例子“技术研发”方向的权重模型可能是逻辑思维 0.4、学习能力 0.3、创新能力 0.2、执行力 0.1而“市场运营”方向可能是沟通表达 0.35、创新能力 0.25、执行力 0.2、团队协作 0.2。用户的能力画像如果偏向逻辑思维强摇杆图上的“技术研发”方向就会亮起来其数值远高于其他方向。摇杆图在这种“方向预测”场景下有天然的适配性因为它把“非此即彼”的选择变成了“看数值取舍”的决策。用户看到摇杆图上多个方向都有不低的匹配度可以综合参考而不是被系统强硬地推入某个单一方向。我还额外做了一层交互点击某个方向轴时下方会展示该职业方向的详细说明和所需能力的雷达图高亮对比两张图联动信息呈现非常立体。6. 踩坑记录与常见问题排查6.1 雷达图的典型绘制问题在开发过程中有几个雷达图的坑我认为值得专门写出来给后续复用的同学提个醒。第一数据全零时图形异常。如果某次评估所有维度都打分极低归一化后全部接近 0雷达图会退化成中心的一个小点。这个视觉上很丑而且容易让用户产生系统出错的误解。我的处理方式是在归一化时增加一个最小值阈值比如数据最低不低于 0.05保证即使极端情况也能看到一个极小的多边形轮廓而不是完全消失。第二标签重叠问题。维度数量超过 8 个时标签重叠几乎是必然的。除了前面提到的交错布局我还会在维度数量超过 10 个时自动降低字号从 14px 降到 12px并增加相邻标签之间的最小间距检测。间距检测的原理是绘制前先计算每个标签文本的近似宽度然后判断相邻标签的包围盒是否相交如果相交就错开。这个逻辑不复杂但能显著提升图表的可读性。第三Canvas 在高分屏上模糊。这是个老生常谈的问题但总有人忽略。现在的笔记本和手机屏幕基本都有 devicePixelRatio 大于 1 的情况Canvas 默认的绘制尺寸如果不做倍率变换画出来的线条会发虚。我的做法是在初始化时获取 devicePixelRatio把 canvas 的实际宽高乘以倍率然后用 CSS 尺寸固定显示大小这样绘制出来的图形才能保持清晰锐利。6.2 摇杆图的交互与视觉细节摇杆图的 Hover 效果是我后期加上的因为静态展示虽然直观但缺少“拨动”的反馈感。我给每个方向的数据标记点绑定了 mousemove 事件当鼠标进入标记点半径 8px 范围时标记点放大并显示 tooltiptooltip 内容包含方向名称和匹配分数。这个功能用 Canvas 实现时不能直接给单个图形绑事件需要监听 canvas 本身的 mousemove然后通过坐标反算判断当前鼠标落点是否落在某个标记点范围内。核心代码如下canvas.addEventListener(mousemove, (e) { const rect canvas.getBoundingClientRect(); const mouseX e.clientX - rect.left; const mouseY e.clientY - rect.top; let hoveredIndex -1; directions.forEach((dir, index) { const point getDirectionPoint(cx, cy, maxRadius, index, total, dir.weight); const dist Math.hypot(mouseX - point.x, mouseY - point.y); if (dist 12) hoveredIndex index; }); if (hoveredIndex ! -1) { canvas.style.cursor pointer; showTooltip(directions[hoveredIndex], mouseX, mouseY); } else { canvas.style.cursor default; hideTooltip(); } render(); });这种事件方案的优点是没有多余的事件监听开销所有方向的 Hover 判断都在一次 mousemove 回调中完成。数据量增大时同样适用因为方向数量通常不会超过几十个线性遍历的性能完全可接受。摇杆图还有一个不容忽视的视觉细节——中心圆区域。我见过一些实现把中心区域留成空白视觉效果很突兀。我的做法是填入深色实心圆并配上标题文字比如“职业倾向”或者“能力”。这样既丰富了视觉层次又给了用户一个信息焦点。中心和外围的方向轴形成强烈的明暗对比用户的视线会自然地被引导到外围的数据标记点上阅读动线非常顺畅。我自己在实际使用中的体会是雷达图和摇杆图的组合远不止“两个图表放在一页”这么简单。雷达图提供的是能力的细粒度剖面摇杆图提供的是方向上的高粒度决策一细一粗、一横一纵恰好互补。如果你也想做类似的测评可视化工具不妨在布局上尝试把雷达图放在左侧作为“能力解析”摇杆图放在右侧作为“方向建议”鼠标在雷达图上悬停时会同步高亮摇杆图对应的方向轴。最后再分享一个小技巧在做图形联动时我建议底层数据模型共用一个 store雷达图的维度数值和摇杆图方向轴的计算分数都从同一份评估结果推导但两者的展示状态各自独立。这样既能保证数据一致又方便后续扩展“双角色对比”“历史趋势”这类进阶需求。整套实现完成之后你会发现最大的收获不是代码本身而是“数据结构驱动图形渲染”这套思考方式——它让你面对任何新图表需求时都能快速拆解出数据模型然后用最直接的方式把它画出来。本文还有配套的精品资源点击获取

相关新闻

最新新闻

日新闻

周新闻

月新闻