简介:QT雷达图绘制源码面向需要学习QT图形界面与数据可视化的开发者,基于QGraphicsView与QGraphicsScene构建交互式雷达图,覆盖自定义图形项、极坐标与直角坐标转换、QPainter多边形绘制及信号槽动态更新等核心知识点。包体共8个文件,包含3个cpp源码、2个头文件、1个pro工程、1个ui界面及1个配置文件,压缩后仅11KB,结构精简便于研读。已有1787人查阅学习,适合有一定C++基础、希望在QT中实现复杂图表或定制化绘制的初学者进阶参考。通过该源码可掌握雷达图轴数、最大值、间隔等参数调整方式,理解QGraphicsView缩放平移与交互机制,并能在现有工程中复用或扩展绘图逻辑。
1. 基于QT的雷达图绘制源代码:为什么我不建议你直接拿现成控件
做量化分析面板、人才评估系统或者设备状态监控的时候,经常会遇到一个需求:把多维度的指标画成雷达图。市面上确实有现成的开源绘图控件,装上去拖进来就能出图,但到了真实项目里,你会发现三个躲不开的问题:第一,控件的样式跟你的整体 UI 风格很难完全一致,改起来比从头画还费劲;第二,数据量一大或者刷新频率一高,控件的渲染性能不够用;第三,复杂交互——比如悬停高亮某个顶点、点击选中某个维度、动态对比多组数据——现成控件往往不支持。所以我更倾向于直接用 Qt 的 QPainter 自己画,把雷达图绘制的源代码握在自己手里。这套做法的核心思路是:雷达图本质上就是「极坐标下的多边形绘图」,理解了坐标变换和 QPainter 的绘制管线,画一个功能完整的雷达图并不难。
2. 雷达图绘制的底层拆解:坐标系、分层结构与时序规划
2.1 雷达图的图层结构:先分四层再动手,后续才有扩展空间
画雷达图的第一步不是写代码,而是把画面拆层。我习惯把整个雷达图拆成四层:背景网格层、轴线与标签层、数据多边形层、交互反馈层。背景网格层负责画同心圆或正多边形轮廓,以及每个维度对应的射线;轴线与标签层负责在每个维度射线的末端放上维度名称和刻度值;数据多边形层是核心,把归一化后的数据映射到各个射线上的点,连成封闭多边形;交互反馈层处理鼠标悬停、选中高亮、图例和 Tooltip。
这个分层直接对应代码结构:每个绘制步骤拆成一个私有函数,paintEvent 里按顺序调用。四层内容不需要做图层合并,QPainter 默认就是按调用顺序叠加绘制,先画网格再画数据,数据层永远盖在网格层上。分层最大的好处是排错方便——某一个维度标签错位了,你直接看标签层的代码;如果只是网格变形,那问题一定出在网格层,不会让你调试的时候把整个绘制函数翻个底朝天。
2.2 坐标变换与归一化:雷达图所有视觉异常的来源
雷达图的核心数学公式只有一组:极坐标到直角坐标的转换。假设画布中心为(cx, cy),数据点在极坐标下的半径为r,所在维度的角度为theta,那么它的直角坐标就是:
qreal x = cx + r * qCos(theta); qreal y = cy - r * qSin(theta);注意 Y 轴方向取负数,因为 Qt 的屏幕坐标系是 Y 轴向下为正,而数学上的极坐标是 Y 轴向上为正,不减负号的话整个图会上下颠倒。这是雷达图绘制里最容易出现的低级错误,代码没问题,效果却完全不对。
角度分配上,n 个维度均匀分布在 360 度内,第 i 个维度的角度是-90度 + i * (360 / n)。起始角度取 -90 度的目的是让第一个维度朝正上方,符合大多数业务场景的阅读习惯。数据归一化则是把原始数值映射到[0, maxRadius]区间,最简单的做法是r = value / maxValue * radius,但这里有一个坑:如果业务数据存在理论下限和上限(比如评分是 -100 到 100),就要按整个区间做对称归一化,否则画出来的雷达图会偏向一侧,视觉上非常误导。
3. 用 QPainter 绘制雷达图的核心代码:从零到一张可用的图
3.1 最小可运行版本:数据类定义与绘制入口
先定义数据结构和控件骨架。数据结构只需要两个信息:维度名称列表和每个维度的数值列表,数值列表对应一组数据。控件类负责把这两组信息渲染成雷达图。
struct RadarSeriesData { QStringList dimensionNames; // 维度名称,顺序对应角度分配 QVector<double> values; // 归一化前的原始数值 QColor color; // 该组数据的绘制颜色 };控件类的骨架如下,重点是 paintEvent 里调用三个私有绘制函数,每个函数只干一件事:
class RadarChartWidget : public QWidget { Q_OBJECT public: explicit RadarChartWidget(QWidget *parent = nullptr); void setDimensions(const QStringList &names); void addSeries(const RadarSeriesData &series); void clearSeries(); protected: void paintEvent(QPaintEvent *event) override; private: void drawGrid(QPainter &painter, const QRectF &chartRect); void drawLabels(QPainter &painter, const QRectF &chartRect); void drawSeriesPolygons(QPainter &painter, const QRectF &chartRect); QPointF polarToCartesian(qreal angleDeg, qreal radius, const QPointF ¢er) const; qreal normalize(qreal value, qreal maxValue, qreal radius) const; QStringList m_dimensions; QList<RadarSeriesData> m_series; qreal m_maxValue; // 全局最大值,用于归一化 };paintEvent 的实现里先开启抗锯齿,再计算绘图区域。绘图区域不直接取整个 widget 的矩形,而是向内收缩一圈,给维度标签留出空间,这个细节直接影响标签是否会被窗口边缘裁剪掉。
void RadarChartWidget::paintEvent(QPaintEvent *event) { Q_UNUSED(event); QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); // 绘图区域收缩 50 像素,给维度名称标签留空间, // 避免"压力""性能"这类文字被窗口边缘截断 QRectF chartRect = rect().adjusted(50, 50, -50, -50); if (chartRect.width() <= 0 || chartRect.height() <= 0) return; drawGrid(painter, chartRect); drawLabels(painter, chartRect); drawSeriesPolygons(painter, chartRect); }drawGrid和drawSeriesPolygons都依赖同一个坐标转换函数,这个函数就是前面提到的极坐标转直角坐标,角度统一用角度制,内部转弧度,这样调用处的代码可读性更好。单独的转换函数还有一个好处:鼠标交互时判断命中哪个顶点也要用它,画图和交互共用一套坐标逻辑,不会出现「画出来一个位置、点击却是另一个位置」的错位。
3.2 数据归一化:决定雷达图形状的第一个关键决策
归一化看起来只是简单的除法,但实际选择「除以什么」直接决定图形的形状语义。我常用的方案是支持两种模式,由构造函数或 setter 参数控制:
// mode = 0: 按当前数据最大值归一化,图形铺满整个网格,适合对比形状 // mode = 1: 按业务理论上限归一化,图形比例忠实反映绝对值,适合看真实水平 qreal RadarChartWidget::normalize(qreal value, qreal maxValue, qreal radius) const { if (qFuzzyIsNull(maxValue)) return 0.0; qreal ratio = value / maxValue; return qBound<qreal>(0.0, ratio, 1.0) * radius; }按数据最大值归一会让图形永远撑满整个网格,适合做「这组数据在维度之间谁的占比大」的对比;按业务上限归一化则保留了数据的绝对含义,比如满分 100 只得了 60 分,图形就只占网格的 60%。两种模式对应的业务解读完全不同,我给控件同时保留两种模式,用setNormalizeMode切换,项目中按需求选择。这里有一个要注意的点:如果维度数值的量纲差异过大,比如一个维度是百分比、另一个维度是次数上万,不做归一化直接画,小数值的维度会贴在内圈看不见。
3.3 绘制网格:同心圆与射线,用循环画出来
网格层的代码分成两个循环:第一个循环画同心圆,第二个循环画每个维度的射线。同心圆的圈数我固定取 4 层,加上最外圈一共 5 圈,圈数太多视觉密集,太少刻度感不强。
void RadarChartWidget::drawGrid(QPainter &painter, const QRectF &chartRect) { const QPointF center = chartRect.center(); const qreal maxRadius = qMin(chartRect.width(), chartRect.height()) / 2.0; QPen gridPen(QColor(200, 200, 200), 1.0, Qt::DashLine); painter.setPen(gridPen); painter.setBrush(Qt::NoBrush); // 画 4 层同心圆 + 最外圈,半径按 20% 步进 const int gridLevels = 5; for (int i = 1; i <= gridLevels; ++i) { qreal radius = maxRadius * i / gridLevels; painter.drawEllipse(center, radius, radius); } // 画每个维度的射线 const int dimCount = m_dimensions.size(); for (int i = 0; i < dimCount; ++i) { qreal angle = -90.0 + i * (360.0 / dimCount); QPointF edgePoint = polarToCartesian(angle, maxRadius, center); painter.drawLine(center, edgePoint); } }射线的数量等于维度数量,角度的计算逻辑和后续画数据多边形完全一致,如果两处的角度公式写得不一样,网格和数据就会错位。我见过不止一次项目里出现这种问题:网格是一个循环、数据是另一个循环,两个循环的角度计算方式不同,结果图看起来网格和数据扭在一起。所以网格层和数据层必须共用同一个角度计算函数,不要各写各的。
3.4 绘制数据多边形:连点成面,带透明度填充
数据多边形的绘制直接利用 QPainter 的 QPainterPath,先把所有顶点加入路径,然后一次绘制多边形边框和填充色。填充色用半透明,这样多组数据叠加时能看清重叠情况。
void RadarChartWidget::drawSeriesPolygons(QPainter &painter, const QRectF &chartRect) { const QPointF center = chartRect.center(); const qreal maxRadius = qMin(chartRect.width(), chartRect.height()) / 2.0; const int dimCount = m_dimensions.size(); for (const RadarSeriesData &series : m_series) { if (series.values.size() != dimCount) continue; // 数据维度数量与维度名称不匹配时跳过 QPainterPath polygonPath; for (int i = 0; i < dimCount; ++i) { qreal angle = -90.0 + i * (360.0 / dimCount); qreal radius = normalize(series.values.at(i), m_maxValue, maxRadius); QPointF point = polarToCartesian(angle, radius, center); if (i == 0) polygonPath.moveTo(point); else polygonPath.lineTo(point); } polygonPath.closeSubpath(); QColor fillColor = series.color; fillColor.setAlpha(40); // 填充透明度固定 40,深色背景上适当调低 painter.setBrush(fillColor); QPen pen(series.color, 2.0); painter.setPen(pen); painter.drawPath(polygonPath); } }顶点数量不足两个时 QPainterPath 不会形成有效多边形,绘制结果为空,所以调用前要检查series.values.size()与维度数量一致。多组数据叠加时,绘制顺序即数组顺序,先画的数据会被后画的盖住,如果想让某组数据始终在最上层显示,把它的位置放到数组末尾即可。这里还有一个经验值:线宽 2.0 在 100% 缩放比下视觉刚好,在高 DPI 屏幕或导出大图时可能需要按缩放比例调整。
3.5 维度标签:刻度值放射线末端,名称放更外侧
标签层的代码分两部分:维度名称放在最外圈网格半径再往外偏移 12 像素的位置;刻度值放在每条射线 80% 半径处。名称的锚点要对齐方式做特殊处理,否则文字会被画到窗口外或者左右偏移不对。标准做法是按角度判断文字锚点:角度在 0 度附近用居中对齐,90 度附近用右对齐,-90 度附近用左对齐,其他角度按象限选择。
void RadarChartWidget::drawLabels(QPainter &painter, const QRectF &chartRect) { const QPointF center = chartRect.center(); const qreal maxRadius = qMin(chartRect.width(), chartRect.height()) / 2.0; const int dimCount = m_dimensions.size(); QFont labelFont = painter.font(); labelFont.setPointSize(9); painter.setFont(labelFont); painter.setPen(QColor(80, 80, 80)); for (int i = 0; i < dimCount; ++i) { qreal angle = -90.0 + i * (360.0 / dimCount); QPointF labelPos = polarToCartesian(angle, maxRadius + 12, center); QString text = m_dimensions.at(i); // 根据角度选对齐方式,避免标签中心和锚点错位 Qt::Alignment align; if (angle < -80 && angle > -100) { align = Qt::AlignHCenter | Qt::AlignBottom; } else if (angle > 80 && angle < 100) { align = Qt::AlignHCenter | Qt::AlignTop; } else if (angle >= -100 && angle <= -80) { align = Qt::AlignRight | Qt::AlignVCenter; } else { align = (angle < 0) ? Qt::AlignBottom : Qt::AlignTop; } painter.drawText(QRectF(labelPos.x() - 100, labelPos.y() - 50, 200, 100), align, text); } }这里的 QRectF 范围故意放宽到 200 乘 100,配合对齐方式,让 Qt 在指定区域内按锚点摆放文字。这个做法比分毫不差地手动计算文字左上角坐标稳健得多,维度名称长短变化时不容易错位。刻度值同样按角度和半径位置绘制,做法与维度名称一致,只是偏移量不同,这里不再重复列举代码。
4. 让雷达图真正能用:图例、鼠标交互与导出图片的三个进阶落点
4.1 多组数据的图例绘制:右上角排布,支持点击显隐
多组数据叠加后,图例不是可选项而是必须项。我直接把图例画在 paintEvent 里,用固定位置——右上角距离边缘 20 像素处往下排。每一行由一个颜色色块、一个系列名称和一个透明度状态组成,点击色块可以临时隐藏该组数据,隐藏后对应的多边形不绘制,但网格和坐标轴保持不变。
// 图例数据结构:名称 + 颜色 + 隐藏标记 QList<LegendItem> m_legendItems; void RadarChartWidget::drawLegend(QPainter &painter) { qreal y = 20; const qreal x = width() - 200; for (int i = 0; i < m_legendItems.size(); ++i) { const LegendItem &item = m_legendItems.at(i); // 色块 12x12 painter.fillRect(QRectF(x, y, 12, 12), item.isHidden ? QColor(180, 180, 180) : item.color); // 系列名称 painter.setPen(item.isHidden ? QColor(180, 180, 180) : QColor(50, 50, 50)); painter.drawText(QRectF(x + 18, y - 2, 160, 20), Qt::AlignLeft | Qt::AlignVCenter, item.name); y += 24; } }图例点击的命中检测放到 mousePressEvent 里,遍历每个图例项的矩形区域,命中后取反隐藏状态并调用update()触发重绘。注意被隐藏的系列在 drawSeriesPolygons 里要跳过去,否则点击图例没有实际效果。我习惯在 drawSeriesPolygons 循环里加一个if (series.isHidden) continue;,而不是在点击事件里把整个 series 从列表里移除,这样切换显隐不会丢失数据顺序,而且重绘效率更高。
4.2 悬停与选中:把「哪个维度离鼠标最近」算清楚
悬停高亮的交互实现依赖两段逻辑:鼠标移动事件里做命中检测,绘制时把命中的顶点和射线画高亮。命中检测的思路是把鼠标坐标换算成以雷达图中心为原点的极坐标,比较角度和半径是否落在某个维度射线和顶点的附近范围内。
void RadarChartWidget::mouseMoveEvent(QMouseEvent *event) { const QPointF pos = event->pos(); const QPointF center = rect().center(); qreal dx = pos.x() - center.x(); qreal dy = center.y() - pos.y(); // 注意 Y 轴取反,与坐标系一致 qreal angle = qRadiansToDegrees(qAtan2(dy, dx)); if (angle < 0) angle += 360; // 把 atan2 给出的 0 度起始值换算成雷达图的 -90 度起始角度 qreal adjustedAngle = angle - 90.0; if (adjustedAngle < 0) adjustedAngle += 360.0; // 找最接近的维度角度 int hoveredIndex = -1; qreal minDiff = 360.0; const int dimCount = m_dimensions.size(); for (int i = 0; i < dimCount; ++i) { qreal dimAngle = i * (360.0 / dimCount); qreal diff = qAbs(adjustedAngle - dimAngle); if (diff > 180.0) diff = 360.0 - diff; if (diff < minDiff) { minDiff = diff; hoveredIndex = i; } } // 差值小于阈值才判定为命中,防止悬停空白处也高亮 if (minDiff < 10.0) { m_hoveredIndex = hoveredIndex; setCursor(Qt::PointingHandCursor); } else { m_hoveredIndex = -1; unsetCursor(); } update(); }高亮绘制在 drawSeriesPolygons 之后追加一个独立函数,只画命中的维度的射线和高亮圆点,颜色用系列颜色加深,圆点半径放大到 5 像素。这个交互有一个比较容易出错的点:鼠标位置换算极坐标时的角度基准必须和绘制时的角度基准完全一致,绘制从 -90 度开始,换算回来时也要减 90,差一个角度基准,悬停高亮就会落在错误的方向上,看起来像「鼠标指 A 却亮 B」。
4.3 导出图片:paintEvent 复用与高 DPI 导出的分辨率处理
导出功能最常见的问题是「导出图比屏幕上看到的模糊」。原因很简单:直接调用widget->grab()得到的是屏幕分辨率下的渲染结果,放大后自然模糊。我常用的做法是单独走一遍绘制逻辑到 QImage,指定目标分辨率,然后用 QPainter 的缩放变换让所有绘制代码原样执行。
void RadarChartWidget::exportToPng(const QString &filePath, int width, int height) { QImage image(width, height, QImage::Format_ARGB32_Premultiplied); image.fill(Qt::transparent); QPainter painter(&image); painter.setRenderHint(QPainter::Antialiasing, true); // 按导出尺寸与当前控件尺寸的比例做缩放,绘制代码完全复用 qreal scaleX = width / qreal(this->width()); qreal scaleY = height / qreal(this->height()); painter.scale(scaleX, scaleY); painter.translate(-geometry().topLeft()); // 直接调用渲染逻辑。这里把 paintEvent 的核心抽成 renderChart(QPainter &) renderChart(painter); painter.end(); image.save(filePath, "PNG"); }这里的renderChart是从 paintEvent 里抽出来的公共绘制函数,paintEvent 只负责创建 QPainter 并调用它。导出大图时字体和线条宽度也会跟着 scale 等比放大,这在高 DPI 场景下是期望行为,但如果你只想要放大的图、不想要变粗的线,就需要在 renderChart 里根据 painter 的变换矩阵手动调整QPen宽度和字体大小,这个调整参数没有统一标准,我在项目里是按输出宽度除以控件宽度作为线宽补偿系数。
5. 雷达图绘制的 4 个高频踩坑点:现象、原因与解决办法
5.1 图形上下颠倒或左右镜像
现象:雷达图的第一个维度没有朝正上方,而是朝正下方或者左右翻转,图形像照了镜子。原因:极坐标转直角坐标时 Y 轴方向没有取反,Qt 屏幕坐标的 Y 轴是向下的,数学坐标的 Y 轴是向上的,直接套公式就会镜像。解决:把y = cy + r * sin(angle)改成y = cy - r * sin(angle),然后重新编译运行确认方向正确。这个坑特别容易出现在从网上参考代码或者数学教材公式直接搬的时候,教材里坐标系和 Qt 不一致,不能直接抄。
5.2 维度数量超过 8 个后标签文字挤成一团
现象:维度增加到 10 个以上时,维度名称互相重叠,刻度值也挤在网格圈上,图完全没法读。原因:360 度除以维度数量得到每两个维度之间的夹角,维度越多夹角越小,标签文字本身的宽度不会跟着变小,超过一定数量必然重叠。解决:方案一是缩短标签文字,用两字以内的短名;方案二是标签文字改为在射线末端沿射线方向放置,而不是水平放置;方案三是维度数量超过 8 个时改用横向条形图或者把雷达图改成「环形图」形态,只画高亮扇区。我在实际项目中遇到 12 个维度的需求,最后是改成双圈布局,外圈放主要的 8 个维度标签,内圈放剩余维度的缩略名,配合 Tooltip 显示全名。
5.3 不同维度数值量纲差异大,小数值维度显示不出来
现象:一组数据里有百分比值(0 到 100)和次数值(上千甚至上万),按统一最大值归一化后,百分比维度在多边形上只有一个贴近中心的小点。原因:归一化时直接除以全局最大值,量纲大的维度把量纲小的维度压缩到中心区域。解决:先给每个维度独立设置理论最大值,归一化时按各自的最大值分别除,而不是全局统一除。维度独立上限控制在数据类里增加一个maxPerDimension列表,归一化时优先取维度上限,取不到再用全局最大值兜底。这个改动不影响坐标转换逻辑,只改归一化函数,但图形表达的含义会从「绝对值对比」变成「各自完成度对比」,业务上要明确用哪种语义。
5.4 频繁 setData 后重绘闪烁、界面卡顿
现象:数据每秒刷新 5 次以上时,整个控件区域不断闪烁,CPU 占用率居高不下。原因:每次刷新都直接update()触发整个控件重绘,网格层和数据层全部重画一遍,而网格层其实是静态的,浪费了大量绘制时间。解决:把网格层和标签层缓存到 QPixmap,数据层单独重绘,paintEvent 里先贴缓存再画数据。我实际用的方案是在 resizeEvent 里重建网格缓存,数据变化时只重绘数据层,这样每秒刷新 10 次都不会有明显的性能压力。具体做法是缓存一个QPixmap m_gridCache,在 resize 时绘制网格和标签,paintEvent 里drawPixmap(0, 0, m_gridCache)之后再做数据层的绘制,CPU 占用能下降 60% 以上。
6. 把绘制代码收进组件:封装技巧与性能验证
最后一个值得做的动作,是把前面这些散落的绘制逻辑收成一个独立控件类。我自己的封装习惯是:对外暴露setDimensions、addSeries、setNormalizeMode、setMaxValue、exportToPng几个方法,内部把 paintEvent 抽成renderChart(QPainter &),数据层和网格层分开缓存,交互相关的状态变量集中管理。这样做的好处是换项目时整个控件文件可以直接复制过去,不需要改任何绘制逻辑,只需要适配数据接口。
封装之后最好做一个简单的性能验证,方法是在一个定时器里循环调用addSeries数据更新,观察 CPU 占用和重绘耗时。我在项目里用QElapsedTimer量过,10 个维度加 3 组数据,普通update()触发重绘一次大约 8 到 15 毫秒,在 60Hz 刷新下有余量;如果超过 30 毫秒,优先检查是不是直接把整个 widget 区域都重绘了,以及网格层是否每次都重建。还有一个隐蔽的性能点是 dimension 名称的字体测量,Qt 的QFontMetrics::horizontalAdvance在绘制循环里频繁调用会有开销,把字体宽度测量结果按维度缓存起来,重绘时直接查表,比每次都量快不少。这些优化做完,雷达图在主流配置上跑实时刷新是绰绰有余的。我踩过的最深的一个坑就是把所有绘制都堆在一个函数里导致没法做缓存,后来全部改成按层拆分,性能问题和排错难度一起降下来了。希望这篇基于 QT 的雷达图绘制源代码拆解对你有用,按这个思路做出来的控件,至少能撑住两到三个真实业务项目的复用。
本文还有配套的精品资源,点击获取