
简介基于QT的雷达图绘制源代码是一份面向中高级QT开发者的自定义图形绘制示例用于解决QT原生控件不提供雷达图的问题。资源共8个文件包含3个cpp源文件、2个头文件、1个pro工程文件、1个ui界面文件及1个user配置文件压缩包仅11KB结构轻量清晰。目前已有1787人学习。代码利用QGraphicsView与QGraphicsScene构建绘图场景通过继承QGraphicsItem实现雷达图的点、线、多边形等元素并手动完成极坐标与直角坐标的数学转换。同时接入QT信号槽机制可实现按钮点击动态刷新图表无需重新编译。工程基于QT5.14.2与C11编写对图形渲染做了优化。读者可由此学习自定义控件绘制、坐标变换、事件交互等关键技巧也能直接修改参数调整轴数、最大值与间隔快速集成到自己的数据分析或性能比较项目中。1. 基于 Qt 的雷达图绘制到底卡在哪坐标换算、网格与数据多边形的一次讲清雷达图本身不算复杂复杂的是它在实际看板里的出现方式监控大屏要随数据定时刷新指标维度从 4 个变成 8 个时网格要跟着变某个维度的数值超标时那一整块要高亮变色。这些需求在通用图表控件里往往做不到位反而是在 QWidget 里用 QPainter 自绘最省心。这篇文章把基于 Qt 的雷达图绘制源代码拆成一条能直接跑通的路线覆盖坐标换算、网格与填充、鼠标命中、动态刷新和踩坑排查。适合谁看正在用 Qt 做设备状态监控、能力评估、测试报告这类多指标可视化又不想被第三方图表库绑定的人。我会默认你已经搭好了 Qt 工程下面每一步给出可直接参与编译的代码骨架后端指标接入时替换数据源就行。自绘雷达图的难点不在画线而在坐标。只要把极坐标到屏幕坐标的映射写对网格、轴线、数据折线就全通了。2. 雷达图坐标换算与方案选型极坐标映射和两种绘制路线2.1 极坐标到 QWidget 坐标的映射为什么需要减去 90 度雷达图表面的数据结构很简单一个维度对应一根轴线N 个维度把圆周均匀切成 N 份每个维度的数值大小决定数据点到圆心的距离。但真正动手时会发现网上不少示例在坐标换算这一行就翻车。问题出在 Qt 的坐标系和数学课本里的平面直角坐标系不一致默认状态下原点在窗口左上角X 轴向右Y 轴向屏幕下方。如果照搬数学公式 y r * sin(θ)画出来的图形会是上下颠倒的。所以项目里约定好三个规则整个绘制就不再玄学第一个维度放在 12 点钟方向即正上方后面各维度按顺时针方向依次排列第 i 个维度对应的角度是360.0 * i / N - 90减去的 90 度就是坐标系旋转量半径与数值按比例换算r radius * value / maxValue其中 maxValue 是业务量程。有了这个约定整个雷达图只需要一个映射函数。网格线、放射线、数据多边形的顶点全部复用它保证各层图形对齐。#include QtMath // 把角度度与比例值映射成 QWidget 坐标 static QPointF radarPoint(const QPointF center, double radius, double angleDeg, double value, double maxValue) { double ratio qBound(0.0, value, maxValue) / maxValue; double rad qDegreesToRadians(angleDeg - 90.0); return QPointF(center.x() radius * ratio * qCos(rad), center.y() - radius * ratio * qSin(rad)); }这段代码是整个雷达图的地基。qBound把数值裁剪在 0 到 maxValue 之间异常指标不会让折线飞出网格qDegreesToRadians是 QtMath 提供的角度转弧度函数避免手写 π/180 丢精度Y 坐标用减号把数学坐标系里的向上方向换算成 QWidget 的向下方向。参数上最容易混淆的是 maxValue 的语义。maxValue 决定最外圈刻度代表多少它不是网格层数而是业务量程比如设备能耗上限 100kW、评估满分 100 分。网格层数是另一个概念同心环只是把量程均分成若干段。如果混在一起换量程时网格刻度会一起错乱。我一般把量程作为外部传入参数默认 100内部不再改动它。如果业务要求从正上方开始、按逆时针排列维度只要把传入 radarPoint 的角度参数取反即可映射函数内部不用动。这样一条规则改动就能影响所有图形不至于改了数据多边形却忘了改网格。2.2 用 Qt Charts 还是 QPainter 自绘对比之后我选自绘在 Qt 里画雷达图有两条路Qt Charts 的极坐标图表或者 QPainter 自绘。很多人一开始会倾向封装的 Qt Charts因为它省事。实际项目里把雷达图做成看板主角时往往会发现封装的灵活度撑不起需求。下面这张表是我在模拟项目里反复权衡后整理的对比结果对比项Qt Charts 极坐标方式QPainter 自绘上手速度快十几行能出基础图首次约 200 行需理解映射网格样式控制受轴线 API 限制调整粒度粗每个像素都由自己控制中文字体渲染引擎层级与 QWidget 不完全一致完全跟随 QPainter可控鼠标命中检测需要自行维护 Series 与坐标关系顶点列表自己持有检测简单依赖稳定性Charts 在精简版/嵌入式构建里可能缺失有 QtGui 即可编译性能通用图表框架偏重仅绘制调用轻量结论很直接如果雷达图只是大面板里众多图表之一且其他图表也依赖 Charts 模块那用 Qt Charts 不算错。但如果雷达图是核心展示控件后面还要加超标高亮、顶点拖拽、动态刷新QPainter 自绘的成本反而更低。联合需求越大越该回到自绘这条路上。接下来全部按 QPainter 自绘来写。这个方案的关键是把绘制拆成网格、标注、数据多边形三个环节每部分独立维护后续加交互时才不会牵一发动全身。3. 用 QPainter 自绘雷达图网格、标注和数据多边形的运行代码3.1 搭建 RadarWidget 骨架类别、数值和量程的输入约定我习惯把雷达图封装成一个 QWidget 子类对外只暴露三个入口类别列表、数值列表、最大量程。类别和数值的数量必须一致这是接口层面的硬约束。// RadarWidget.h class RadarWidget : public QWidget { Q_OBJECT public: explicit RadarWidget(QWidget *parent nullptr); void setCategories(const QStringList categories); void setValues(const QVectordouble values); void setMaxValue(double maxValue); protected: void paintEvent(QPaintEvent *event) override; private: void drawGrid(QPainter painter, const QPointF center, double radius); void drawAxisLabels(QPainter painter, const QPointF center, double radius); void drawDataPolygon(QPainter painter, const QPointF center, double radius); private: QStringList m_categories; QVectordouble m_values; double m_maxValue 100.0; QPointF m_center; double m_radius 0.0; int m_hoverIndex -1; };paintEvent 里每次根据当前控件尺寸重新计算中心和半径而不是在 resizeEvent 里缓存。理由很简单窗口拉伸是高频操作把尺寸计算放在绘制入口天然避免缓存不同步的问题画布始终和控件实际大小一致。void RadarWidget::paintEvent(QPaintEvent *) { if (m_categories.size() 3 || m_categories.size() ! m_values.size()) { return; // 维度不足或数据不对齐时不画避免索引越界 } const double margin 60.0; const double side qMin(width(), height()) - 2.0 * margin; if (side 0.0) return; m_center QPointF(width() / 2.0, height() / 2.0); m_radius side / 2.0; QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); drawGrid(painter, m_center, m_radius); drawAxisLabels(painter, m_center, m_radius); drawDataPolygon(painter, m_center, m_radius); }margin 取 60 是有讲究的最外圈维度文字需要空间文字矩形宽度我后面会给到 200高度 4060 的余量在 1080p 窗口下够用。如果你把控件塞进很小的一格布局这个 margin 也可以改成相对值比如qMin(width(), height()) * 0.08。写在 paintEvent 里而不是写死会成为后面导出高清图的关键前提。3.2 网格与放射线分层绘制避免填充污染网格由两层组成若干个同心多边形加上从中心射向各维度的放射线。维度越多多边形越接近圆形但绘制逻辑始终按多边形计算不要贪方便画圆否则维度数变化时圆心留白和顶点对齐都会出问题。void RadarWidget::drawGrid(QPainter painter, const QPointF center, double radius) { const int levels 5; const int n m_categories.size(); QPen gridPen(QColor(210, 210, 210), 1); gridPen.setCosmetic(true); // 线宽固定为 1 逻辑像素 painter.setPen(gridPen); for (int level 1; level levels; level) { QPolygonF polygon; double r radius * level / levels; for (int i 0; i n; i) { polygon radarPoint(center, r, 360.0 * i / n, 1.0, 1.0); } polygon polygon.first(); // 手动闭合 painter.drawPolyline(polygon); } for (int i 0; i n; i) { QPointF end radarPoint(center, radius, 360.0 * i / n, 1.0, 1.0); painter.drawLine(center, end); } }注意两个细节。第一闭合用的是drawPolyline而不是drawPolygon因为 drawPolygon 默认带填充逻辑网格层一旦被半透明笔刷污染底下数据层颜色会浑浊。第二setCosmetic(true)保证高分屏下网格线始终是 1 像素实线不会因为缩放变得发虚或粗细不一。levels 默认 5对应量程的 20%、40%、60%、80%、100%符合大多数业务刻度习惯。有些场景需要 4 层或 6 层把它抽成成员变量即可暂时不必做成属性暴露减少接口冗余。3.3 维度标注和数据多边形先填充后描边的固定顺序维度文字应放在最外圈半径之外留 8 像素空隙避免和网格线压在一起。文字矩形统一用 200 x 40再根据坐标位置切换对齐方式防止左侧文字被截断、右侧文字溢出窗口。void RadarWidget::drawAxisLabels(QPainter painter, const QPointF center, double radius) { QFont labelFont(painter.font()); labelFont.setPixelSize(13); painter.setFont(labelFont); const int n m_categories.size(); for (int i 0; i n; i) { QPointF pos radarPoint(center, radius 8, 360.0 * i / n, 1.0, 1.0); QRectF textRect(0, 0, 200, 40); textRect.moveCenter(pos); Qt::Alignment align Qt::AlignCenter; if (pos.x() width() - 100) align | Qt::AlignRight; if (pos.x() 100) align | Qt::AlignLeft; painter.drawText(textRect, align | Qt::AlignVCenter, m_categories.at(i)); } }数据多边形是雷达图的核心层绘制顺序有一个血泪经验先填充整个闭合多边形再描边最后画顶点。如果反过来半透明填充会把描边和顶点一起盖住形成一层灰蒙蒙的雾。void RadarWidget::drawDataPolygon(QPainter painter, const QPointF center, double radius) { const int n m_values.size(); QPolygonF polygon; for (int i 0; i n; i) { polygon radarPoint(center, radius, 360.0 * i / n, m_values.at(i), m_maxValue); } // 先填充使用低透明度主题色 painter.setPen(Qt::NoPen); painter.setBrush(QColor(36, 100, 200, 40)); painter.drawPolygon(polygon); // 后描边闭合线段需要手动补一条 QPen linePen(QColor(36, 100, 200), 2); linePen.setJoinStyle(Qt::RoundJoin); painter.setPen(linePen); painter.setBrush(QColor(36, 100, 200)); painter.drawPolyline(polygon polygon.first()); // 顶点 for (int i 0; i polygon.size(); i) { if (i m_hoverIndex) { painter.setBrush(QColor(255, 140, 30)); painter.drawEllipse(polygon.at(i), 7, 7); } else { painter.drawEllipse(polygon.at(i), 4, 4); } } }这里我把 hover 高亮也塞进了顶点绘制里hoverIndex 在鼠标事件中更新下一章详细讲。数据多边形如果只需要画一组这套流程已经够用要画多组对比序列把 drawDataPolygon 外面包一层循环每种序列独立传值和颜色即可。4. 交互与动态刷新让雷达图响应鼠标并安全更新数据4.1 顶点命中检测判断鼠标当前位置离哪个数据点最近看板上的雷达图通常要配合鼠标悬停显示具体数值。实现思路是计算鼠标坐标与每个数据顶点之间的距离取最近且小于容差的那个顶点作为 hover 目标。容差我一般定 12 个像素太小不好点中太大会误触相邻维度。void RadarWidget::mouseMoveEvent(QMouseEvent *event) { int hitIndex -1; const double tolerance 12.0; if (m_categories.size() m_values.size()) { for (int i 0; i m_values.size(); i) { QPointF pt radarPoint(m_center, m_radius, 360.0 * i / m_values.size(), m_values.at(i), m_maxValue); if (QLineF(event-pos(), pt).length() tolerance) { hitIndex i; break; } } } if (hitIndex ! m_hoverIndex) { m_hoverIndex hitIndex; update(); } if (m_hoverIndex 0) { setToolTip(QStringLiteral(%1%2) .arg(m_categories.at(m_hoverIndex)) .arg(m_values.at(m_hoverIndex))); } else { setToolTip(QString()); } QWidget::mouseMoveEvent(event); } void RadarWidget::leaveEvent(QEvent *event) { m_hoverIndex -1; update(); QWidget::leaveEvent(event); }这段逻辑有两个关键点。一是鼠标事件里复用了 paintEvent 计算好的 m_center 和 m_radius避免重复计算尺寸二是 hoverIndex 变化时才触发 update鼠标在非顶点区域移动时不会反复重绘节省 CPU。如果维度很多比如超过 50 个每次鼠标移动都重新计算所有顶点距离会有轻微开销。优化方案是把顶点坐标在数据更新时预计算到 QVector 里鼠标事件只做纯几何比较。对于常见 6 到 12 维度场景现算完全够用。4.2 跨线程数据刷新信号槽排队和 update 时机雷达图真实项目里很少是静态数据基本都是采集线程定时给界面推新指标。常见做法是数据源线程只管发信号界面槽里接收后赋值并触发重绘。class DataSource : public QObject { Q_OBJECT public: void start(); signals: void valuesReady(const QVectordouble values); }; // 在控制类中建立连接 connect(source, DataSource::valuesReady, radarWidget, RadarWidget::setValues);当你把信号和槽分别挂在两个线程时Qt 会自动采用 QueuedConnection信号参数会排队到接收者所在线程的事件循环里再由界面线程执行槽函数。这样 m_values 始终在主线程被写入和读取从根上避免数据竞争。setValues 槽需要一个防护数值数量和类别数量不一致时直接忽略否则后续绘制索引越界。这也是接口设计上宁可静默也不崩溃的原则。void RadarWidget::setValues(const QVectordouble values) { if (values.size() ! m_categories.size()) return; m_values values; update(); }刷新频率也要控制。雷达图适合 1 到 5 秒刷新一次采集线程如果每秒推 50 帧界面就会白白重绘 50 次。常见做法是在采集端合并数据或者用 QTimer 在 GUI 线程做节流只取最新一帧。提示如果你的数据源和 RadarWidget 不在同一线程不要在数据线程直接调用 setValues。虽然 Qt 的信号槽最终也会把调用排队回界面线程但直接跨线程调用成员函数会打破 Qt 的事件模型一致性属于迟早要还的写法。5. 雷达图绘制的高频踩坑排查五种翻车现场与对应修复5.1 中文与数字字体变成方块字体家族没有显式锁定现象维度标签里的中文全部显示成豆腐块英文和数字正常。原因绘制时没有指定中文字体Qt 回退到系统默认字体在部分精简系统镜像里默认字体不包含 CJK 字符集。解决在绘制标签前显式设置字体家族优先使用系统常见中文字体。QFont labelFont(painter.font()); labelFont.setFamily(QStringLiteral(Microsoft YaHei)); labelFont.setPixelSize(13); painter.setFont(labelFont);如果是跨平台部署让用户从配置文件选择字体更稳妥。统一字体设置之后整个控件的中文渲染都跟随同一份配置不会再出现某一层字体忽大忽小。5.2 正圆画成椭圆绘图区域没有保持等比缩放现象雷达图在某些窗口尺寸下变成左右扁或上下扁的椭圆网格和填充区域明显变形。原因计算半径时直接取了 width() 或 height()或者上下左右预留边距不一致导致绘制区域不是正方形。解决半径永远基于宽度和高度的最小值计算中心点固定在控件中心。这样即使控件是长方形雷达图也是正圆两侧留白交给布局处理。注意不要用resize(width(), width())硬性改成正方形那会破坏整体界面布局。5.3 高清屏下线条发虚抗锯齿和设备像素比没对齐现象普通分辨率下线条清晰换到高分屏后网格线和多边形边缘发虚。原因常见是没开抗锯齿还有一种是自己手动调了 painter 的缩放导致坐标被重复变换。解决paintEvent 里必须设置Antialiasing渲染提示。Qt 在 Qt6 默认启用高分屏缩放绘制代码里不需要再乘设备像素比也不要去碰QPainter::scale。如果开了抗锯齿还虚检查代码里有没有无谓的painter.translate(width())这一类操作最容易把逻辑坐标弄乱。5.4 半透明区域叠出脏色绘制顺序和合成模式没理顺现象数据多边形之间重叠区域变成黑紫色或者脏绿色尤其在多序列雷达图里严重。原因多个半透明序列重叠后颜色按 SourceOver 合成重叠越多颜色越深视觉上就像被污染了。解决一种做法是从业务上限制透明度值不超过 30另一种是改变绘制顺序先画完所有序列的填充最后统一描边。如果必须要让某一序列看起来更突出可以让该序列不填充、只加粗描边其他序列保持低透明度填充。这个我在多序列版本里默认采用。5.5 窗口拉伸时闪烁背景擦除动作每次都清屏现象拖拽窗口边缘时雷达图区域频繁闪烁残影和空白交替出现。原因默认情况下 Qt 会在重绘前擦除整个背景擦除和重绘之间存在时间差人眼就看到了闪烁。解决给控件设置WA_OpaquePaintEvent属性告诉 Qt 不需要单独擦背景由 paintEvent 自己完整覆盖绘图区域。setAttribute(Qt::WA_OpaquePaintEvent, true);注意这个属性有个前提paintEvent 必须把整个控件矩形画满否则旧内容会残留。雷达图本身有空白背景设置属性后要在绘制最开始用背景色填满整个 rectpainter.fillRect(rect(), QColor(#FFFFFF));如果只想填充雷达图区域而保留透明背景则保持默认属性不要强行关闭擦除否则窗口移动时会出现拖影。6. 进阶封装与图片导出把雷达图沉淀成团队可复用的控件6.1 对外 API 设计多序列、样式和值域的动态控制项目里同一个雷达图往往要承载多组对比数据比如实际值与目标值、本期与上期。这时候把之前的单一 m_values 扩展为多序列接口更合适。void setSeries(int index, const QVectordouble values, const QColor color); void setCategories(const QStringList categories); void setMaxValue(double maxValue); void setGridLevels(int levels);多序列绘制只需在 drawDataPolygon 外面套一层循环每个序列独立持有颜色。为保持可读性我建议把序列数据封装成一个小结构体包含 values 和 color 两项避免用两个并行数组同步管理索引错位是这种设计最常见的隐患。6.2 导出高清 PNG用 QWidget::render 二次绘制报告导出和截图分享经常需要高清效果。不要用 QPixmap::grabWidget 截屏再拉伸因为拉伸只会模糊。正确做法是渲染到一张高分辨率 QImage 上。QImage RadarWidget::exportImage(int scale) { QImage image(size() * scale, QImage::Format_ARGB32_Premultiplied); image.fill(Qt::transparent); image.setDevicePixelRatio(scale); QPainter painter(image); painter.setRenderHint(QPainter::Antialiasing, true); render(painter); painter.end(); return image; }render 会把控件完整重绘到目标 painter 上而 setDevicePixelRatio(scale) 让逻辑坐标和新像素尺寸自动换算。scale 取 2 或 3 就足够读报告导出文件不会大到不可控。这里之所以能把导出代码写得这么干净得益于最初就把尺寸计算放在 paintEvent 里。render 会以控件的逻辑尺寸触发绘制所有坐标换算自动适配不需要额外做任何修正。我做这类控件的习惯是先把量程、维度数量、数据长度三者写成三张表的测试用例再谈样式美化。坐标映射一旦错了后面所有颜色和交互都是白搭这个先后顺序请一定守住。希望这篇文章能把你在雷达图上节省下的时间留给真正值得投入的业务逻辑。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。