简介:资源包为QCustomPlot多坐标系功能的Qt示例工程,代码结构完整,演示了如何在绘图区域中创建多个坐标系、动态添加新的QCPAxisRect、共享X轴联动,并利用鼠标双击事件修改坐标系轴颜色。资源面向有一定Qt基础、希望实现复杂数据可视化布局的开发者,尤其适合需要同时对比多组量纲不同数据的场景,也适合研究QCustomPlot底层绘图机制的进阶学习者。压缩包共含10个文件,以cpp和h源码为主体,配合ui界面、pro工程配置和个人设置文件,整体仅241KB,可直接导入Qt Creator编译运行,便于快速定位核心逻辑。目前已有3880人学习/下载。工程内含完整的qcustomplot库代码、主窗口实现与界面设计,通过阅读可以掌握轴矩形管理、linkedAxes坐标轴绑定、鼠标双击事件监听与重绘调用的具体写法,并能在此基础上扩展更多交互控制功能,应用到自己的2D图表项目中。
1. QCustomPlot 多坐标系:为什么一个 QCustomPlot 控件能装下几套互不干扰的坐标
先抛一个可能会颠覆直觉的结论:在 QT 里用 QCustomPlot 做多坐标系,真正要拆的不是「坐标轴」,而是 axisRect、layout 和 layer 这三层对象之间的关系。很多人卡在“我明明 addAxis 了右侧轴,为什么刻度重叠”“我明明创建了第二个坐标系,为什么曲线还是画在同一个区域”。我最初做 Qt 数据看板时也被这两个问题拦住过:一个 QCustomPlot 实例里同时展示电压、电流、温度三路不同量纲的数据,必须让它们各走各的坐标系,否则曲线全部挤在一个坐标系里看不清。这篇笔记就围绕“qcustomplot 多坐标系”这个核心场景写下来,带你从拆解 QCustomPlot 的坐标系模型开始,最后落到可复现的多坐标系布局模板与排错套路,适合正在用 QT 做上位机数据展示、波形回放和多通道监控面板的人。
2. 坐标系模型拆解:axisRect、轴与 layer 的三层关系
2.1 坐标系的最小单元:axisRect 与四边轴
先建立认知:在 QCustomPlot 里,坐标系并不是一个叫 CoordinateSystem 的类,而是 QCPAxisRect 加上内部的若干条 QCPAxis。QCPAxisRect 负责划定一块矩形绘图区域,默认自带四条轴(上下左右),其中左轴和底轴可见,右轴和顶轴隐藏。很多人把“添加了右轴”误以为就是“添加了一个坐标系”,实际上只是往同一个 axisRect 里加了一条轴。真正决定一块绘图区域归属的,是 axixRect 这个矩形容器。
以我这个模拟项目里的双纵轴场景为例,右侧轴用来显示开关频率,左侧轴显示输出电压。创建右侧轴的常见做法是:
// 获取默认的 axisRect 0 QCPAxisRect *rect = customPlot->axisRect(0); // 在 rect 右侧添加一条新轴 QCPAxis *freqAxis = rect->addAxis(QCPAxis::atRight); // 把新轴范围设成独立的频率量程 freqAxis->setRange(0, 5000); // 添加一条只绑定右侧轴的曲线 QCPGraph *freqGraph = customPlot->addGraph(rect->axis(QCPAxis::atBottom), freqAxis); freqGraph->setPen(QPen(QColor(200, 80, 0)));这段代码的逻辑是:先拿到默认的 axisRect,也就是默认坐标系;再通过 addAxis 往这个坐标系里扩充一条右侧轴;最后 addGraph 时明确指定这条曲线的 x 轴是 rect 的底轴、y 轴是新加的 freqAxis。于是 freqGraph 就以右侧轴的 0~5000 频率刻度绘制,不会和左侧电压轴的数据混在一起。
这里值得重点说明两个参数:addAxis 的参数 atRight 表示轴的位置;addGraph 的两个参数分别指定 xAxis 和 yAxis,缺省会走 customPlot 的默认 xAxis/yAxis,这也是很多初学者把第二路曲线画到同一坐标系的原因。我习惯把这种绑定关系叫“轴绑定”,先定坐标轴,再定曲线,顺序不能反。
2.2 同一窗口多坐标系:layout grid 的角色
如果需求只是左右两根轴,上面的 axisRect 方案就够;但如果要在同一个 QCustomPlot 里放三个独立分区,比如主图显示波形、左下角显示频谱、右下角显示趋势,那就要依赖 plotLayout。QCustomPlot 的 layout 是一个 QCPLayoutGrid,默认只有一个格子,装着 axisRect(0)。想要多坐标系,最直接的方法是清空默认布局,重新往格子里分配 axisRect。
// 先把默认布局清空 customPlot->plotLayout()->clear(); // 创建两个 axisRect 作为两个独立坐标系区域 QCPAxisRect *rectTop = new QCPAxisRect(customPlot); QCPAxisRect *rectBottom = new QCPAxisRect(customPlot); // 左上角放 rectTop,左下角放 rectBottom customPlot->plotLayout()->addElement(0, 0, rectTop); customPlot->plotLayout()->addElement(1, 0, rectBottom); // 分别给不同坐标系设置范围 rectTop->axis(QCPAxis::atBottom)->setRange(0, 100); rectTop->axis(QCPAxis::atLeft)->setRange(-1, 1); rectBottom->axis(QCPAxis::atBottom)->setRange(0, 100); rectBottom->axis(QCPAxis::atLeft)->setRange(0, 1000);这里 clear() 相当于把默认坐标系移除,接下来 addElement 按行列位置塞入新建的 QCPAxisRect。QCPLayoutGrid 的索引从 (0,0) 开始,所以 (1,0) 就是第二行第一列。每个 axisRect 都是独立的世界,范围、网格、坐标轴互不影响。需要注意的是:new QCPAxisRect 时要传入 customPlot 指针,这样内部图层和坐标轴才会被正确挂载到控件图上;如果像 new QCPAxisRect() 这样不传,后续绘制可能会出现“坐标系没出现”的奇怪现象。
多坐标系布局的常用场景是“一主两辅”:主矩形占右上,左下列放缩略图。此时布局不再是一列,而要指定行列跨度。QCPLayoutGrid 支持 mergeCell 或直接 addElement 到多行多列,我常用做法是先 addElement 两个格子,再用 setColumnStretch 控制宽度比,避免两个坐标系挤在一起。
2.3 layer 与绘制顺序:坐标系重叠时的可见性控制
多坐标系真正让人头大的不是轴和刻度,而是坐标系重叠。比如我最早做“波形主图 + 半透明叠加坐标系”时,两个 axisRect 范围有交集,结果附加上去的曲线被主图层遮挡。这就是 layer 的问题。QCustomPlot 有一套自管理的 layer 机制,默认有 background、grid、main、axes、legend 等。每个 axisRect 内的数据曲线默认画在 main 层,网格画在 grid 层,轴和刻度画在 axes 层。
当两个 axisRect 区域重叠时,后绘制的内容会盖住先绘制的内容。图层排序并不严格按 axisRect 的 z 序走,而是按 layer 的上下顺序。如果你需要在主坐标系之上再画一个高亮层,常规做法是给新的 axisRect 指定独立图层,或者调整 axisRect 的 layer:
// 获取叠加坐标系的底层 layer QCPLayer *topLayer = customPlot->layer("main"); // 让第二个 axisRect 的内容绘制在 topLayer 上层 rectOverlay->setLayer(topLayer);setLayer 参数可以传 layer 名。QCustomPlot 内部默认支持 "main"、"grid"、"axes" 这类字符串标识。这里最容易踩的坑是:把 setLayer 理解成“设置透明度”,其实它只控制绘制顺序。半透明效果要通过 QCPGraph 的 pen 和 brush 的 alpha 通道实现,和 layer 无关。
图层在设计多坐标系时的价值是:它让你可以把“坐标网格”“曲线数据”“轴刻度”分开控制,比如叠加坐标系不需要网格时,直接关掉 rectOverlay->axis(QCPAxis::atLeft)->grid() 的可见性。这一层关系理解透了,后面做动态切换才会顺手。
3. 多坐标系落地:双纵轴子图与四坐标系布局的代码路径
3.1 从零搭一个电压电流双纵轴坐标系
先落到一个完整可复现的小例子:一个 QCustomPlot 里两条曲线共用一个时间轴 x,但 y 轴分左右,左侧显示电压 0~36V,右侧显示电流 0~12A。这种结构在电源测试和电池充放电监控里很常见,段代码可以直接搬进你的上位机工程。
// 初始化 QCustomPlot customPlot->setNotAntialiasedElement(QCP::aePlots, false); customPlot->setNotAntialiasedElement(QCP::aeGrid, true); // 第一步:拿到默认坐标系并加右侧轴 QCPAxisRect *rect = customPlot->axisRect(0); QCPAxis *voltAxis = rect->axis(QCPAxis::atLeft); QCPAxis *currAxis = rect->addAxis(QCPAxis::atRight); // 第二步:设置各自量程与颜色 voltAxis->setRange(0, 36); voltAxis->setLabel("Voltage (V)"); voltAxis->setTickLabelColor(QColor(40, 110, 40)); currAxis->setRange(0, 12); currAxis->setLabel("Current (A)"); currAxis->setTickLabelColor(QColor(180, 60, 60)); // 第三步:添加两条曲线并绑定各自的 y 轴 QCPGraph *voltGraph = customPlot->addGraph(rect->axis(QCPAxis::atBottom), voltAxis); QCPGraph *currGraph = customPlot->addGraph(rect->axis(QCPAxis::atBottom), currAxis); // 第四步:设置曲线样式便于区分 voltGraph->setPen(QPen(QColor(40, 110, 40), 2)); currGraph->setPen(QPen(QColor(180, 60, 60), 2));代码的核心点在第三步:addGraph 的参数不是空的,而是显式传入了底轴和对应的 y 轴。这里 voltAxis 是默认左轴,currAxis 是新建的右轴,两条曲线就这样“分道扬镳”了。第四步给曲线设置不同的颜色和线宽,是为了在左右刻度重叠时能肉眼快速区分。
实际使用里,我会把量程设置抽成函数,比如 setVoltRange(float min, float max),因为设备上电瞬间电压可能冲到 40V,在波形显示里不提前留量程,曲线直接裁掉。右侧电流轴刻度颜色和曲线颜色保持一致,是工程上比较推荐的标识习惯,看刻度颜色就能知道曲线归哪一侧。
3.2 四坐标系网格布局:一个控件呈现四路监控数据
接着做重头戏:一个 QCustomPlot 放 4 个独立坐标系,排成 2x2 网格,每个坐标系独立显示一路传感器曲线。这个结构做温湿度、多路压力、电机四象限监控时很实用。QCustomPlot 的 plotLayout 本身就是网格布局,直接用 addElement 按行列填入新 axisRect 即可。
// 清空默认布局 customPlot->plotLayout()->clear(); // 建立 2x2 网格指针数组 QCPAxisRect *rects[2][2]; for (int row = 0; row < 2; ++row) { for (int col = 0; col < 2; ++col) { rects[row][col] = new QCPAxisRect(customPlot); customPlot->plotLayout()->addElement(row, col, rects[row][col]); // 各坐标系独立范围:左上 0~10,右上 0~100,左下 0~1000,右下 0~10000 double maxValue = std::pow(10.0, row * 2 + col + 1); rects[row][col]->axis(QCPAxis::atLeft)->setRange(0, maxValue); rects[row][col]->axis(QCPAxis::atBottom)->setRange(0, 60); // 给每个坐标系添加一条模拟数据曲线 QCPGraph *graph = customPlot->addGraph( rects[row][col]->axis(QCPAxis::atBottom), rects[row][col]->axis(QCPAxis::atLeft)); graph->setPen(QPen(QColor(30 + row * 60, 100, 200 - col * 40), 2)); } }这段代码的好处是:把四个坐标系的创建收敛到同一个双重循环里,后续想改成 3x3 网格,只需要改数组维度和行列上限。坐标轴 setRange 用来模拟不同量级的数据范围,真实使用时直接把 range 替换成传感器量程即可。addGraph 时关联的 axis 必须从 rects 对应坐标系内取,千万不能误写成 customPlot->xAxis,否则曲线又会画到默认坐标系上。
二维指针数组初始化时,每个元素都要 new QCPAxisRect 并 addElement,这个顺序不能反。addElement 之前如果 new 出来的 axisRect 还没挂进 layout,后续给它加曲线是看不到的。有个地方容易忽略:plotLayout()->clear() 会把默认坐标系 axisRect(0) 一并清掉,如果后续又要用默认坐标系,得重新创建一个 QCPAxisRect 放回去。
3.3 动态切换:显示、隐藏与替换坐标系的运行时操作
多坐标系不是建完就完事,实际用到的是切换。比如同一窗口里预设三套坐标系布局:单图模式、双轴模式、四图模式,用户点按钮切换。我采用的思路是先创建好所有 axisRect,运行时控制各 axisRect 的可见性和布局占用,而不是每次重新 new 再 clear。
// 预设四个 axisRect,其中两个先隐藏 QCPAxisRect *rect1 = new QCPAxisRect(customPlot); QCPAxisRect *rect2 = new QCPAxisRect(customPlot); QCPAxisRect *rect3 = new QCPAxisRect(customPlot); customPlot->plotLayout()->clear(); customPlot->plotLayout()->addElement(0, 0, rect1); customPlot->plotLayout()->addElement(1, 0, rect2); // 切换到双列模式:把 rect3 放到第一行第一列 void switchToDoubleColumnMode() { customPlot->plotLayout()->clear(); customPlot->plotLayout()->addElement(0, 0, rect1); customPlot->plotLayout()->addElement(0, 1, rect2); customPlot->plotLayout()->addElement(1, 0, rect3); customPlot->plotLayout()->addElement(1, 1, new QCPAxisRect(customPlot)); customPlot->replot(); }这里有个隐藏问题:clear() 会移除布局里的所有元素,但 axisRect 对象本身还在,只要指针没丢,还能再 addElement 回来。所以要保留 rect1、rect2、rect3 这类成员指针,不能用完就丢。运行时切换往往伴随数据重绘,QCustomPlot 的 replot() 是异步的,切换布局后必须调用一次才能看到新坐标系。若你的 QT 版本较老,还可以用 customPlot->replot(QCustomPlot::rpQueuedReplot) 防止高频切换时卡顿。
动态切换时的致命细节是:隐藏的 axisRect 是否还要继续接收数据。我一般做法是数据照常写入,只是不显示,这样切回来时曲线立刻可见,不用再重新取历史数据。若内存紧张,也可以配合 setVisible 关掉隐藏坐标系和对应图层。工程上我倾向“数据常驻 + 显示切换”,省去缓存队列的复杂度。
4. 常见问题排查:刻度堆叠、坐标对不上、缩放错乱的三个现场
4.1 现象一:右轴刻度与左轴刻度重叠,几乎没法看
现象:添加右侧轴后,右轴刻度标签直接压在绘图区右侧,甚至和左边曲线重叠。出现这个问题的原因通常是:新增的右轴没有指定 margin 或 padding,QCustomPlot 默认的 axisRect 边距只给了一条可见轴的刻度空间,右侧再挤一条轴进来就会叠在一起。
解决:调大 axisRect 的右边距,给右轴单独留出空间。做法是在创建右轴后:
rect->setMargins(QMargins(50, 30, 80, 30));这里 80 是我根据右侧刻度宽度试出来的值,刻度数字位数为 3~4 位时足够。如果右侧刻度很长,比如 100000,需要进一步加到 100 左右,否则新刻度依旧会被截断。另有一个隐蔽原因:曲线绑定的轴错误,导致左轴范围被右侧轴数据撑大,刻度标签显示混乱。排查时可以先打印两条曲线的 valueAxis 范围,再确认 setRange 是否生效。
4.2 现象二:新建的第二个坐标系区域是空的,曲线不显示
现象:已经 addElement 把 rectBottom 放进布局,也加了一条曲线,但运行时底部区域空白。原因多半出在 addGraph 的轴绑定:曲线绑定的 axis 取自 customPlot 默认轴,而不是 rectBottom 里的轴。第二种原因则常见于 new QCPAxisRect 时没有传入 customPlot 指针,导致该坐标系没有加入图层绘制队列。
解决:把绑定关系明确改成本坐标系的轴:
QCPGraph *g = customPlot->addGraph( rectBottom->axis(QCPAxis::atBottom), rectBottom->axis(QCPAxis::atLeft) ); g->setData(xData, yData); customPlot->replot();同时检查 new QCPAxisRect(customPlot) 这个传参。我在排查这种问题时,会先在每个坐标系里画一个填充矩形,比如用 QCPCurve 放一条对角直线,能立刻确认坐标系本身是否可见。如果直线可见而曲线不可见,问题就锁死在曲线数据或轴绑定上。
4.3 现象三:用户缩放/平移时,多坐标系之间的 x 轴不同步甚至错位
现象:主坐标系 x 范围缩小到 0~50,但副坐标系 x 轴还停留在 0~100,两图对不上,看起来像数据漂移。原因:QCustomPlot 的每个轴都有独立的 range,缩放操作只作用于被交互的坐标系,不会自动带动其他坐标系。
解决:如果希望多个坐标系共享同一个 x 视野,我一般把主坐标系的 rangeChanged 信号连到同步函数,并把副坐标系的交互关掉:
connect(customPlot->xAxis, qOverload<const QCPRange &>(&QCPAxis::rangeChanged), this, &ChartWindow::syncXRange); void ChartWindow::syncXRange(const QCPRange &newRange) { rectTop->axis(QCPAxis::atBottom)->setRange(newRange); rectBottom->axis(QCPAxis::atBottom)->setRange(newRange); customPlot->replot(); }qOverload 是为了匹配 QCPAxis::rangeChanged 的重载形式。这个信号在新老版本里都有,但写法略有差异,老版本不需要 qOverload,直接 &QCPAxis::rangeChanged 即可。同步频率不要太高,可配合一个 QTimer 做 30ms 节流,否则拖动坐标轴时光标会一卡一顿。这里还有个反向坑:如果副坐标系自身开启了缩放,用户拖拽副坐标系时又会反过来触发主坐标系,形成信号环。最省心的做法是副坐标系只做显示,禁用所有用户交互。
4.4 现象四:坐标系重叠后,点击和缩放永远作用在最上层
现象:两个坐标系区域存在重合,鼠标点击时高亮出来的总是后创建的那个,底层坐标系无法选中,也无法缩放。原因:QCPAxisRect 的碰撞测试按图层顺序从上往下匹配,后 addElement 的坐标系默认在更上层。
解决:最直接的办法是不让坐标系重叠,用布局物理分开;如果必须重叠,就把被遮挡坐标系的交互关闭,只做静态显示,用 setSelectionRect 限定点击区域,或者用一层透明 QCPAxisRect 拦截事件后根据鼠标坐标换算到对应坐标系,再做 range 调整。这种重叠叠加坐标系适合做“数据背景层”,不适合做交互层,我后期做示波器 BG 显示时就直接禁用叠加层交互。
5. 进阶验证:坐标往返与视图同步,把多坐标系玩成可调试工具
多坐标系做完后,真正要验证的是“坐标转换是否自洽”。我常用一个办法:在某个坐标系里取一个点,通过 axis 的 coordToPixel 转成屏幕像素,再通过另一个坐标系的 pixelToCoord 换回数据坐标,如果两次结果一致,说明坐标系的范围和轴绑定没有错乱。这个技巧在做鼠标跟踪十字线和多坐标系联动时非常有用。
// 取主坐标系一个数据点 (10.0, 3.5) double mainX = 10.0; double mainY = 3.5; // 转成主坐标系的像素位置 double mainPixelX = customPlot->xAxis->coordToPixel(mainX); double mainPixelY = customPlot->yAxis->coordToPixel(mainY); // 再把像素位置映射到副坐标系的数据坐标 double subX = rectTop->axis(QCPAxis::atBottom)->pixelToCoord(mainPixelX); double subY = rectTop->axis(QCPAxis::atLeft)->pixelToCoord(mainPixelY); // 如果 subX 和 mainX 一致,说明两个坐标系共享同一像素空间 qDebug() << "X:" << mainX << " -> " << subX;coordToPixel 与 pixelToCoord 是一对往返函数,它们不会受坐标系可见性影响。即使某个坐标系隐藏了,也可以用它做命中检测和坐标映射。我调试多坐标系布局时,会先用这个函数把所有坐标系的边界换算一遍,写进日志里,能快速发现轴绑定不对称、取值范围翻倍这类隐蔽问题。
视图同步还有一种更省事的做法:直接让两个坐标系共享同一个轴对象。QCPAxis 只能属于一个 axisRect,但可以把副坐标系底轴的范围绑定为主坐标系底轴,从信号层面保证永远一致。与之相比,设置常见的 align 属性需要小心:QCPAxis::setScaleRatio 是固定坐标轴之间的像素缩放比,比较适合做百分比同比,不适合做数据同步。两套方案分场景用。
从那以后,我每次搭多坐标系模板时,都会强制走一遍验证流程:先拆 axisRect 和轴绑定,再检查图层遮挡,最后用 coordToPixel/pixelToCoord 做一次坐标往返自测。这套流程帮我挡掉过不少“波形看起来对,实际数据全错”的潜在问题——希望这篇记录也能帮到你,拿到对应的 QCustomPlot 多坐标系参考包时,少走我踩过的那些弯路。
本文还有配套的精品资源,点击获取