ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MFC自绘图表完全指南:GDI坐标映射与曲线/柱状/饼图实现

MFC自绘图表完全指南:GDI坐标映射与曲线/柱状/饼图实现 简介一份基于MFC类库编写的图表绘制源码工程面向熟悉C基础语法、希望进阶Windows GUI开发的学习者也可作为高校《Visual C程序设计》课程设计或软件工程师快速实现数据可视化的参考。它围绕CDC设备上下文与GDI绘图机制示范了曲线图、饼状图、柱状图的完整绘制过程包括数据点坐标换算、扇形角度计算、矩形柱体布局以及OnDraw视图刷新等关键环节能帮助使用者跳过繁琐的环境搭建和接口摸索。压缩包共37个文件整体大小约26.51MB主要包含9个h头文件、8个cpp源文件其余为rc资源脚本、图标、工具栏位图及Visual Studio工程配置文件结构完整可用Visual Studio直接打开并编译运行。资源中Graph、GraphSeries、GraphLegend等模块分别承担图形绘制、数据序列管理和图例绘制职责适合对照源码理解MFC图形编程框架。目前已有857人学习下载对于需要系统掌握MFC绘图思路的开发者来说是一份值得阅读的实战示例。1. 折腾过自绘图表的人才懂 DrawGraph 这套源码的价值MFC 工程里要画曲线图、饼状图、柱状图第一反应是找 ChartCtrl 这类第三方控件。但做过工控上位机的人都知道第三方图表库要么版本老在各版本 VS 之间编译报错要么定制需求一来改源码比重画还痛苦。DrawGraph 这类自绘源码的价值正在这图表本质是 GDI 绘图函数的组合搞清坐标系和绘制流程自维护代码的长期成本远低于第三方依赖。这套源码适合手上有 MFC 维护任务、要把数据快速可视化的工程师。温度曲线、产量对比、占比饼图核心就是三件事坐标换算、设备上下文管理、图形学基础。拆开学透后续改样式只是加函数。下文按坐标映射、曲线图、统计图、工程集成四段展开每段给出可直接抄进工程的代码和参数说明。2. MFC 自绘图表的第一步逻辑坐标与设备上下文的映射所有图表库的底层都是同一个问题把数据域映射到屏幕上一块矩形区域。比如温度 0100℃ 的数据要显示在客户区扣除边距后的矩形内。坐标映射做错了曲线会被裁剪柱状图会底部错位饼图会偏移出绘图区而且不同图表现还不一样排查起来很费时间。2.1 数据坐标到设备坐标的换算公式2.1.1 先留出绘图区边距再写转换函数GDI 的 SetMapMode 可以把映射模式设为 MM_ISOTROPIC、MM_ANISOTROPIC让系统自动缩放。实际工程里我一般不用原因有两个坐标轴刻度、网格线、文本标签都要在设备坐标下精确定位开了映射模式后所有文字位置都得跟着反向换算MM_ANISOTROPIC 的非等比缩放会改变线条宽度画出来的边框粗细不一。常见做法是自己维护一个 RECT 表示绘图区写两个可逆的转换函数。// 数据坐标转设备坐标xData/yData 是原始数据值 // rcPlot 是绘图区矩形dMinX/dMaxX/dMinY/dMaxY 是当前数据范围 CPoint DataToDevice(double xData, double yData, const CRect rcPlot, double dMinX, double dMaxX, double dMinY, double dMaxY) { CPoint pt; // x 方向数据范围线性平铺到绘图区宽度 pt.x (int)(rcPlot.left (xData - dMinX) / (dMaxX - dMinX) * rcPlot.Width()); // y 方向屏幕坐标向下为正最大值要映射到绘图区顶部 pt.y (int)(rcPlot.bottom - (yData - dMinY) / (dMaxY - dMinY) * rcPlot.Height()); return pt; }关键在第 8 行的 y 方向处理数学坐标 y 向上为正屏幕坐标 y 向下为正所以用rcPlot.bottom减去偏移量否则曲线上下颠倒。x 方向直接把数据范围平铺到绘图区宽度上调用时要注意(dMaxX - dMinX)不能为 0设置数据范围的地方要做保护。rcPlot 一般在 OnSize 里根据客户区减去左边距 50、下边距 30、上边距 10 得到工具栏和状态栏占掉的高度才不会压到图形。2.1.2 用 1/2/5 序列算出好看的刻度步长直接拿 min/max 当刻度边界画出来的刻度线常常是 37.5、84.2 这种难读的值。常见的做法是固定用 1、2、5 乘以 10 的幂次作为候选步长选一个最接近目标值的// 根据数据范围计算规整的刻度步长使刻度落在 1/2/5 的整数倍上 double CalcNiceStep(double dRange, int nTargetTicks) { double dRaw dRange / nTargetTicks; // 原始步长 double dMag pow(10.0, floor(log10(dRaw))); // 取数量级 double dNorm dRaw / dMag; // 归一化到 [1,10) double dStep; if (dNorm 1.5) dStep 1.0; else if (dNorm 3.0) dStep 2.0; else if (dNorm 7.0) dStep 5.0; else dStep 10.0; return dStep * dMag; }得到步长后刻度下限取floor(dMin/dStep)*dStep上限取ceil(dMax/dStep)*dStep网格线就能对齐到整数。几个典型输入输出数据范围 dRange目标刻度数返回步长实际刻度1005200,20,40,60,80,100374100,10,20,30,400.8540.20,0.2,0.4,0.6,0.8这套取整逻辑同时适用于横轴和纵轴。曲线图两个方向都用柱状图只需要纵轴饼图不涉及。2.2 双缓冲绘制WM_PAINT 里不闪烁的标准写法OnPaint 里直接画曲线窗口一刷新就闪原因是背景擦除和绘制不同步。标准做法是先在内存 DC 上画完再一次 BitBlt 到屏幕void CChartView::OnPaint() { CPaintDC dc(this); // 客户区 DC CRect rcClient; GetClientRect(rcClient); // 内存 DC 与兼容位图位图大小跟随客户区 CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bmp; bmp.CreateCompatibleBitmap(dc, rcClient.Width(), rcClient.Height()); CBitmap* pOldBmp memDC.SelectObject(bmp); // 先填背景避免内存 DC 初始内容不确定 memDC.FillSolidRect(rcClient, RGB(255, 255, 255)); DrawGrid(memDC, rcClient); // 画网格和刻度 DrawData(memDC, rcClient); // 画曲线/柱状/饼状 // 一次性拷贝到屏幕消除闪烁 dc.BitBlt(0, 0, rcClient.Width(), rcClient.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); // 恢复旧对象位图析构才安全 }CreateCompatibleBitmap 创建的位图和屏幕 DC 色深一致直接画不丢色。SelectObject 后旧的 GDI 对象必须保存并在结束前恢复否则 CBitmap 析构时因为对象还被选中会失败。DrawGrid 和 DrawData 接收 CDC 指针绘制逻辑可以复用在同一套代码上做报表打印时把 DC 换成打印机 DC 即可。注意WM_ERASEBKGND 默认会被系统响应并擦掉客户区这是闪烁的另一半来源工程集成阶段要在消息映射里处理掉见第 5 章。3. 曲线图自绘实现数据容器、网格与折线绘制曲线图是三个图里最常用的坐标映射问题也暴露得最充分。实时趋势、历史回放、频率分析本质都是把一组 (x, y) 序列按顺序连成折线。3.1 数据容器存原始数据不存屏幕坐标一个容易踩的坑把屏幕坐标 CPoint 存进数组绘制时用 MoveTo/LineTo 连起来。窗口一 Resize整条曲线错位因为屏幕坐标跟着客户区大小变了。正确做法是存原始 double 数据绘制时才换算class CCurveData { public: CArraydouble, double m_arrX; // X 采样点存原始数据 CArraydouble, double m_arrY; // Y 采样值 COLORREF m_clrLine; // 曲线颜色 int m_nPenWidth; // 线宽默认 2 BOOL m_bVisible; // 显示/隐藏开关 void AddPoint(double x, double y) { m_arrX.Add(x); m_arrY.Add(y); } int GetCount() const { return (int)m_arrX.GetCount(); } };用 CArray 而不是 std::vector一是保持 MFC 工程风格统一二是数据可以配合 CArchive 直接序列化保存历史数据到文件时不用手写循环。m_bVisible 用来做多曲线显隐同时画温度和湿度两条曲线时点图例切换对应项的可见性重新 Invalidate 即可。追加数据用 AddPoint绘制函数在数据变化后调用 Invalidate(FALSE) 触发重绘。3.2 网格、刻度与折线的具体绘制绘制顺序固定为三步网格 → 刻度文字 → 折线。网格用浅灰色细线折线用 2 像素实线层次才清楚void CChartView::DrawCurve(CDC* pDC, const CRect rcPlot) { // 第一步画 5x5 网格浅灰色 1px 线 CPen penGrid(PS_SOLID, 1, RGB(220, 220, 220)); CPen* pOldPen pDC-SelectObject(penGrid); for (int i 0; i 5; i) { int x rcPlot.left i * rcPlot.Width() / 5; int y rcPlot.top i * rcPlot.Height() / 5; pDC-MoveTo(x, rcPlot.top); pDC-LineTo(x, rcPlot.bottom); // 纵向网格 pDC-MoveTo(rcPlot.left, y); pDC-LineTo(rcPlot.right, y); // 横向网格 } // 第二步把数据点全部换算成屏幕坐标再一次性画折线 CPen penCurve(PS_SOLID, 2, RGB(0, 102, 204)); pDC-SelectObject(penCurve); CArrayCPoint, CPoint arrPts; CCurveData data m_curve[0]; for (int i 0; i data.GetCount(); i) { arrPts.Add(DataToDevice(data.m_arrX[i], data.m_arrY[i], rcPlot, m_dMinX, m_dMaxX, m_dMinY, m_dMaxY)); } if (arrPts.GetCount() 1) pDC-Polyline(arrPts.GetData(), (int)arrPts.GetCount()); pDC-SelectObject(pOldPen); }Polyline 一次调用画完整条折线比逐段 MoveTo/LineTo 性能高数据点上万时差异明显。m_dMinX/m_dMaxX 这些范围值在 SetData 时算好缓存成成员不要每次绘制都重新扫描数组。多曲线时除了颜色区分还可以换线型线型常量值说明与限制PS_SOLID0实线线宽任意默认推荐PS_DASH1虚线仅线宽 1 时生效PS_DOT2点线仅线宽 1 时生效PS_DASHDOT3点划线仅线宽 1 时生效注意PS_DASH、PS_DOT 在线宽大于 1 时会被 Windows 忽略画出来仍是实线。要加粗又要区分线型就叠加颜色或把每个数据点画成小圆点标记。3.3 实时追加时的刷新节奏控制实时曲线最常见的问题数据点超过绘图区宽度后x 轴映射把后续点画到区域外。两种处理方案滑动窗口只显示最近 N 个点或 x 轴范围随数据自动右移。滑动窗口用取模下标覆盖写入i % N定位写入位置绘制时从最新点向前取 N 个不涉及内存搬移。刷新频率是另一个隐藏坑。OnTimer 里每 10ms 追加一个点但 Invalidate 不要跟着每 10ms 调一次GDI 绘制吞吐跟不上CPU 直接拉满。常见做法是数据按 10ms 累计界面每 100200ms 批量刷新一次肉眼看曲线依然连续CPU 占用能低一个量级。这个采样快、绘制慢的节奏控制是实时曲线平滑度和资源占用之间的关键平衡。4. 柱状图与饼状图从矩形填充到扇区角度计算柱状图和饼状图的坐标处理比曲线图简单难点在布局柱状图的柱宽、柱间距、分组柱偏移饼状图的累计角度和百分比标注。这两类图的数据量通常不大绘制开销主要在 GDI 对象切换上。4.1 柱状图的柱宽、间距与分组柱布局柱状图的经典错误是柱子贴满绘图区宽度之间没有间隔。正确做法是把可用宽度按组数 × 组宽分配组内再按柱数细分void CChartView::DrawBar(CDC* pDC, const CRect rcPlot) { int nCount (int)m_arrBarValue.GetSize(); // 柱子总数 int nGroupCount (int)m_arrGroups.GetSize(); // 每组柱数单组时 1 int nGroups nCount / nGroupCount; // 分组数 int nGap 8; // 组间间距像素 // 每组可用宽度 (绘图区宽 - 总组间距) / 组数 int nWidth (rcPlot.Width() - nGap * (nGroups - 1)) / nGroups; int nBarW nWidth / nGroupCount - 4; // 单根柱宽组内留 4px CBrush br(RGB(52, 120, 246)); for (int i 0; i nCount; i) { int nGroup i / nGroupCount; // 属于第几组 int nInGroup i % nGroupCount; // 组内第几根 // 组起点 绘图区左 组号 * 组宽组内再加偏移 int xLeft rcPlot.left nGroup * nWidth nInGroup * (nBarW 4) 2; // 柱高按数据值占纵轴范围的比例计算从底部向上 int nH (int)(rcPlot.Height() * m_arrBarValue[i] / m_dMaxBarValue); int yTop rcPlot.bottom - nH; pDC-FillRect(CRect(xLeft, yTop, xLeft nBarW, rcPlot.bottom), br); // 柱顶数值标签y 方向预留 18px 给文字 CString str; str.Format(_T(%.1f), m_arrBarValue[i]); pDC-TextOut(xLeft nBarW / 2 - 12, yTop - 18, str); } }m_dMaxBarValue 取数据最大值的 1.1 倍给柱顶标签留空间否则文字被裁剪。分组柱适合同比、环比场景比如 2023 和 2024 的产量并排nGroupCount 2两组柱子用不同颜色区分。TextOut 居中用了固定偏移 12px严格做法是先 GetTextExtent 量文本宽度再居中数据位数变化时才不会偏。y 轴默认从 0 开始但数据全在 50005100 时柱子高度几乎没差异这时把 dMinY 设成 4900 能放大差异但刻度上必须明确标注纵轴非零起点避免误导。柱状图常见问题集中在这几类常见问题现象处理方式柱宽写死窗口拉宽后柱子挤在左侧柱宽按绘图区宽度动态计算数据含负值柱子向下溢出绘图区绘制前按正负分段或调整纵轴范围总间距算错最后一根柱被裁剪先用总宽减间距再分组均分4.2 饼状图的累计角度、扇区填充与百分比标签饼状图核心是 GDI 的 Pie 函数它接收外接矩形和起始、结束两个点而不是角度。角度按数学约定 0 度在三点方向、逆时针为正但屏幕 y 向下计算点坐标时 sin 要取负void CChartView::DrawPie(CDC* pDC, const CRect rcPlot) { // 饼图区域取绘图区中心的最大内接正方形 int nSide min(rcPlot.Width(), rcPlot.Height()); CRect rcPie(rcPlot.CenterPoint().x - nSide / 2, rcPlot.CenterPoint().y - nSide / 2, rcPlot.CenterPoint().x nSide / 2, rcPlot.CenterPoint().y nSide / 2); CPoint ptCenter rcPie.CenterPoint(); int nRadius nSide / 2 - 20; // 半径留 20px 给外部图例 // 先求总和后续按比例算扇区角度 double dSum 0.0; for (int i 0; i (int)m_arrPieValue.GetSize(); i) dSum m_arrPieValue[i]; if (dSum 0.0) return; // 全 0 直接返回 double dAngle 0.0; // 当前扇区起始角弧度 for (int i 0; i (int)m_arrPieValue.GetSize(); i) { double dSweep m_arrPieValue[i] / dSum * 2 * 3.1415926; // 屏幕 y 向下sin 取负才是常规顺时针排布 CPoint ptStart((int)(ptCenter.x nRadius * cos(dAngle)), (int)(ptCenter.y - nRadius * sin(dAngle))); CPoint ptEnd((int)(ptCenter.x nRadius * cos(dAngle dSweep)), (int)(ptCenter.y - nRadius * sin(dAngle dSweep))); CBrush br(m_arrPieColor[i]); // 每块扇形独立颜色 CBrush* pOld (CBrush*)pDC-SelectObject(br); pDC-Pie(rcPie, ptStart, ptEnd); pDC-SelectObject(pOld); // 百分比文字画在扇区角平分线方向半径 60% 处 double dMid dAngle dSweep / 2.0; int nx (int)(ptCenter.x nRadius * 0.6 * cos(dMid)); int ny (int)(ptCenter.y - nRadius * 0.6 * sin(dMid)); CString str; str.Format(_T(%.1f%%), m_arrPieValue[i] / dSum * 100.0); pDC-TextOut(nx, ny, str); dAngle dSweep; } }最容易错的是 y 方向取负ptCenter.y - nRadius * sin(...)。屏幕 y 向下不取负的话饼图走向会反。nRadius 应该由 rcPie 动态算出而不是写死窗口缩放时才不会把扇区画出界。标签放在 0.6 倍半径处既避开圆心重叠又不会压到扇区边缘。Pie 函数的契约是起始角累加不超过 2πdSum 归一化之后天然满足某个值占比极小如 0.1% 时扇区画出来是一条细线分类超过 10 个时建议把占比小于 5% 的合并成其他一项图例里再展开明细。5. 把 DrawGraph 接进工程自适应重绘与命中检测5.1 WM_SIZE 里重算绘图区WM_ERASEBKGND 返回 TRUEOnSize 在窗口尺寸变化时触发绘图区 rcPlot 应该在这里重算并缓存成成员变量。不要每次 OnPaint 里临时算否则工具栏、状态栏占掉的高度没扣除曲线底部被截断。绘图区由客户区减去左 50、下 30、上 10 的边距得到这块逻辑集中在一个成员函数 UpdatePlotRect 里OnSize 调用它再 Invalidate。WM_ERASEBKGND 的处理函数直接return TRUE告诉系统背景已经处理过配合 OnPaint 里的 FillSolidRect 统一擦除。注意两点OnPaint 必须无条件先 FillSolidRect否则残留上一帧内容窗口缩放会频繁触发擦除和重绘双缓冲已保证不闪但位图不要每次 OnPaint 都创建销毁把 CBitmap 做成成员变量只在客户区尺寸变化时重建能省掉大量内存分配和 GDI 句柄切换。5.2 柱状图命中检测从鼠标位置反查柱子交互需求常见的场景是鼠标点哪根柱就在哪显示明细。实现思路是绘制时把每根柱子的实际 CRect 存进 CArray鼠标移动时做矩形命中判断void CChartView::OnMouseMove(UINT nFlags, CPoint point) { // m_arrBarRects 在 DrawBar 里同步填充索引与柱子一一对应 for (int i 0; i (int)m_arrBarRects.GetSize(); i) { if (m_arrBarRects[i].PtInRect(point)) { CString strTip; strTip.Format(_T(分类 %s数值 %.1f), m_arrBarNames[i], m_arrBarValue[i]); SetWindowText(strTip); // 标题栏显示命中信息 SetCursor(LoadCursor(NULL, IDC_HAND)); return; } } CView::OnMouseMove(nFlags, point); }命中检测用的矩形必须和绘制时严格一致柱子加了 3D 效果或圆角时用整个外接矩形判断会有几像素误差属于可接受范围。曲线图的命中检测则反过来把鼠标 x 坐标反算成数据 x 值二分查找最近采样点再判断 y 偏差是否在阈值内这也是从设备坐标回到数据坐标的典型应用。5.3 用三组数据快速验证源码可用性验证一套 DrawGraph 自绘代码是否合格我一般跑三组数据一组 10000 点的正弦波叠加噪声验证曲线性能和 Polyline 的吞吐一组 12 分类的占比数据验证饼图布局和标签是否重叠一组带负值且数值跨度极大的数据验证柱状图刻度是否规整。三组跑完不闪、不越界、标签清晰就可以接正式工程。性能检查用任务管理器看 CPU绘制逻辑跑在 UI 线程持续超过 15% 就要查 Invalidate 频率或位图是否在反复创建通常问题都出在这两处而不是绘图函数本身。本文还有配套的精品资源点击获取
返回列表