ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

QCustomPlot散点样式全解析:从基础设置到性能优化实战

QCustomPlot散点样式全解析:从基础设置到性能优化实战

1. 从折线到散点:为什么需要关注 Scatter Style

很多人用 QCustomPlot 画图,第一反应就是addGraph()然后setData(),默认出来一条折线,能用就行。但真正做过数据可视化项目的人都知道,折线图适合表达趋势,散点图适合表达分布和相关性。当你需要展示一组实验测量值的离散程度、观察两个变量之间是否存在聚集特征、或者在一张图上叠加多组不同来源的数据点时,散点样式就成了绕不开的需求。

QCustomPlot 作为 Qt 生态里最常用的图表库之一,它的QCPScatterStyle类专门负责控制散点的外观。这个类看起来简单,实际上里面的门道不少——形状怎么选、大小怎么定、填充和描边怎么配合、自定义图片怎么用、性能怎么保证,每一个点都值得单独拿出来说。我在几个数据采集和信号分析类的项目里反复用过这套东西,踩过的坑也不算少,今天就把 Scatter Style 这块从头到尾捋一遍。

这篇文章适合两类人:一类是刚接触 QCustomPlot、还在被各种 set 方法绕晕的初学者;另一类是用了一段时间但总觉得散点画出来“差那么点意思”的开发者。我会从最基础的散点设置讲起,逐步深入到自定义样式、性能优化和实际项目中的组合用法,尽量把每个参数背后的逻辑讲清楚,而不是只丢一段代码让你抄。

2. QCPScatterStyle 的核心构造逻辑与形状体系

2.1 一个类,两种构建路径

QCPScatterStyle的构造函数有好几个重载,但归根结底可以分成两条路径。第一条路径是指定一个预定义的形状枚举值,比如QCPScatterStyle::ssCircle、QCPScatterStyle::ssDisc、QCPScatterStyle::ssSquare这些;第二条路径是传入一个QPixmap或QPainterPath,用自定义图形来充当散点。

先看第一条路径的典型写法:

QCPScatterStyle scatter; scatter.setShape(QCPScatterStyle::ssCircle); scatter.setSize(8); scatter.setPen(QPen(Qt::blue, 1.5)); scatter.setBrush(QBrush(Qt::yellow));

这段代码做了四件事:把散点形状设为圆形,尺寸设为 8 像素,描边用蓝色 1.5 像素宽的画笔,填充用黄色画刷。最终效果是一个黄底蓝边的圆点。这里有个容易混淆的地方——ssCircle和ssDisc的区别。ssCircle是空心圆,只画轮廓;ssDisc是实心圆盘,会用画刷填充。如果你设了ssCircle又设了 brush,填充也会生效,但默认情况下ssCircle是不填充的。

第二条路径用自定义图片:

QPixmap customPixmap(":/icons/star.png"); QCPScatterStyle scatter(customPixmap, 12);

或者用QPainterPath画一个自定义形状:

QPainterPath path; path.addEllipse(-4, -4, 8, 8); path.addRect(-2, -6, 4, 12); QCPScatterStyle scatter(path);

用QPainterPath的好处是矢量绘制,缩放不失真,而且可以组合多个基本图形。用QPixmap的好处是可以直接用美术资源,适合需要品牌图标或者特殊标记的场景。

2.2 预定义形状的全家福与选型建议

QCustomPlot 内置的形状枚举大概有二十来种,我按使用频率和适用场景把它们分成几类:

形状枚举外观描述典型用途
ssDot单个像素点超大数据量、密集散点
ssCross十字线标记特殊数据点
ssPlus加号与 Cross 区分不同数据组
ssCircle空心圆通用散点,最常用
ssDisc实心圆强调数据点存在感
ssSquare空心方块分类数据标记
ssDiamond菱形与圆形区分数据组
ssTriangle三角形方向性数据标记
ssStar星形重点标注
ssCrossSquare方块内十字复合标记
ssPlusCircle圆内加号复合标记
ssPeace和平符号特殊趣味场景

选形状的核心原则是:同一张图上不同数据组之间要能一眼区分。如果你只有一组数据,ssCircle或ssDisc就够了;如果有三组数据,可以用ssCircle、ssSquare、ssTriangle来区分;如果数据组更多,光靠形状区分就不够了,得配合颜色。

还有一个细节:ssDot在数据量极大的时候性能最好,因为它本质上就是画一个像素,几乎不消耗绘制资源。但它的视觉存在感很弱,适合做背景密度图,不适合做需要精确读取数值的散点图。

2.3 尺寸、画笔、画刷的三角关系

散点的最终外观由三个属性共同决定:size、pen、brush。它们之间的关系不是简单的叠加,而是有优先级的。

size控制的是散点图形的外接矩形边长,单位是像素。对于圆形来说,size 就是直径;对于方形来说,size 就是边长。这个值设得太小,散点看不清;设得太大,密集区域会糊成一片。我的经验是:在 1080P 屏幕上,size 设在 6 到 12 之间比较合适,具体取决于数据点的密度。

pen控制描边。如果你把 pen 设为Qt::NoPen,那么无论形状是什么,都只会显示填充部分。反过来,如果 brush 设为Qt::NoBrush,那就只显示轮廓。这两个属性的组合可以产生很多变化:

// 实心无边框 scatter.setPen(Qt::NoPen); scatter.setBrush(QBrush(Qt::red)); // 空心有边框 scatter.setPen(QPen(Qt::blue, 2)); scatter.setBrush(Qt::NoBrush); // 半透明填充加边框 scatter.setPen(QPen(Qt::darkGreen, 1)); scatter.setBrush(QBrush(QColor(0, 255, 0, 80)));

半透明填充这个技巧在数据重叠比较多的场景下特别有用。当两个散点重叠时,半透明填充会让重叠区域颜色更深,你能直观地看出哪些区域数据更密集。

注意:QCPScatterStyle的setPen和setBrush只影响散点本身的绘制,不影响连接散点的线条。线条的样式要通过graph->setPen()来设置。这两个是独立的,初学者经常搞混。

3. 把散点样式挂到 Graph 上的完整操作链路

3.1 从零开始搭建一个散点图

光有QCPScatterStyle对象还不够,得把它关联到具体的QCPGraph上才能生效。完整的流程是这样的:

// 第一步:创建图表和坐标轴 QCustomPlot *plot = new QCustomPlot(this); plot->setInteractions(QCP::iRangeDrag | QCP::iRangeZoom); // 第二步:添加一个 Graph QCPGraph *graph = plot->addGraph(); graph->setName("实验数据"); // 第三步:准备数据 QVector<double> x(50), y(50); for (int i = 0; i < 50; ++i) { x[i] = i * 0.2; y[i] = qSin(x[i]) + (QRandomGenerator::global()->generateDouble() - 0.5) * 0.3; } graph->setData(x, y); // 第四步:设置散点样式 QCPScatterStyle scatter; scatter.setShape(QCPScatterStyle::ssCircle); scatter.setSize(8); scatter.setPen(QPen(Qt::darkBlue, 1.5)); scatter.setBrush(QBrush(QColor(100, 150, 255, 180))); graph->setScatterStyle(scatter); // 第五步:决定是否显示连接线 graph->setLineStyle(QCPGraph::lsNone); // 只显示散点,不显示连线 // 第六步:调整坐标轴范围并重绘 plot->xAxis->setRange(0, 10); plot->yAxis->setRange(-1.5, 1.5); plot->replot();

这段代码里最关键的一步是graph->setLineStyle(QCPGraph::lsNone)。如果你不设这一行,QCustomPlot 默认会画一条折线把散点连起来。很多时候我们只想要散点,不想要连线,就必须显式地把线型设为lsNone。

QCPGraph::lsNone表示不画线,lsLine表示画直线段,lsStepLeft、lsStepRight、lsStepCenter是阶梯线,lsImpulse是脉冲线(从零线到数据点的垂直线)。散点图通常配lsNone,但有时候配lsLine也能做出“带标记的折线图”效果,这个后面会细说。

3.2 多组数据叠加时的样式隔离

实际项目里经常需要在同一张图上画多组散点。这时候要注意,每个QCPGraph有自己独立的QCPScatterStyle,互不干扰。你可以这样组织代码:

struct DataSeries { QString name; QVector<double> x, y; QColor color; QCPScatterStyle::ScatterShape shape; }; QList<DataSeries> seriesList = { {"对照组", x1, y1, Qt::blue, QCPScatterStyle::ssCircle}, {"实验组A", x2, y2, Qt::red, QCPScatterStyle::ssSquare}, {"实验组B", x3, y3, Qt::green, QCPScatterStyle::ssTriangle} }; for (const auto &series : seriesList) { QCPGraph *g = plot->addGraph(); g->setName(series.name); g->setData(series.x, series.y); g->setLineStyle(QCPGraph::lsNone); QCPScatterStyle ss; ss.setShape(series.shape); ss.setSize(9); ss.setPen(QPen(series.color.darker(150), 1.5)); ss.setBrush(QBrush(series.color)); g->setScatterStyle(ss); }

这里有个小技巧:series.color.darker(150)用来生成比填充色更深的描边色,这样散点看起来有层次感,不会显得扁平。darker(150)的参数是百分比,150 表示比原色暗 50%。

3.3 图例与散点样式的联动

QCustomPlot 的图例默认显示的是 Graph 的线条样式。如果你把lineStyle设成了lsNone,图例里可能什么都不显示,或者只显示一个很小的标记。要让图例正确反映散点样式,需要确保scatterStyle在设置图例之前就已经配置好。

plot->legend->setVisible(true); plot->legend->setFont(QFont("Microsoft YaHei", 9)); plot->axisRect()->insetLayout()->setInsetAlignment(0, Qt::AlignTop | Qt::AlignRight);

图例中每个条目会显示一个小的散点图标加上 Graph 的名称。如果发现图例里的散点太小看不清,可以通过plot->legend->setIconSize()来调整图例图标的大小。这个细节在项目验收的时候经常被忽略,但用户第一眼看到的就是图例,所以值得花两分钟调一下。

4. 自定义散点样式:从 QPixmap 到 QPainterPath 的进阶玩法

4.1 用图片做散点标记的完整流程

内置形状虽然够用,但有时候项目需求就是要在散点上显示一个特定的图标,比如地图上的 POI 标记、设备状态图标、或者公司 Logo。这时候就得用QPixmap来自定义。

// 加载图片并缩放到合适大小 QPixmap marker(":/images/device_icon.png"); marker = marker.scaled(16, 16, Qt::KeepAspectRatio, Qt::SmoothTransformation); // 创建自定义散点样式 QCPScatterStyle customScatter(marker); graph->setScatterStyle(customScatter);

这里有几个坑要注意。第一,图片的尺寸最好提前缩放好,不要指望 QCustomPlot 帮你做高质量的缩放。scaled()函数用Qt::SmoothTransformation可以保证缩放质量,但如果你在replot()里反复调用缩放,性能会很差。正确的做法是在初始化阶段就把图片处理好,存成成员变量。

第二,图片的背景最好是透明的 PNG。如果图片有白色背景,散点叠在一起的时候会互相遮挡,看起来像一堆白色方块。用图像处理工具把背景抠掉,或者用QPixmap::setMask()在代码里做透明处理。

第三,图片散点的“锚点”是图片的中心。也就是说,数据点的坐标对应的是图片的中心位置,不是左上角。这个在精确定位的时候要心里有数。

4.2 QPainterPath 组合形状的实战

QPainterPath的灵活性比QPixmap更高,因为它是矢量绘制,而且可以在代码里动态生成。比如我要做一个“圆环加十字”的标记:

QPainterPath path; // 外圆 path.addEllipse(QPointF(0, 0), 5, 5); // 内部十字 path.moveTo(-3, 0); path.lineTo(3, 0); path.moveTo(0, -3); path.lineTo(0, 3); QCPScatterStyle pathScatter(path); pathScatter.setPen(QPen(Qt::darkRed, 1.2)); pathScatter.setBrush(Qt::NoBrush); graph->setScatterStyle(pathScatter);

用QPainterPath的时候,坐标原点是散点的中心。上面的代码画了一个半径 5 的圆,加上从 (-3,0) 到 (3,0) 和从 (0,-3) 到 (0,3) 的两条线段,形成一个瞄准镜一样的标记。

这种自定义路径的好处是,你可以根据数据的状态动态改变路径。比如正常数据点用实心圆,异常数据点用带叉的圆,只需要在绘制前重新生成QPainterPath并调用setScatterStyle()即可。

4.3 动态切换散点样式的时机与性能考量

在一个实时数据监控项目里,我遇到过这样的需求:数据点正常时显示绿色圆点,超过阈值时显示红色三角。最初的实现是在每次数据更新时遍历所有点,为每个点单独设置样式。但 QCustomPlot 的QCPGraph只支持一种散点样式,没法给单个数据点设置不同样式。

解决方案有两个。方案一是用两个 Graph 叠加:一个 Graph 画正常点,另一个 Graph 画异常点,两个 Graph 用不同的散点样式。数据更新时,把异常点从正常 Graph 的数据里剔除,加到异常 Graph 里。这个方案实现简单,但数据量大时会有额外的内存开销。

方案二是用QCPGraph::setScatterStyle()配合QCPScatterStyle的setPen和setBrush在每次replot()前动态调整。但这样只能整体切换,没法做到单点差异化。

方案三是放弃QCPGraph,改用QCPCurve或者直接继承QCPAbstractPlottable自定义绘制。这个方案最灵活,但工作量也最大。我的建议是:如果差异化需求不超过三四种状态,用方案一就够了;如果状态很多或者需要精细控制,再考虑方案三。

提示:QCPGraph的setScatterStyle()调用后需要replot()才能看到效果。如果在循环里频繁调用setScatterStyle()和replot(),性能会非常差。正确的做法是批量修改完所有样式后,只调用一次replot()。

5. 大数据量下的散点渲染性能与视觉平衡

5.1 什么时候散点图会变卡

QCustomPlot 的散点绘制是逐个点进行的,每个点都要走一遍 QPainter 的绘制流程。当数据点超过一万个时,replot()的耗时就会明显增加;超过十万个时,交互操作(缩放、拖拽)会变得卡顿。

我做过一个测试,在普通办公电脑上,不同数据量下的replot()耗时大致如下:

数据点数散点样式平均 replot 耗时
1,000ssCircle, size 8约 5ms
10,000ssCircle, size 8约 45ms
50,000ssCircle, size 8约 220ms
100,000ssDot约 180ms
100,000ssCircle, size 8约 450ms

从表里可以看出,ssDot在大数据量下的优势非常明显,因为它的绘制逻辑最简单。如果你的数据点超过五万个,而且不需要精确区分每个点的形状,优先考虑ssDot。

5.2 降采样与抽稀策略

如果数据量实在太大,又必须用散点图展示,那就得做降采样。最简单的做法是等间隔抽稀:

QVector<double> xRaw = /* 原始数据 */; QVector<double> yRaw = /* 原始数据 */; int step = qMax(1, xRaw.size() / 5000); // 最多保留5000个点 QVector<double> xSampled, ySampled; for (int i = 0; i < xRaw.size(); i += step) { xSampled.append(xRaw[i]); ySampled.append(yRaw[i]); } graph->setData(xSampled, ySampled);

等间隔抽稀的缺点是可能丢掉尖峰和异常值。更聪明的做法是保留每个区间内的最大值和最小值,这样既能压缩数据量,又能保留数据的包络特征。不过这个策略更适合折线图,散点图用等间隔抽稀通常就够了。

还有一个技巧是开启 QCustomPlot 的setAdaptiveSampling(true)。这个选项会让 QCustomPlot 在绘制时自动根据屏幕像素密度决定是否跳过某些点。对于折线图效果很好,但对于散点图,它可能会跳过一些视觉上重要的点,所以散点图场景下要谨慎使用。

5.3 视觉密度与可读性的平衡

散点图的一个常见问题是:数据点密集的区域糊成一团,看不出个体差异;数据点稀疏的区域又显得太空。解决这个问题有几个方向。

第一个方向是调整散点大小和透明度。数据量大的时候,把 size 调小,同时给 brush 加上 alpha 通道:

scatter.setSize(4); scatter.setBrush(QBrush(QColor(0, 100, 255, 60))); scatter.setPen(Qt::NoPen);

这样密集区域会因为透明度叠加而颜色更深,稀疏区域则颜色较浅,形成一种类似热力图的视觉效果。

第二个方向是使用ssDot配合颜色映射。根据数据点的某个属性(比如温度、压力)来设置不同的颜色,这样即使点很小,也能通过颜色看出分布规律。

第三个方向是分层绘制。把数据按密度分成几层,高密度区域用大一点、透明一点的散点,低密度区域用小一点、实心的散点。这个实现起来比较复杂,但在一些专业的数据分析软件里很常见。

6. 散点样式与其他图表元素的配合技巧

6.1 散点加误差棒:科研图表的标配

在实验数据展示中,散点往往需要配上误差棒来表示测量精度。QCustomPlot 没有内置的误差棒功能,但可以用QCPErrorBars类来实现:

QCPErrorBars *errorBars = new QCPErrorBars(plot->xAxis, plot->yAxis); errorBars->setData(plot->graph(0)->data()); errorBars->setErrorType(QCPErrorBars::etKeyError); // X方向误差 errorBars->setWhiskerWidth(6); errorBars->setPen(QPen(Qt::gray, 1));

QCPErrorBars需要关联到一个已有的 Graph 上,它会自动读取 Graph 的数据并绘制误差棒。etKeyError表示 X 方向的误差,etValueError表示 Y 方向的误差。误差棒的颜色通常比散点浅一些,避免喧宾夺主。

6.2 散点与折线的混合模式

有时候我们既想展示原始数据点,又想展示拟合曲线或趋势线。这时候可以用两个 Graph:一个 Graph 只画散点(lsNone+ scatterStyle),另一个 Graph 只画线(lsLine+scatterStyle设为ssNone)。

// 原始数据散点 QCPGraph *scatterGraph = plot->addGraph(); scatterGraph->setData(rawX, rawY); scatterGraph->setLineStyle(QCPGraph::lsNone); scatterGraph->setScatterStyle(QCPScatterStyle(QCPScatterStyle::ssCircle, 6)); // 拟合曲线 QCPGraph *fitGraph = plot->addGraph(); fitGraph->setData(fitX, fitY); fitGraph->setLineStyle(QCPGraph::lsLine); fitGraph->setScatterStyle(QCPScatterStyle(QCPScatterStyle::ssNone)); fitGraph->setPen(QPen(Qt::red, 2, Qt::DashLine));

这样散点和拟合线各司其职,图例里也会分别显示,读者能清楚地知道哪个是原始数据、哪个是拟合结果。

6.3 选中态与悬停态的散点反馈

QCustomPlot 支持交互操作,当用户点击某个散点时,我们可以改变它的样式来提供视觉反馈。实现方式是通过QCPGraph::selectTest()和selectionChanged()信号:

connect(plot, &QCustomPlot::selectionChangedByUser, this, [=]() { for (int i = 0; i < plot->graphCount(); ++i) { QCPGraph *g = plot->graph(i); if (g->selected()) { QCPScatterStyle ss = g->scatterStyle(); ss.setPen(QPen(Qt::red, 3)); g->setScatterStyle(ss); } } plot->replot(); });

这个逻辑是:当用户选中某个 Graph 时,把它的散点描边加粗变红。更精细的做法是只高亮被选中的那个数据点,但这需要自定义QCPAbstractPlottable的绘制逻辑,工作量会大不少。

7. 那些年我在 Scatter Style 上踩过的坑

7.1 散点不显示?先检查这三个地方

散点设置完了但屏幕上什么都没有,这是新手最常见的问题。排查顺序应该是这样的:

第一,检查lineStyle是不是lsNone且scatterStyle是不是ssNone。如果两个都是 None,那当然什么都不画。有时候代码里某处不小心把 scatterStyle 重置了,也会导致这个问题。

第二,检查数据是否为空。setData()传入了空向量,或者 x 和 y 的长度不一致,都会导致绘制异常。可以在setData()之后打印一下graph->dataCount()确认。

第三,检查坐标轴范围。数据点的坐标可能远远超出了当前坐标轴的显示范围。调用plot->rescaleAxes()让 QCustomPlot 自动调整范围,如果这样能看到散点,那就是范围设置的问题。

7.2 散点边缘锯齿与抗锯齿设置

默认情况下,QCustomPlot 的抗锯齿是关闭的,散点的圆形边缘会有明显的锯齿。开启抗锯齿很简单:

plot->setNotAntialiasedElements(QCP::aeNone);

或者更精细地控制:

plot->setAntialiasedElements(QCP::aePlottables);

开启抗锯齿后,散点的边缘会平滑很多,但渲染开销也会增加。如果数据量很大,可以考虑只在静态导出图片时开启抗锯齿,交互时关闭。

7.3 图例中散点图标过小的调整

前面提到过图例图标的问题,这里再展开说一下。QCustomPlot 的图例图标大小默认是跟随散点大小的,但如果散点 size 设得很小(比如 4),图例里的图标就会小得看不清。这时候需要单独设置图例的图标大小:

plot->legend->setIconSize(20, 20);

这个设置会让图例中每个条目左侧的图标区域变成 20x20 像素,散点会在其中居中绘制。注意这个设置不影响实际绘图区域中散点的大小,只影响图例。

7.4 导出图片时散点样式丢失的排查

用plot->savePng()导出图片时,有时候会发现散点样式和屏幕上看到的不一样,比如颜色偏差、大小不对。这通常是因为导出时的 DPI 和屏幕 DPI 不同导致的。解决方法是在导出前设置好缩放比例:

plot->savePng("output.png", 1920, 1080, 2.0);

最后一个参数是缩放因子,设为 2.0 表示以两倍分辨率导出。这样散点的像素尺寸会按比例放大,保持和屏幕上一致的视觉效果。

8. 一个完整的实战案例:多组实验数据散点对比图

8.1 需求拆解与数据结构设计

假设我们要做一个实验数据对比图,有三组数据:对照组、处理组A、处理组B。每组有 30 个样本,每个样本有两个测量值(X 和 Y)。要求:三组数据用不同的散点形状和颜色区分,图例清晰,支持缩放和拖拽,鼠标悬停时显示具体数值。

数据结构可以这样设计:

struct ExperimentGroup { QString name; QColor color; QCPScatterStyle::ScatterShape shape; QVector<double> xValues; QVector<double> yValues; };

8.2 完整代码实现与关键注释

void setupExperimentPlot(QCustomPlot *plot, const QList<ExperimentGroup> &groups) { plot->setInteractions(QCP::iRangeDrag | QCP::iRangeZoom | QCP::iSelectPlottables); plot->axisRect()->setRangeDrag(Qt::Horizontal | Qt::Vertical); plot->axisRect()->setRangeZoom(Qt::Horizontal | Qt::Vertical); // 设置坐标轴标签 plot->xAxis->setLabel("测量值 X"); plot->yAxis->setLabel("测量值 Y"); plot->xAxis->setLabelFont(QFont("Microsoft YaHei", 10)); plot->yAxis->setLabelFont(QFont("Microsoft YaHei", 10)); // 遍历每组数据,创建对应的 Graph for (const auto &group : groups) { QCPGraph *graph = plot->addGraph(); graph->setName(group.name); graph->setData(group.xValues, group.yValues); graph->setLineStyle(QCPGraph::lsNone); QCPScatterStyle scatter; scatter.setShape(group.shape); scatter.setSize(9); scatter.setPen(QPen(group.color.darker(160), 1.5)); scatter.setBrush(QBrush(group.color)); graph->setScatterStyle(scatter); // 设置选中时的样式 graph->setSelectionDecorator(new QCPSelectionDecorator); graph->selectionDecorator()->setScatterStyle( QCPScatterStyle(group.shape, group.color.lighter(120), group.color.darker(200), 12) ); } // 配置图例 plot->legend->setVisible(true); plot->legend->setFont(QFont("Microsoft YaHei", 9)); plot->legend->setBrush(QBrush(QColor(255, 255, 255, 200))); plot->legend->setBorderPen(QPen(Qt::gray, 1)); plot->axisRect()->insetLayout()->setInsetAlignment(0, Qt::AlignTop | Qt::AlignRight); // 自动调整坐标轴范围 plot->rescaleAxes(); plot->replot(); }

8.3 鼠标悬停显示数值的实现

鼠标悬停显示数值需要用到QCPItemTracer和QCPItemText两个辅助类:

// 在初始化时创建 tracer 和 text QCPItemTracer *tracer = new QCPItemTracer(plot); tracer->setStyle(QCPItemTracer::tsCircle); tracer->setSize(10); tracer->setPen(QPen(Qt::red, 2)); tracer->setBrush(Qt::NoBrush); tracer->setVisible(false); QCPItemText *tooltip = new QCPItemText(plot); tooltip->setLayer("overlay"); tooltip->setPadding(QMargins(6, 4, 6, 4)); tooltip->setBrush(QBrush(QColor(255, 255, 220, 230))); tooltip->setPen(QPen(Qt::gray)); tooltip->setVisible(false); // 连接鼠标移动信号 connect(plot, &QCustomPlot::mouseMove, this, [=](QMouseEvent *event) { double x = plot->xAxis->pixelToCoord(event->pos().x()); double y = plot->yAxis->pixelToCoord(event->pos().y()); // 找到最近的数据点 double minDist = std::numeric_limits<double>::max(); QCPGraph *nearestGraph = nullptr; double nearestX = 0, nearestY = 0; for (int i = 0; i < plot->graphCount(); ++i) { QCPGraph *g = plot->graph(i); for (int j = 0; j < g->dataCount(); ++j) { double dx = g->dataMainKey(j) - x; double dy = g->dataMainValue(j) - y; double dist = dx * dx + dy * dy; if (dist < minDist) { minDist = dist; nearestGraph = g; nearestX = g->dataMainKey(j); nearestY = g->dataMainValue(j); } } } // 如果距离足够近,显示 tooltip double threshold = 0.5; // 根据实际数据范围调整 if (nearestGraph && minDist < threshold * threshold) { tracer->setGraph(nearestGraph); tracer->setGraphKey(nearestX); tracer->setVisible(true); tooltip->position->setCoords(nearestX, nearestY); tooltip->setText(QString("(%1, %2)").arg(nearestX, 0, 'f', 2).arg(nearestY, 0, 'f', 2)); tooltip->setVisible(true); } else { tracer->setVisible(false); tooltip->setVisible(false); } plot->replot(); });

这段代码的核心逻辑是:把鼠标的像素坐标转换成数据坐标,然后遍历所有 Graph 的所有数据点,找到距离鼠标最近的那个点。如果距离小于阈值,就把 tracer 和 tooltip 移到那个点的位置并显示出来。

阈值0.5需要根据实际数据的坐标范围来调整。如果 X 轴范围是 0 到 100,0.5 的阈值就太小了,鼠标很难精确对准;如果范围是 0 到 1,0.5 又太大了,会误触发。一个更合理的做法是根据坐标轴的当前范围动态计算阈值,比如取范围宽度的 2%。

9. 散点样式的导出与打印适配

9.1 高分辨率导出时的样式保持

前面提过用savePng()的缩放参数来保持样式,这里再补充一个细节:导出 PDF 或 SVG 矢量图时,散点的QPixmap自定义样式会变成位图嵌入,放大后会模糊。如果项目需要矢量导出,自定义散点样式应该优先用QPainterPath而不是QPixmap。

// 矢量友好的自定义散点 QPainterPath starPath; // 用代码绘制一个五角星 double outerR = 6, innerR = 2.5; for (int i = 0; i < 10; ++i) { double r = (i % 2 == 0) ? outerR : innerR; double angle = M_PI / 2 + i * M_PI / 5; double px = r * qCos(angle); double py = -r * qSin(angle); if (i == 0) starPath.moveTo(px, py); else starPath.lineTo(px, py); } starPath.closeSubpath(); QCPScatterStyle starScatter(starPath); starScatter.setPen(QPen(Qt::orange, 1.5)); starScatter.setBrush(QBrush(Qt::yellow));

用QPainterPath绘制的散点在导出 SVG 时会保持矢量特性,无论放大多少倍都清晰。

9.2 打印场景下的颜色与对比度调整

屏幕上的颜色在打印到纸上时往往会变暗,特别是浅色和半透明色。如果图表需要打印,建议在打印前切换到一套高对比度的配色方案:

void prepareForPrint(QCustomPlot *plot) { // 背景设为白色 plot->setBackground(QBrush(Qt::white)); // 遍历所有 Graph,调整散点样式 for (int i = 0; i < plot->graphCount(); ++i) { QCPGraph *g = plot->graph(i); QCPScatterStyle ss = g->scatterStyle(); // 增大散点尺寸 ss.setSize(ss.size() * 1.3); // 加深描边 QPen pen = ss.pen(); pen.setColor(pen.color().darker(200)); pen.setWidthF(pen.widthF() * 1.2); ss.setPen(pen); // 填充色改为不透明 QBrush brush = ss.brush(); QColor c = brush.color(); c.setAlpha(255); brush.setColor(c); ss.setBrush(brush); g->setScatterStyle(ss); } plot->replot(); }

这个函数把散点尺寸放大了 30%,描边加深加粗,填充色改为完全不透明。这些调整在屏幕上看起来可能有点“重”,但打印出来效果正好。

10. 关于 Scatter Style 的一些个人体会

用了这么久 QCustomPlot 的散点样式,我最大的感受是:这个功能看起来简单,但真正用好需要同时考虑数据特征、视觉传达和性能约束三个维度。初学者往往只关注“怎么把点画出来”,而有经验的开发者会多想一步——“这样画出来的图,别人能不能一眼看懂”。

几个我觉得比较实用的经验:散点大小不要超过数据点间距的三分之一,否则密集区域会糊成一片;同一张图上不同数据组的散点形状差异要足够大,圆形和方形能区分,圆形和椭圆就很难区分;颜色不要超过五种,超过五种人眼就很难准确对应图例了;如果数据点超过一万个,优先考虑ssDot或者降采样,不要硬扛。

还有一个容易被忽略的点是散点的描边颜色。很多人直接用填充色作为描边色,结果散点看起来像一坨没有层次的颜色块。正确的做法是用darker()生成一个比填充色深 30% 到 50% 的描边色,这样散点会有轻微的立体感,视觉上更精致。

最后,如果你在做的是需要频繁交互的实时图表,记得把replot()的调用频率控制好。QCustomPlot 的replot()是同步的,频繁调用会阻塞 UI 线程。可以用QTimer做节流,比如每 50 毫秒最多重绘一次,这样既能保证流畅度,又不会让 CPU 跑满。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进