65.9K
CodeProject 正在变化。 阅读更多。
Home

具有增强用户界面的 MFC 图表控件

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.92/5 (102投票s)

2012 年 1 月 31 日

CPOL

112分钟阅读

viewsIcon

532636

downloadIcon

99980

一个具有增强外观的 MFC 线性图表控件。

演示应用程序:图表曲线

演示应用程序:带标签的容器

目录

引言

为什么我们要将一组数据点视为一条曲线?也许是因为人眼是定性分析的最佳工具。我们可以轻松地看到起伏、趋势,找到某个地方的超调,或者某个地方的最小值等等。

量化分析则会在之后进行,当我们想知道超调有多大,或者最小值发生在哪个 X 值时。所以我们首先需要一个整洁的画面(没有数字、文本标签等),然后按需调用屏幕上的数据值,再将其隐藏以清理画面。

在查看和尝试了许多图表控件后,我决定开发自己的。我的目的是用最少的屏幕干扰获得最大的功能。这就是结果。

致谢

我在打印基于对话框的应用程序方面从在线教程中获得了一些输入。我想感谢 Igor Tandetnik 在数据点模板谓词和 `_VARIADIC_MAX` 参数(后来移植到 VS2012)方面的帮助。

我在 `SaveContainerImage` 函数中使用了 Microsoft VS2010 帮助示例中的一些代码。

2.0 版的更改(主要是)源自我与用户/读者 Alberl 的讨论(请参阅本文的帖子)。 

总体描述

上面的屏幕截图显示了这个图表控件在一个基于对话框的演示应用程序中。第一个截图显示了图表曲线,第二个截图显示了带有两个已调用子窗口的控件:数据标签和名称标签。第二个截图还展示了一条垂直数据线以及距离它最近的图表数据点。我们将这些点称为选定点。数据线穿过用户选择的 X 坐标 X0。数据标签显示图表名称、X 轴和 Y 轴的名称以及相应选定点的 X 和 Y 值。还显示了 X 轴标签。通常,子窗口、线条和数据点以及 X 标签是不可见的。它们可以根据用户的请求显示或隐藏。截图中的名称是用户(我)的选择,只是为了展示此控件可以做什么。

此控件实现为一个 MFC 静态类库,它将有序的二维数据点系列渲染为三次样条曲线。(来自微软网页:“……样条由点数组和张力参数指定。三次样条在数组中的每个点之间平滑通过……”。)

我们将曲线称为图表,将控件称为图表容器。

您可以在容器中插入任意数量的图表,最多 25 个。限制由为隐藏/显示单个图表保留的弹出菜单项数量决定。每个图表的图表视觉效果:张力(线条平滑度)、线条颜色、虚线样式和宽度可以单独设置。图表数据可以随时附加或截断。为避免屏幕混乱,您可以暂时隐藏某些图表。自 1.1 版起,您还可以为每个图表设置 X 轴名称、Y 轴名称和 Y 精度。您还可以为 X 和 Y 值提供自己的格式化函数。

您可以沿图表水平和垂直方向缩放和平移,更改单个图表的垂直比例,并在单独的窗口中以表格形式查看图表数据点系列。如果您在表中选择某些数据点,您将看到这些点在图表曲线上的精确位置。

您可以打印选定的图表、所有可见图表或图表数据表。

图表属性和数据系列可以保存为 XML 文件。稍后可以将文件加载到图表容器中。您还可以将图表数据系列导出为 STL 向量。

在 1.2 版中,您可以将图表容器保存为图像,任何您的 Windows 操作系统支持的图片格式(BMP、JPEG、PNG 等)。 

您可以通过鼠标和键盘、容器的弹出菜单或以编程方式控制和操作图表。

所有绘图均在 GDI+ 中完成,并进行双缓冲以避免闪烁。

大量使用了 STL 容器:向量、映射和多重映射。我们在代码中广泛使用了 STL 算法和谓词。所有谓词都已重写,以用于二维数据点。

代码是用 MS VS2010 VC++ 10 编写的,并在 Windows 7 Pro 下进行了测试。

演示应用程序的视觉布局已调整为屏幕分辨率 96 DPI 逻辑像素,各向同性。鼠标应具有三个按钮和一个滚轮,但提供了两按钮鼠标的选项。

我选择 `double` 类型作为图表数据的内部表示,因为它提供了范围和精度的最佳组合。此外,科学数据通常是双精度浮点数。

数据系列是具有 `double X`、`double Y` 坐标的点向量。该向量是图表类的成员变量。数据点定义了数据空间。容器的客户区矩形包含客户区或屏幕空间。从一个空间到另一个空间的变换会自动计算。

图表容器,是的,它是一个容器,`std::map`。映射键是图表 ID,值是指向图表的指针。您不应该直接处理此映射和图表。

Doxygen 生成的项目文档以 zip 文件“ChartCtrlLibDoxigen.zip”提供。解压缩它,在“html”文件夹中打开“Index.htm”,您的默认浏览器将显示主页。要使用指向源文件的文档链接,您必须保留与源 zip 文件中保存的相同的文件夹结构。 

1.1 版新增功能?

读者反馈和我在使用 ChartCtrl 过程中的经验促使我为该控件添加了一些新功能。读者Haekkinen要求移除编译器选项 /GL 和 /LTCG,以便能够将库与使用其他编译器的项目链接。Jeff Archer要求向库中添加最小二乘曲线拟合。我自己也觉得需要一些额外的表示功能。因此,1.1 版包含以下添加和更改:

  • 移除了与优化相关的编译器选项 /GL 和 /LTCG。链接器使用 VS 2010 默认的 /MACHINE:X86。
  • 在图表虚线样式中添加了一种新的曲线样式,用于将图表数据点绘制成断开的十字。
  • 用户可以根据自己的选择设置 X 轴名称,而不是默认的“X”。
  • 用户可以为每个图表单独设置 Y 轴名称,而不是默认的“Y”。
  • Y 精度可以为每个图表单独设置。
  • 用户可以为每个图表的 X 值和 Y 值提供格式化函数。
  • 图表的“Set”函数现在接受 -1 作为图表 Idx。这意味着“所有可见图表”。
  • 当图表可见性、X 轴扩展或“显示/隐藏数据点”标志从容器的上下文菜单更改时,图表容器现在会向其父级发送通知消息。
  • 在代码中添加了用于访问库版本信息的定义和函数。

这些添加导致许多容器函数发生进一步更改,以适应新功能。为了演示这些新功能,我在演示应用程序的选项卡控件中添加了一个新的“更改图表”选项卡,并在旧选项卡中添加了一些新控件。

1.2 版新增功能?

再次,读者反馈和我使用 ChartCtrl 的经验促使我为该控件添加了一些新功能。

  • 读者 Nelisse 询问如何将图表容器保存到某种 BMP 文件。作为回应,我添加了 `CChartContainer::SaveContainerImage` 函数。该函数会枚举您的 Windows 操作系统版本支持的图片格式,并将图表容器窗口以用户选择的格式保存到文件中。
  • 如果您有一个图表,其 `Ymax = 10.0`,另一个图表为 `Ymax = 10-5`,您必须手动设置第二个图表的局部 Y 比例才能看到它。我添加了 `CChartContainer::EqualizeVertRanges(double spaceMult, bool bRedraw)` 函数以编程方式均衡屏幕上的图表。该函数会自动为图表设置局部 Y 比例,使可见图表的垂直尺寸成为 `spaceMult` 的递进。例如,如果 `spaceMult = 0.9`,则第二个图表的可见 `Ymax` 是第一个图表 `Ymax` 的 `0.9` 倍。
  • 我添加了 `CChartContainer::IsUserEnabled()` 和 `CChartContainer::EnableUser(bool bEnable, bool bClearState)` 功能和函数,用于阻止/允许用户访问容器的弹出菜单以及从键盘和鼠标输入到容器。禁用的容器是“只读”的:您只能查看它。
  • `CChartContainer::SaveChartData` 的签名已更改,允许保存容器中的所有图表:现在它是 `HRESULT CChartContainer::SaveChartData(bool bAll)`。`bAll` 的默认值为 false。
  • 函数 `AddChart` 和 `AppendChartData` 中的约束 `pntNmb > 2` 已被移除。现在您可以向容器添加一个没有数据的图表。它会将传递给 `AddChart` 函数的所有图表属性设置为参数,或者通过 `AppendChartData` 向图表添加一个或两个点。
  • 我更改了时间序列重载函数 `AddChart`、`AppendChartData` 和 `ReplaceChartData` 的签名。现在用户可以定义时间序列的时间原点和时间步长。例如,我们现在有 `CChartContainer::AppendChartData(int chartIdx, std::vector& vTmSeries, double startX, double stepX, bool bUpdate)`。
  • 函数 `CChartContainer::SetChartVisibility` 和 `CChartContainer::GetChart` 现在接受参数 `chartIdx = -1`。对于可见性,它表示“所有图表”,对于 `GetChart`,它返回容器中的第一个图表。
  • 我添加了一个代码为 `CODE_REFRESH` 的通知,该通知在用户选择容器弹出菜单的“刷新”菜单项时发送。
  • 已添加将 *ChartCtrlLib.lib* 移植到 Visual Studio 2012 (VC++ 11) 的功能。

2.0 版新增功能?

如上所述,2.0 版的主要更改和添加是源自用户/读者 Alberl 的请求和建议。我们在本文的帖子中对它们进行了详细讨论。我同意的一些功能已添加到控件中。

  • 在 1.2 版中,用户可以向图表容器添加没有数据的图表,但这些图表在获得至少三个数据点之前是不可见的。如果每小时只获得一个数据点,您会等三个小时才看到图表吗?在 2.0 版中,没有数据或数据点少于三个的图表是图表容器的完整公民:无论有多少数据点,您都可以缩放、平移、查看名称和数据图例等。您甚至可以将没有数据的图表保存为 XML 文件并重新加载。
  • 我为图表容器添加了沿 Y 轴缩放和平移的功能。在 2.0 版中,沿 X 和 Y 轴的缩放和平移是两个独立的过程,用户可以独立控制它们。
  • 我对现有功能进行了一些微调,例如添加了容器窗口和数据视图之间的更好同步。
  • 从本文的帖子中,我了解到需要更多地以编程方式控制 ChartCtrl。因此,我添加并完善了从父级控制容器的函数。
  • 当然,我也修复了发现的错误。 

更改量很大,因此是 2.0 版。

特点  

以下是操作和控制图表容器及图表的四种方法:

  • 鼠标
  • 键盘
  • 容器弹出菜单
  • 以编程方式

您可以使用的容器和图表功能包括:

  • 数据系列的长度仅受常识限制:没有意义将十亿个数据点绘制到 300 像素上,但 `std::vector` 理论上可以处理这个数量。
  • 图表容器中的图表数量同样仅受常识限制。屏幕上过多的曲线可能会通过暂时隐藏其中一些或大部分图表来缓解。根据我的经验,每个容器十个或十二个图表就足以让您感到痛苦:您必须隐藏其中大部分才能理解您的数据。目前,为表示单个图表预留了 25 个弹出菜单项。 
  • 图表容器接受具有多值数据系列的图表。多值数据系列可能有很多具有相同 X 坐标的点。
  • 图表容器接受没有数据的图表。显然,这些图表不可见,但容器的弹出菜单将它们显示为没有数据的图表。
  • 图表数据向量可以随时以编程方式附加、截断或替换。图表可以从容器中完全删除,或者其数据向量可以清空所有数据点。
  • 容器背景、边框、轴以及所有其他颜色的颜色可以随时以编程方式更改。  
  • 图表的颜色、线型、画笔宽度和张力可以随时以编程方式更改。
  • 容器的名称和容器的 X 轴名称可以随时以编程方式设置或更改。
  • 图表的 Y 轴名称可以在图表插入容器时设置,和/或随时以编程方式更改。每个图表都可以单独设置 Y 轴名称。
  • 用户可以为容器的 X 轴值提供格式化函数,并为每个图表的 Y 轴值提供格式化函数。默认格式化函数只是将数字转换为字符串。
  • 可以通过鼠标滚轮、键盘箭头键或以编程方式更改图表的垂直比例。可以以编程方式均衡局部垂直比例集。  如果用户使用鼠标或键盘,容器会向其父级发送 `WM_NOTIFY`。

  • 容器会自动计算轴位置。用户可以显示或隐藏 X 轴范围的最左侧和最右侧值。
  • 容器允许使用鼠标按钮和鼠标滚轮、键盘箭头键、容器弹出菜单以及以编程方式沿 X 和 Y 轴缩放和平移图表。容器会单独为 X 和 Y 轴保留这些操作的历史记录,并允许撤销这些操作。如果这些操作是从弹出菜单开始的,容器会向其父级发送通知 (WM_NOTIFY)。 
  • 容器允许以编程方式或从容器弹出菜单更改每个图表的可见性和“显示/隐藏数据点”标志。如果这些操作是从弹出菜单开始的,容器会向其父级发送通知 (WM_NOTIFY)。
  • 容器的 X 值和图表的 Y 值的显示精度可以随时以编程方式更改。Y 精度是为每个图表单独设置的。
  • 用户可以看到可见图表名称的列表(名称图例)以及距离所选 X 坐标值最近的所有数据点的 X 和 Y 坐标列表(数据图例)。图例是容器的子窗口,如果需要可以隐藏。
  • 选定图表的数据系列可以在单独的数据窗口中显示为表格。该窗口与容器中的图表同步:在数据窗口中选择的点将在图表曲线上显示,并且图表数据向量、图表名称、X 轴和 Y 轴、格式化函数以及 X 和 Y 精度中的更改将反映在数据窗口中。
  • 容器的可见图表、选定图表或所有图表(可见和不可见)都可以保存到 XML 文件中。文件格式是专有的,但该文件可以被 MS Excel 接受(它不是真正的 Excel 文件,但允许手动编辑为完美的 Excel 格式)。这些 XML 文件中的图表可以再次加载到任何图表容器中。  
  • 容器图像可以保存到操作系统支持的任何格式的图片文件中。支持的格式(如 BMP、JPEG、PNG 等)由容器自动枚举并呈现给用户选择。 
  • 容器的可见图表或选定图表可以打印到 8.5" x 11" 的页面上。
  • 数据视图表也可以打印。
  • 图表数据向量可以以三种不同的格式以编程方式导出。
  • 用户对图表的访问可以被以编程方式阻止。当用户被阻止(禁用)时,容器是“只读”的:不允许进行任何图表操作。
  • 可以以编程方式访问有关图表控件库版本的信息。

如何使用 

要在您的应用程序中使用图表控件,您应该先执行一些初步步骤:

  • 您必须在应用程序的开头添加一些代码来初始化 GDI+,并在退出时释放它。
  • 您必须在项目中包含静态库 ChartCtrlLib.lib 以及头文件 ChartDef.hChartContainer.h。另一种方法是将所有控件源文件包含到项目中。为了方便您的使用,请使用 zip 文件 ChartCtrlLibKit.zip 将图表容器插入到应用程序中。
  • 准备数据系列并将它们添加到容器中。

准备应用程序:启用 GDI+

要启用 GDI+

  1. 在您的应用程序中包含一个私有数据成员 `ULONG_PTR m_nGdiplusToken`
    class CMyApp : public CWinAppEx
    {
        .........................................
    private:
        ULONG_PTR m_nGdiplusToken;
        ..........................................
    }
  2. 在 `CMyApp::InitInstance` 函数中,添加两行:
    BOOL CMyApp::InitInstance ()
    { 
        ..........................................
    
        Gdiplus::GdiplusStartupInput gdiplusStartupInput;
        Gdiplus::GdiplusStartup(&m_nGdiplusToken,
                             &gdiplusStartupInput, NULL);
    
        ..........................................
    }
  3. 为了让您的应用程序优雅地退出,请在 `CMyApp` 中添加 `ExitInstance()` 函数(如果它不存在),并在其主体中插入该行:
    int  CMyAppApp::ExitInstance()
    {
         ...............................................
         Gdiplus::GdiplusShutdown(m_nGdiplusToken);
         ...............................................
    }

准备应用程序:启用静态库

本文档包含两个库文件:调试版本 ChartCtrlLibD.lib 和发布版本 ChartCtrlLib.lib

要将库添加到您的项目中,请在项目属性对话框的“链接器\输入\附加依赖项”中输入库的完整路径。

另一种添加库到项目引用的方法是使用 VS 宏。将库 ChartCtrlLibD.lib 复制到您的 Solution (Project) Directory \Debug 目录,并将 ChartCtrlLib.lib 复制到 Solution (Project) Directory\Release 目录。在相应的配置(例如,Debug 或 Release)中,选择“项目属性”->“配置属性/VC++ Directories”。在“库目录”中输入 $(SolutionDir)$(Configuration)。如果在此目录之后还有条目,请添加分号。在“链接器\输入\附加依赖项”中输入库的名称,ChartCtrlLibD.lib 或 ChartCtrlLib.lib。同样,如果需要,请添加分号。切换配置时,它会自动选择适当的 lib 版本。

您必须在项目中包含两个文件:ChartDef.hChartContainer.h,或者在项目属性页的“VC++ Directories\Include”目录中添加这些文件的路径。

如果您要调试项目和库,请在“源文件目录”中包含库源文件目录的路径。

请注意,在 VC++ Directories 页面中输入的路径可能会被您的后续项目继承。如果您不希望发生这种情况,请在“C++\General\Additional Include Directories”中输入包含路径,而不是在“VC++ Directories”页面中,并将指向静态库文件 ChartCtrlLib.lib 的路径输入到“链接器\General\Additional Library Directories”中。

当然,您也可以将所有库头文件和源文件包含到您的项目中,而不是编译的库文件。

如果您将此库与 Boost 或 Windows SDK 等其他库一起使用,可能会遇到链接器和编译器错误“多重定义”。如果多重定义发生在 Windows SDK 和/或 MFC 文件中,请尝试忽略它们:将链接器选项 /FORCE:MULTIPLE 添加到命令行。它会将错误转换为警告。有关减轻这些错误的更多方法,请搜索 MS 论坛。

如果您的项目是在 MS Visual Studio 2012 VC++ (VC++ 10) 下开发的,您必须使用在 VC++ 10 下开发的库。我已将它们包含在 ChartCtrlLibVS2012.zip 存档中。

此外,在 VS 2012 中,Microsoft 实现了一些模板,包括使用 假可变参数元组。他们将可变模板参数的默认最大数量设置为 5,而不是 VS 2010 中的 10。因此,为了能够在 VS 2012 C++ 项目中使用 ChartCtrlLib.lib,您必须手动将 `_VARIADIC_MAX` 参数设置为 10。Igor Tandetnik 告诉我,最方便的方法是使用项目属性。因此,转到您的项目属性/C++/预处理器/预处理器指令,并在预处理器指令行中输入 _VARIADIC_MAX=10

准备应用程序:设置图表容器

如果您的应用程序是基于对话框的,请在资源编辑器中选择工具箱中的图片控件,并将其拖放到对话框中图表容器的位置。调整控件的大小和位置。在控件属性窗口中,输入控件 ID(例如 IDC_STCHARTCONTAINER)。  确保 `NOTIFY` 控件属性设置为 `TRUE`。

在您的 `CDialog` 类定义中添加数据成员,如下所示:

CChartContainer m_chartContainer;

在对话框函数 `DoDataExchange(CDataExchange* pDX)` 中添加 `DDX_Control` 函数以子类化控件,如下所示:

DDX_Control(pDX, IDC_STCHARTCONTAINER, m_chartContainer);

另一方面,如果您的应用程序是文档-视图应用程序,请在视图类中声明 `CChartContainer` 数据成员,如下所示:

CChartContainer m_chartContainer;

并调用:

m_chartContainer.CreateChartCtrlWnd(DWORD dwExStyle, DWORD dwStyle, 
                 const CRect& wndRect, CWnd* pParent, UINT nID);

创建容器窗口。`dwStyle` 参数将在函数内部与 `WS_CHILD` 和 `WS_VISIBLE` 样式组合。

设置图表容器属性

图表容器属性包括容器名称、X 轴名称、格式化函数、X 范围、精度以及容器元素的颜色。实际上,您可以使用默认值愉快地生活:X 轴名称是“X”,Y 轴是“Y”,X 和 Y 精度是三。默认的格式化函数只是将数字转换为具有给定精度的字符串。容器名称仅用于打印图表。X 范围可以自动设置以显示所有图表的完整范围。如果您有特殊需求,可以使用以下函数设置 X 范围: 

CChartContainer::UpdateExtX(double minExtX, double maxExtX, bool  bRedraw = false)

要设置容器名称,您可以在构造函数中提供名称,如下所示:

CChartContainer myContainer(string_t(_T("Demo"))

或调用函数: 

SetContainerName(string_t name);

在此,`string_t` 是 `std::basic_string` 的 `typedef`。

要设置 X 轴名称,请调用函数 `CChartContainer::SetAxisXName(string_t nameX, bool bRedraw = false)`。 

要设置 X 值格式化函数,您必须在应用程序中包含该函数的代码,并通过调用容器函数 `CChartContainer::SetLabXValStrFn(val_label_str_fn pLabValStrFn, bool bRedraw = false)` 来注册它。`pLabValStrFn` 是指向您的格式化函数的指针。稍后将提供更多关于格式化函数的详细信息。 

精度值是在将数字转换为字符串时要显示的有效数字的数量。容器精度是 X 值的精度。默认值三表示任何数字将显示三个有效数字:1233.4567 显示为 1230,0.1234567e-45 显示为 1.23e-045。 

使用 `SetContainerPrecision(int precision, bool bRedraw = false)` 来设置精度。参数 `bRedraw` 是一个标志,用于请求重绘图表容器及其子项(数据标签和名称图例)。精度仅影响数据的表示。它不会改变图表数据系列的精度。 

默认构造函数设置了默认颜色(白色背景,黑色轴和边框,灰色点网格,浅黄色背景用于数据和名称标签等,如上面演示快照所示)。如果您想在开始时命名容器,请将名称传递给容器的构造函数。

图表容器的成员函数用于设置颜色,这些函数位于文件 ChartContainer.h 中。

请小心颜色,因为它们是相互关联的:例如,更改背景可能会迫使您更改所有其他容器元素和图表的颜色。尝试使用演示来查看更改的结果。 

所有图表容器属性都可以在任何时候更改,而不仅仅是在初始化时。

准备数据 

每个图表将其数据系列保留为数据点向量。数据点是类模板的实例化:

template <typename T> class PointT

对于 `double` 类型。 

有 `typedef`: 

typedef PointT<double> PointD;
typedef std::vector<PointD> V_CHARTDATAD;

如果您决定使用 `V_CHARTDATAD` 来准备数据,请记住您的应用程序必须看到 `PointT` 的定义才能为 `double` 实例化它。`PointT`、`PointD` 和 `V_CHARTDATAD` 的定义在文件 ChartDef.h 中。 

您也可以完全不使用 `PointD` 来准备数据。图表容器也接受以下形式的数据系列:`std::vector >`、`std::vector`(时间序列)以及一对向量 `std::vector`,一个用于 X 坐标,一个用于 Y 坐标。`PointD` 向量和对向量将按 X 自动排序;时间序列不需要排序,因为 X 坐标会自动分配。默认情况下,时间序列原点设置为 0.0,时间步长为 1.0。这意味着时间序列中的第一个数据点将是 `PointD(0.0, Y0 )`,第二个点将是 `PointD(1.0, Y1 )`,依此类推。如果您想或需要(例如附加时间序列图表),您必须将您自己的时间原点和/或时间步长值传递给相应的函数(`AddChart`、`AppendChartData` 或 `ReplaceChartData`)。 

用户负责最后两个向量。它们必须具有相同的大小;`X` 向量必须已排序;`Y` 值应按排序的 X 值顺序排列。

容器显示至少有一个数据点的数据系列(显然,您不能绘制零个数据点的图表)。实际上,您需要更多:如果 `Gdiplus::DrawCurve` 例程没有接收到足够的数据点,它会绘制一个丑陋的曲线。在此情况下,请自行插值。通常,30-40 个点对应 350 像素就足够了。您还可以调整张力以美化您的曲线。对于没有数据的图表,`AddChart` 将所有图表属性设置为函数参数。它不会更新容器的 `X` 和 `Y` 扩展。 ` `

如果容器中的所有图表都只有一个 X 坐标相同的数据点,容器将自动将 X 扩展设置为 X 值的 1%。如果所有图表都只有一个 Y 坐标相同的数据点,则 Y 扩展设置为 Y 值的 5%。容器将根据获取的图表数据调整其 X 和 Y 扩展。您也可以使用 `UpdateExtX` 和 `UpdateExtY` 函数提前设置扩展。

不同的图表可能具有不同的 X 和 Y 范围以及不同数量的数据点,但通常您希望在一个容器中使用相关的数据系列集。这通常意味着相同数量的数据点和相同的 X 范围。 

向图表容器添加图表

现在是时候将您的图表添加到容器中了。

使用 `CChartContainer` 的成员函数:

int AddChart(bool bVisible, 
               bool bShowPnts, 
               string_t label, 
               string_t labelY, 
               int precisionY, 
               Gdiplus::DashStyle dashStyle, 
               float penWidth, 
               float tension, 
               Gdiplus::Color colChart, 
               V_CHARTDATAD& vData, 
               bool bRedraw = false);

函数的大多数参数都是不言自明的。

如果两个相邻数据点之间的最小 `X` 距离足够大,则数据点将被小圆圈包围。有时用这些圆圈干扰画面是不希望的。设置 `bShowPnts = false` 以隐藏它们。如果图表只有一个数据点,它将始终呈现为一个圆圈。

根据设计,每个图表必须有一个唯一的名称和 ID。`AddChart` 会自动计算 ID。ID 在每次会话中是唯一的,即从第一个图表添加到空容器到最后一个图表从该容器删除的时间。

如果您输入的图表名称在此会话中不唯一,`AddChart` 将在名称后添加一个后缀。后缀是图表的 ID。例如,如果您为 ID 为 8 的图表输入名称“Sine Wave”,而容器已有一个同名图表,`AddChart` 将在其后添加一个后缀:“Sine Wave_8”。如果您为空字符串提供名称,`AddChart` 将生成带有相同后缀的名称,如“Chart_0”、“Chart_8”等。

图表名称以及 X 和 Y 值名称的长度限制为 18 个字符。如果字符串长度大于 18 个字符,则作为参数传递给 `AddChart`(以及所有其他处理名称的函数)的字符串将被截断到此长度。截断字符串的末尾将包含分隔符“^”和字符串的最后一个字符。例如,字符串“Very, very, strange and long, long, long string”将被截断为“Very, very, stran^g”。

张力的选择取决于曲线的类型和数据点的数量。显然,随机数据用线性曲线(张力 = 0)效果更好,但正弦波的十个点可能用张力 = 0.6 看起来不错,而用张力 = 1.0 看起来很糟糕。

此外,还有三个 `AddChart` 的重载函数,它们接受双精度对的向量 `std::vector >&`、时间序列 `std::vector&` 以及两个向量 `std::vector& X` 和 `Y`。使用其中任何一个。  要将时间序列添加到容器,您必须提供 X 坐标的起始值以及用于增加后续点 X 值的步长值。

图表颜色、名称、Y 值名称、虚线样式、画笔宽度、可见性以及“显示/隐藏点”属性可以随时以编程方式更改。

新添加的图表具有默认的 Y 格式化函数 `string_t __stdcall GetLabelValStr(double val, int precision, bool bAddEqSign)`。它将值字符串格式化为具有给定精度的数字。

函数 `AddChart` 成功时返回新图表的 ID,失败时返回 -1。

请记住,图表是在堆上分配的;容器只存储图表的指针。所有容器函数在适当的时候都会删除图表,但如果您尝试在容器外部处理图表,则需要自行删除。

允许您向 `Add Charts` 提供空数据向量。如果没有数据点,容器将设置 `AddChart` 参数提供的所有图表属性,但不会修改容器的 `X` 和 `Y` 扩展,也不会显示图表。此图表将列在容器弹出菜单中,显示为无数据图表。

如果需要,提供格式化函数

并非总是方便将图表数据点显示为裸数字。假设您有一个图表显示平均每月温度与月份的关系。自然地,将 X 坐标显示为月份名称,将 Y 坐标显示为“0F”。在这种情况下,可以调用用户提供的格式化函数。

格式化函数在 ChartDef.h 中定义为:

typedef string_t (__stdcall *val_label_str_fn)(double val, int precision, bool bAddEqSign);

该函数接受数字的值和精度,并返回一个字符串。当容器准备显示值时,参数 `bAddEqSign` 为 true 时,会在字符串前面加上“=”前缀。该函数是一个回调函数,在显示值时被调用。应用程序可以通过调用容器的“Set”函数来设置格式化函数:

void CChartContainer::SetLabXValStrFn(val_label_str_fn pLabValStrFn, bool bRedraw = false);
bool CChartContainer::SetLabYValStrFn(int chartIdx, 
     val_label_str_fn m_pLabYValStrFn, bool bRedraw = false);

如果容器找不到具有给定 `chartIdx` 的图表,它将不执行任何操作并返回 false。

X 值和 Y 值的默认格式化函数相同。

string_t __stdcall GetLabelValStr(double val, int precision, bool bAddEqSign)
{
  sstream_t stream_t;
  stream_t << std::setprecision(precision) << val;
  return bAddEqSign ? string_t(_T("= ")) + stream_t.str() : stream_t.str();
}

如果您想沿 X 轴显示月份名称,您应该编写类似如下的代码:

string_t __stdcall GetLabelValStrMonths(double val, int , bool)
{
  if (in_range(-0.5, 0.5, val))
   return string_t(_T("January"));
  else if (in_range(0.5, 1.5, val))
   return string_t(_T("February"));
//etc.
}

对于 Y 值,可以是:

string_t __stdcall GetLabelValStrGradF(double val, int precision, bool bAddEqSign)
{
  sstream_t stream_t;
  stream_t << std::setprecision(precision) << val << _T(" <sup>0</sup>F");
  return bAddEqSign ? string_t(_T("= ")) + stream_t.str() : stream_t.str();
}

您必须使用以下方法注册这些函数:

myContainer.SetLabXValStrFn(GetLabelValStrMonth);
myContainer.SetLabYValStrFn(myChartIdx, GetLabelValStrGradF);

用户手册

现在您已将所有图表输入到容器中,并且屏幕显示如第一个演示截图所示(没有子窗口)。让我们来玩玩它。

某些对容器的操作只能通过编程方式从外部执行。这些操作包括向容器添加、附加、截断和删除图表,以及访问图表和容器属性(如名称、精度等)的函数。对于其他操作,容器有一个内置的弹出菜单以及对鼠标点击、鼠标滚轮和键盘箭头键的处理程序。这些操作也可以使用容器的接口成员函数来执行。

以下是内置操作的摘要: 

  • 显示/隐藏名称标签:右键单击图表控件,然后从弹出菜单中选择“显示图例”。图例窗口将显示在控件窗口的右上角,包含可见图表的名称。名称字符的颜色是图表颜色,名称前面的短线具有图表曲线的颜色、宽度和虚线样式。当向容器添加或删除新图表,或更改容器图表的可见性时,标签窗口的内容将更新。
  • 查看/隐藏 X 轴标签:转到弹出菜单并选择菜单项“显示 X 轴边界”。在屏幕上时,X 标签将跟随 X 轴范围:平移和缩放将改变它们显示的值。
  • 查看所选 X 坐标处图表数据点的值:首先启用跟踪模式。单击鼠标中键。光标将变为十字准星。现在将光标移动到所需的 X 位置,然后单击左键。您将看到在选定 X 位置有一条垂直线,并在最近的数据点周围有圆圈。最近的数据点 X 坐标是距离线条最近的 X ± 3 像素范围内的点。可能出现某些或所有图表在此范围内没有此类点的情况,并且什么都不会显示。如果发生这种情况,请缩放和平移容器,直到数据点被圆圈标记。选择 X,单击以查看数据窗口,然后根据需要使用弹出菜单撤销缩放/平移以查看完整的 X 范围。如果容器窗口被缩放或平移,数据窗口将跟随选定的 X 坐标,当选定的 X 坐标超出视图时隐藏,然后再次出现。同样,只显示可见图表的数据。要禁用跟踪模式,请再次单击鼠标中键。光标将恢复为箭头形状。数据标签显示在第二个演示截图中。
  • 对于某些图表操作,您可能需要先选择图表:将光标移到您需要的图表上,并在按住 CTRL 键的情况下单击曲线附近。选定的图表将由一个扩散且更宽的线条标记。如果光标远离任何数据点且没有选定的图表,容器将选择 ID 较小的可见图表。第二个 CTRL + 左键单击会取消选择当前图表,并在光标靠近第二个图表的一个数据点时选择下一个图表。 
  • 更改特定图表的垂直比例:如上所述,先选择图表。之后,使用 CTRL + 鼠标滚轮或 UP、DOWN、PAGE UP 或 PAGE DOWN 键更改图表的 Y 比例。如果您稍后取消选择图表,它将保留其新的 Y 比例。此操作不会更改图表的数据向量。
  • 查看数据视图中的图表数据表:先选择图表。之后,转到弹出菜单并单击菜单项“查看图表数据”。选定图表的数据视图窗口将显示。如果您在未选择图表的情况下单击“查看图表数据”,则会选择 ID 较小的可见图表并显示其数据。您可以使用视图的箭头按钮在视图页面之间移动。如果图表属性发生更改,您将停留在数据视图的同一页面(如果可能);否则,您将转到第一页。您可以使用视图的“打印”按钮打印视图的某些或所有页面。将显示 MFC“打印”对话框。选择打印机,设置打印机属性,然后打印视图。如果没有选定的图表,容器将显示 ID 较小的可见图表的数据。您可以通过在表单元格上单击左键来选择数据视图中的一个或多个数据点。选定的点将在容器的相应曲线上显示。再次单击选定的单元格将取消选择它。右键单击数据视图窗口将取消选择所有选定的表单元格。下面的截图是数据视图的快照。 

  • 您可以将任何一组图表保存到 XML 文件中。文件中的任何图表或图表组都可以重新加载到此容器或其他任何容器中。图表可以添加到容器中,或替换旧容器中的图表。要仅保存一个图表,请先选择它。要保存一组图表,请隐藏所有您不打算保存的图表(使用弹出菜单隐藏图表)。如果选定了可见图表之一,则取消选择它。之后,转到弹出菜单,从“保存/打印图表”子菜单中选择“保存图表(s)”。将显示 MFC“另存为”对话框。输入文件名或选择要覆盖的文件,然后单击对话框的“保存”按钮。文件格式是专有的,但您可以将文件加载到 MS Excel 中。(对话框中有一个默认保存文件的目录,设置为 $(SolutionDir)Charts。如果您在项目中提供了此目录,它将在对话框启动时打开。)如果您以编程方式保存图表,可以通过将 `bAll` 布尔参数设置为适当的值来保存所有图表(可见和不可见):`SaveChartData(string_t pathName, bool bAll)`。

  • 您可以将容器窗口另存为图像。 隐藏您不希望出现在图片中的所有图表。调用/隐藏名称和/或数据标签窗口。准备好后,转到弹出菜单,选择“保存/打印图表”/“将图表另存为图像”菜单项。容器将枚举您的 Windows 版本支持的所有图片格式,并呈现标准的 MFC “另存为”对话框(带有这些文件扩展名)。单击确定即可完成。(同样,默认目录设置为 $(SolutionDir)Images。)
  • 要打印容器的某个可见图表,请先选择它。要打印一组图表,请隐藏所有其他图表。之后,转到弹出菜单,从“保存/打印图表”子菜单中单击“打印图表”项。从弹出式的 MFC“打印”对话框中选择打印机,并设置打印机属性。单击确定按钮并获取打印件。容器不与图表曲线一起打印标签窗口。相反,它在容器图像下方显示图例字符串。为了方便测量,X 轴标签始终显示在打印件上。每个图例字符串包括图表的垂直比例值。如果屏幕上显示了数据标签,则图例字符串将显示最接近选择线的图表数据点的坐标。如果没有选定的点,图例字符串将显示图表的最小和最大 Y 值。如果没有选定的图表,容器将打印所有可见图表。下面的图片是打印件的样本。

  • 容器中有两种缩放方法:使用鼠标和从弹出菜单。要使用鼠标水平缩放,请按住 SHIFT 键单击容器。一条垂直线将显示缩放范围的第一个 X 边界。按住 SHIFT 键第二次单击将显示缩放范围的第二个边界。在 `LBUTTONUP` 时,容器的 X 轴将更改为新的 X 范围,容器窗口将更新。数据标签位置将根据选定 X 坐标的新位置进行调整。如果选定的 X 超出视图,则数据标签将隐藏。要从弹出菜单水平缩放,请在“缩放/移动”子菜单中选择“放大 X”菜单项。新的 X 轴范围将是旧范围的 80%。容器会保存 X 轴先前边界的值,以便撤销缩放。 
  • 要垂直缩放,您也可以使用鼠标或上下文菜单。使用鼠标,您需要按住 SHIFT 键并双击左键。一条垂直线将被一条水平线替换,该水平线沿第一个垂直缩放边界。第二次单击(仅左键单击,而不是双击左键)将显示第二个垂直边界。释放鼠标左键,将显示新的、缩放后的垂直范围。要使用弹出菜单,请在“缩放/移动”子菜单中使用“放大 Y”菜单项。同样,Y 范围将是旧范围的 80%。数据标签内容将与容器窗口中的图片同步。如果不存在可见数据点,则容器窗口不会缩放:X 和 Y 范围不会改变。容器会保存 Y 轴先前边界的值,以便撤销沿 Y 轴的缩放/移动操作。
  • 沿 X 轴平移容器有三种方法:使用鼠标、箭头键或从弹出菜单。要使用鼠标滚轮沿 X 轴平移容器,请按住 SHIFT 键并旋转滚轮。要使用箭头键沿 X 轴平移,您必须按 LEFT 或 RIGHT 箭头键。要从弹出菜单平移,请转到“缩放/移动”子菜单并选择“向右移动”或“向左移动”菜单项。容器窗口中的图像将向左或向右移动 X 范围的 10%。数据标签也将相应移动。容器会保存 X 轴先前边界的值,以便撤销平移。
  • 沿 Y 轴平移有两种方法:使用箭头键或从上下文菜单。UP、DOWN、PgUP 和 PgDn 箭头键具有双重功能:如果没有选择图表,它们会控制选定图表的局部 Y 比例或平移容器向上或向下;否则,它们会平移容器向上或向下。因此,请确保未选择任何图表,然后使用 UP 或 DOWN 键将图表移动初始 Y 范围的 1%(或使用 PgUp 或 PgDn 将图表向上或向下移动初始 Y 范围的 10%)。  “缩放/移动”子菜单中的弹出菜单项“向上移动”和“向下移动”会将图表向上或向下移动初始 Y 范围的 10%。同样,数据图例内容与容器窗口内容同步,并且 Y 范围边界的旧值被保存,以便沿 Y 轴进行缩放/移动操作的独立撤销。
  • 要撤销上一次缩放或平移步骤,请转到弹出菜单并选择“撤销上次缩放/移动 X”或“撤销上次缩放/移动 Y”菜单项。仅当存在先前缩放/平移操作的历史记录时,弹出菜单才会显示这些项。“撤销 X”会撤销上一次水平缩放/平移操作,“撤销 Y”会撤销上一次垂直操作。数据和名称标签的位置将得到更新(如果存在相应的标签)。X 轴标签(如果可见)也会更新。
  • 要撤销所有缩放/移动步骤,请转到弹出菜单并选择“刷新图表”菜单项。同样,如果没有缩放/移动历史记录,该项将不可见。
  • 如果您在第一次单击进行水平或垂直缩放后改变了主意,请按“Delete”键。第一个边界线将被删除,并且在单击之前的容器状态将恢复。
  • 隐藏/显示图表数据点周围的圆圈:先选择图表。之后,转到弹出菜单并单击“显示/隐藏图表点”菜单项。数据点周围的圆圈仅当相邻点之间的最小 X 距离大于六个像素时才可见。如果没有选定的图表,则选择 ID 较小的可见图表,并隐藏或显示其数据点周围的圆圈。“显示/隐藏图表点”菜单项左侧的复选标记指示选定图表的此属性状态。如果已选中,则当相邻数据点之间的最小距离大于六个像素时,点应可见。您可以随时设置此属性,即使数据点太近而当前不可见。  您也可以通过调用函数 `CChartContainer::AllowChartPnts(int chartIdx, bool bAllowed, bool bVisibleOnly, bool bRedraw)` 来以编程方式显示/隐藏圆圈。如果 `chartIdx == -1`,该函数将操作所有图表或所有可见图表。此操作对只有一个数据点的图表没有影响:它始终呈现为圆圈。
  • 隐藏/显示特定图表:单击弹出菜单的“显示(图表名称)”项。当该项被选中时,图表可见。

以下是内置控件的摘要:

鼠标事件 

  • MBUTTONDOWN:打开/关闭跟踪模式。跟踪模式允许显示最接近选定 X 坐标的可见图表数据点的值。在跟踪模式下,鼠标光标在图表容器上会从箭头变为十字准星形状。
  • LBUTTONDOWN:在跟踪模式下,调用数据标签窗口或更改数据标签位置。然后,数据标签将显示最接近单击的 X 坐标的数据点的名称和 X、Y 值。数据点必须在以单击的 X 坐标为中心的六像素 X 邻域内。穿过 X 坐标的垂直数据线以及这些数据点周围的圆圈也会显示出来。
  • SHIFT + LBUTTONDBLCLK:设置 Y 轴缩放的第一个垂直范围边界。将显示标记此边界的红色水平线。

  • SHIFT + LBUTTONDOWN:放大。第一次单击选择 zoomX 范围的第一个 X 边界。第二次单击的效果取决于之前的鼠标操作:如果连续第二次按下 SHIFT 键并单击左键,它将设置第二个 X 边界;如果紧随 SHIFT + LBUTTONDBLCLK 之后,它将设置 Y 轴缩放的第二个边界。在 `LBUTTONUP` 时,容器将被缩放。容器的 X 或 Y 范围的先前值将被保存在 X 或 Y 缩放/移动历史记录中。
  • CTRL + LBUTTONDOWN:选择/取消选择可见图表。鼠标单击必须在任何图表数据点的邻域内。  如果单击邻域内没有数据点且存在选定图表,则该图表将被取消选择。如果没有选定图表,则选择 ID 较小的可见图表。
  • SHIFT + MOUSEWHEEL:沿 X 轴平移容器。数据线和数据标签子窗口将与先前设置的数据线一起移动。
  • RBUTTONDOWN:调用弹出菜单。 
  • 数据视图中的 LBUTTONDOWN:选择/取消选择数据表中的行。选定的数据点将在容器窗口中的相应图表上显示。 
  • 数据视图中的 RBUTTONDOWN:取消选择数据表中的所有选定行,并从容器窗口中移除数据视图的所有选定数据点。

键盘命令

  • 左右箭头:沿 X 轴平移图表容器。 
  • 上下/翻页键:如果没有选择图表,则沿 Y 轴平移图表容器;否则,更改所选图表的 Y 比例。
  • DELETE:撤销水平或垂直缩放的第一步。

弹出菜单命令

  • “显示图例”:在名称标签窗口中显示可见图表的名称。窗口位于容器窗口的右上方。
  • “显示 X 轴边界”:显示/隐藏 X 轴标签。
  • “查看图表数据”:在单独的窗口中显示选定图表的数据系列作为表格。
  • “保存/打印图表”:打开子菜单,其中包含“保存图表(s)”、“将图表另存为图像”和“打印图表”菜单项。任意数量的可见图表可以保存为 XML 文件,任意数量的图表曲线可以打印到 8.5" x 11" 的页面上。
  • “缩放/移动”:打开一个子菜单,其中包含“放大 X”、“向右移动”、“向左移动”以及“放大 Y”、“向上移动”、“向下移动”等项。容器可以水平或垂直缩放或平移。新的缩放范围是旧范围的 80%,移动距离是范围的 10%。
  • “撤销上次缩放/移动 X”和“撤销上次缩放/移动 Y”:执行相应操作。仅当容器沿 X 和 Y 轴的缩放/平移历史记录非空时,这些菜单项才可见并启用。“撤销 X”撤销上一次水平缩放/平移操作,“撤销 Y”撤销上一次垂直操作。数据和名称标签的位置将得到更新(如果存在相应的标签)。X 轴标签(如果可见)也会更新。
  • “刷新图表”:将容器恢复到初始的 X 和 Y 坐标。仅当容器被缩放/平移至少一次后,此菜单项才可见。刷新将更新数据和名称标签的位置(如果容器处于跟踪模式)。如果 X 轴标签可见,也会更新它们。
  • “显示/隐藏图表点”:如果相邻点之间的最小 X 距离足够大(约 6 像素),则显示/隐藏选定图表的数据点周围的圆圈。  图表在取消选择后将记住状态。
  • “显示(图表名称)”:切换此图表的可见性。 

关注点

首先,让我们讨论一些设计解决方案。

将不相关的数据曲线放在同一个窗口中没有优点。我们应该分析相关的数据集。我们可以预期相关数据集的 X 范围会重叠,尽管不完全重叠。因此,我设计了图表容器,为所有图表提供一个通用的 X 比例和 X 轴范围。初始 X 范围应至少是所有图表 X 范围的并集。用户可以通过缩放和/或平移容器来选择 X 范围的任何部分进行查看。

因为所有图表只有一个 X 轴,所以 X 轴可能只有一个通用名称、一个精度值和一个格式化函数。

图表可以显示非常不同的 Y 轴值。例如,我们可以有一个图表显示高度(Y)与距离(X)的关系,第二个图表显示温度(Y)与相同的距离(X)的关系。因此,每个图表可以有自己的 Y 轴名称、Y 精度和 Y 格式化函数。

显然,为了更好地呈现,Y 比例可以不同,甚至对不同的数据集差异很大。尽管如此,容器的实现具有所有图表的通用 Y 比例和 Y 范围。同样,初始 Y 范围应该是所有图表 Y 范围的并集。我已经提供了用户更改任何选定图表的屏幕 Y 比例的手段,以获得他/她想要的最佳画面。

因为一个像素可能对应多个数据点,所以我提供了沿 X 轴缩放和平移的手段。在适当缩放和平移后,您可以看到每个数据点。

在我看来,这消除了沿 Y 轴缩放/平移的需要。沿 X 轴放大,然后逐点读取 Y 值。但是读者 Alberl 非常坚持要求沿两个轴进行缩放,所以我照做了。Alberl 建议使用矩形作为缩放区域的边界:您单击并拖动鼠标以划定该区域。我发现这对用户来说非常不方便:一旦开始,即使矩形位置错误且尺寸不正确,您也会在释放鼠标时进行缩放。我最终将 X 和 Y 缩放/平移分为两个独立的过程。您可以分别进行,也可以分别撤销。

关于轴?它们是自动放置的。用户不应该关心它们。 

用户如何获取数据系列中点的 X 和 Y 值信息?数据点坐标和图表名称显示在容器的子窗口中。用户选择 X 坐标,容器应该显示最接近请求点所有数据点的值。

许多用户/读者要求添加新功能,如带有命名刻度的 X 和 Y 轴、默认名称标签等。我理解他们,但我想说:各位,如果您决定使用 ChartCtls,您应该接受此控件所基于的图表模型。这是一个交互式控件,假定用户坐在 PC 显示器前。该控件默认显示最少信息,但提供对大量数据的访问。如果用户需要一些细节,他/她可以轻松获得,但必须通过一些操作来请求。如果您不想单击鼠标按钮或按下按键,那没关系:您可以从主应用程序以编程方式控制图表。例如,没有也没有刻度轴标签,但如果您不能没有它们,您可以访问容器的 X 和 Y 范围。获取它们,在某个透明窗口中绘制刻度和名称,然后将其叠加在容器之上。但请记住,这是一个交互式控件,并且用户在场。否则,为什么要绘制图表?   

更多关于设计和实现的内容将在后面讨论。

图表控件代码由七个头文件和六个源文件组成(加上 stdafx.hstdafx.cpp)。我认为将它们全部包含到每个 C++ 项目中太多了,所以我将其制作为静态库。任何使用此图表控件的项目只需要包含两个头文件:ChartDef.hChartContainer.h。当然,也应该包含对库文件 ChartCtrlLib.lib 的引用。

在后续讨论中,我们将使用 STL 容器的别名(文件 ChartDef.h)。

typedef std::basic_string<TCHAR> string_t;
typedef std::basic_stringstream<TCHAR> sstream_t;
typedef std::pair<double, double> PAIR_DBLS;

// Typedefs for data vectors and other STL containers
typedef std::vector<PointD> V_CHARTDATAD;
typedef std::vector<Gdiplus::PointF> V_CHARTDATAF;
typedef std::vector<string_t> V_VALSTRINGS;
typedef std::multimap<int, PointD> MAP_SELPNTSD;

// Used to count multiple Y values for the same X: 
// the first member is the iterator to the first occurrence, the second
// is the count of the data points with the same X-coordinate
typedef std::pair<V_CHARTDATAD::iterator, int> PAIR_ITNEAREST;
// Output of some algorithms returning two iterators
typedef std::pair<V_CHARTDATAD::iterator, V_CHARTDATAD::iterator> PAIR_ITS;
typedef std::vector<string_t> V_CHARTNAMES;

// Typedefs for the History container 
typedef std::pair <double, double> PAIR_POS;
typedef std::vector<PAIR_POS> V_HIST;

// Typedefs for container chart map
class CChart;
typedef std::map<int, CChart*> MAP_CHARTS;

// For load from file
typedef std::map<string_t, Gdiplus::Color> MAP_CHARTCOLS;

库中有九个类:类模板 `PointT`、类 `CChart`、`CDataWnd`、`CPageCtrl`、`CDataView`、`CChartDataView`、`CChartXMLSerializer`、结构 `MatrixD` 和类 `CChartContainer`。库仅导出两个类:`PointT` 和 `CChartContainer`。

类:PointT

数据系列中数据点的基本表示是类模板 `PointT` 为 `double` 实例化(参见 ChartDef.h)。

template <typename T> 
class PointT
{
public:
    PointT(T x = 0, T y = 0) : X(x), Y(y) {}
    PointT(const PointT &pntT) {X = pntT.X; Y = pntT.Y;}
    PointT(const Gdiplus::PointF& pntF) {X = static_cast<T>(pntF.X);
        Y = static_cast<T>(pntF.Y);}
    ...................................................
// Conversion function
    operator Gdiplus::PointF() 
         {return Gdiplus::PointF(float(X), float(Y));}

public:
    T X;
    T Y;
};

typedef PointT<double> PointD;

类定义还包括重载运算符 =、+、-、*、/ 和 ==。

由于 GDI+ 函数仅接受 REAL(float)浮点数,因此有一个接受 `Gdiplus::PointF` 作为参数的构造函数,以及一个将 `PointT` 转换为 `Gdiplus::PointF` 的转换运算符。您必须在每次调用任何返回或接受 `PointD` 类型参数的容器成员函数的应用程序点中,都使此类的定义可见。这意味着您必须包含 ChartDef.h

类:CChart (Chart.h)

这是一个相当“笨”的类。主要用作图表数据系列和属性的存储。

首先,它在 `CChart::m_vDataPnts` 中存储一个数据点向量 `V_CHARTDATAD`。该向量必须按 X 坐标升序排序。向量至少必须有三个数据点,才能在容器窗口中有至少一个数据点。

图表属性包括数据点的 X 和 Y 坐标的最小值和最大值 `m_fMinValX`、`m_fMaxValX`、`m_fMinValY` 和 `m_fMaxValY`。容器使用它们来计算其水平和垂直比例。成员 `m_fLocScaleY` 存储沿 Y 轴放大/缩小图表曲线的乘数。

其他属性是视觉效果:图表颜色、虚线样式、张力和画笔宽度。

该图表具有唯一的 Idx 和名称。它还有自己的 Y 值精度、自己的 Y 轴名称以及自己的 Y 值格式化函数。图表 Idx 和名称唯一性的生命周期是会话的生命周期。容器生成唯一的 Idx,并在添加图表时检查所提供图表名称的唯一性。如果名称已分配给其他图表,容器将在所提供的名称后附加一个后缀,该后缀是图表 Idx。例如,如果名称“SineWave”已分配,并且图表 Idx = 8,则图表名称将是“SineWave_8”。如果未提供名称,容器将生成名称“Chart_4”。长度超过 18 个字符的名称将被截断。

CChart 最重要的成员函数是 `DrawChart (...)`。稍后将讨论它。

类:辅助

类 `CDataWnd`、`CPageCtrl`、`CDataView`、`CChartDataView`、`CChartXMLSerializer` 和结构 `MatrixD` 封装了某些用户操作所需的函数(稍后请参阅)。

类:CChartContainer (ChartContainer.h)

它是一个容器:`std::map `。此类也是访问图表控件所有功能的网关。它是图表控件库导出的唯一一个大型类。您永远不应该直接寻址所有其他类:而是使用 `CCharContainer` 的公共成员函数。

我认为描述图表控件类内部工作原理和交互以及关注点的最佳方法是考虑图表控件应执行的任务。

任务:添加图表

该任务由函数执行:

int CChartContainer::AddChart(bool bVisible, bool bShowPnts, string_t label,
                               string_t labelY, int precisionY,
                               DashStyle dashStyle, float penWidth, float tension, 
                               Color colChart, V_CHARTDATAD& vData, bool bRedraw)
{
  int chartIdx = GetMaxChartIdx() + 1;
  bool bAddIdx = false;
  if (!label.empty())
  {
    label = NormalizeString(label, STR_MAXLEN, STR_NORMSIGN);
    CChart* twinPtr = FindChartByName(label);
    if (twinPtr != NULL)
      bAddIdx = true;
  }
  else
  {
    label = string_t(_T("Cnart"));
    bAddIdx = true;
  }

  if (bAddIdx)
  {
    _TCHAR buffer_t[64];
    _itot_s(chartIdx, buffer_t, 10);  // Chart idx to string
    string_t idxStr(buffer_t);
    label += string_t(_T("_")) + string_t(buffer_t);
  }

  CChart* chartPtr = new CChart;   

  chartPtr->SetChartAttr(bVisible, bShowPnts, chartIdx, label, labelY, 
                                    precisionY, dashStyle, penWidth, tension, colChart);

  size_t dataSize = vData.size();

// Now transfer data and set min max values
  if (dataSize > 0)
  {
    chartPtr->m_vDataPnts.assign(vData.begin(), vData.end());
    chartPtr->m_vDataPnts.shrink_to_fit();
 
// It is cheaper to sort right away than to look for max/min x and sort later if needed
    if (dataSize > 1)
      std::sort(chartPtr->m_vDataPnts.begin(), 
                chartPtr->m_vDataPnts.end(), less_pnt<double, false>());

    double minValX = chartPtr->m_vDataPnts.front().X;
    double maxValX = chartPtr->m_vDataPnts.back().X;

// Find min and max Y; works even for one-point vector
    PAIR_ITS pair_minmaxY = 
        minmax_element(chartPtr->m_vDataPnts.begin(), chartPtr->m_vDataPnts.end(), 
                                                         less_pnt<double, true>());
    double minValY = pair_minmaxY.first->Y;
    double maxValY = pair_minmaxY.second->Y;

// Save in the CChart
    chartPtr->SetMinValX(minValX);
    chartPtr->SetMaxValX(maxValX);
    chartPtr->SetMinValY(minValY);
    chartPtr->SetMaxValY(maxValY);
  }

// Just in case: idx is unique for this container
  if (m_mapCharts.insert(MAP_CHARTS::value_type(
        chartPtr->GetChartIdx(), chartPtr)).second == false)
  {
    delete chartPtr;
    return -1;
  }

// Now update the container's min maxes, saving the history of X
  if (dataSize > 0)
  {
// Wil automatically take care of previous one-point charts
    UpdateExtX(chartPtr->GetMinValX(), chartPtr->GetMaxValX());
    UpdateExtY(chartPtr->GetMinValY(), chartPtr->GetMaxValY());

    if (IsWindow(m_hWnd) && m_bTracking && IsLabWndExist(false))
      PrepareDataLegend(m_dataLegPntD, m_epsX, m_pDataWnd->m_mapLabs, m_mapSelPntsD, true);

    if (bRedraw && IsWindow(m_hWnd)&&IsWindowVisible())
      UpdateContainerWnds(-1, true);
  }

  return chartIdx;
}

 

此函数的伪代码是:

  • 检查数据系列 `vData` 是否至少有三个数据点;如果没有,则返回 -1。
  • 为本次会话生成图表的唯一 Idx。
  • 处理提供的图表名称(标签)的唯一性,并在必要时更改它(函数 NormalizeString())。
  • 在堆上分配新图表。
  • 使用图表成员 `SetChartAttr(...)` 设置图表属性。
  • 将数据系列 `vData` 分配给图表数据成员 `CChart::m_vDataPnts`。
  • 按 X 坐标对数据进行排序。
  • 获取数据系列 X 和 Y 坐标的最小值/最大值,并设置新图表的相应数据成员。
  • 将新图表插入到图表容器的 `m_mapCharts` 中,键为 `chartIdx`,值为指向新图表的指针。
  • 更新容器的 X 和 Y 扩展。
  • 如果容器处于跟踪模式,则更新最接近容器选定 X 坐标的点的映射(如果存在选定)。
  • 如果 `bRedraw == true`,则重绘容器。
  • 返回新的 `chartIdx`。

显然,如果图表没有数据点,则跳过处理数据点的步骤。  对其他图表的渲染没有任何改变。

添加的图表具有默认格式化函数。如果需要替换它,请调用 `CChartContainer::SetLabYValStrFn`。

此伪代码中的几个点需要补充说明。

地图MAP_CHARTS是一个std::map。根据定义,地图按键值排序。因此,要为当前会话生成唯一的图表ID,您只需通过调用m_mapCharts.rbegin().first来获取m_mapCharts的最后一个元素的键,然后将键值加一。调用AddChart是向容器添加图表的唯一方式,因此它将始终为给定的容器生成唯一的ID。

我们正在使用的图表控件中的某些算法只能在有序序列上操作。因此,图表数据系列必须按数据点的X坐标排序。直接调用算法std::sort比遍历数据系列的所有元素来确定它是否已排序,然后如果未排序则对其进行排序要便宜得多。这就是我们直接对数据向量调用std::sort的原因。

为了获得图表的X和Y范围,我们需要找到X和Y坐标的最小值和最大值。

在按X排序的数据向量中,最小X是第一个元素的X坐标,最大X是最后一个X。这很简单:只需获取图表的m_vDataPnts.front().Xm_vDataPnts.back().X

Y坐标的最小值/最大值可以位于向量的任何位置。所以我使用了算法

minmax_element(chartPtr->m_vDataPnts.begin(),
    chartPtr->m_vDataPnts.end(), less_pnt<double, true>())

数据点类没有<运算符,因此我必须编写并使用自己的谓词less_pnt。此库中使用的其他算法也需要类似的谓词,所以我们来看看less_pnt

谓词和算法:less_pnt

我们需要一个谓词,能够根据我们的选择与图表数据点的X或Y值一起工作。这似乎非常简单

template <typename T, bool bY>
struct less_pnt
{
    bool operator () (const T& Left, const T& Right)
    {
         if (bY) return Left.Y < Right.Y;
         return Left.X < Right.X;
    }
};

但是,typename可以传递什么?如果您传递一个类,模板可以为任何具有公共成员X和Y的类进行实例化。X和Y的类型是什么并不重要:它们只需要有<运算符。如果未定义此运算符会发生什么?编译器只会在为这个奇怪的类进行模板实例化时抱怨。这可能发生在开发过程的后期,或者在软件升级期间很久之后。

因此,最好使用相同的POD数据作为模板参数,并将PointT<T>作为()运算符的参数(感谢Igor Tandetnik)。

其次,因为非类型参数的值必须在编译时知道,所以对于该模板的每个特定实例化,运行时只会执行“if”语句的一个分支。尚不清楚编译器是否会优化掉另一个分支。也许我们会在运行时进行不必要的比较。最好通过手动对模板进行部分特化来优化它。我们将有

template <typename T, bool bY>
struct less_pnt
{

    bool operator () (const PointT<T>& Left, const PointT<T>& Right)
    {
         return Left.Y < Right.Y;
    }
};

// Partial specialization for X
template <typename T> struct less_pnt<T, false>
{
    bool operator () (const PointT<T>& Left, const PointT<T>& Right)
    {
         return Left.X < Right.X;
    }
};

数据空间、窗口客户区和变换

图表数据向量中的数据点位于图表的数据空间中。将此空间视为笛卡尔(矩形)坐标系中的矩形是自然的。该矩形的左边界和右边界是数据点的最小和最大X坐标;顶部和底部是最大和最小Y坐标。由于科学数据中的Y轴通常向上,因此顶部值大于底部值。

容器的数据空间是容器中所有图表数据空间的并集。它也是一个矩形。通常,我们委托容器计算容器数据空间,但如果需要,我们可以使用以下函数从容器外部设置容器数据空间的边界:

bool CChartContainer::UpdateExtX(double minExtX, double maxExtX, bool bRedraw = false)
void CChartContainer::UpdateExtY(double minExtY, double maxExtY, bool bRedraw = false)

(参数bRedraw = true强制立即更新容器窗口。)

这些函数计算最小和最大的X和Y范围。例如,如果图表的最大 maxX 是 10,但您将 100 作为参数 maxExtX 传递,则最大范围 X 将设置为 100。

还有另一对函数, 

PAIR_DBLS SetExtX(bool bRedraw = false);
PAIR_DBLS SetExtY(bool bRedraw = false);

这些函数计算容器中所有图表的X和Y范围的并集,并将结果设置为数据空间中的容器维度。

如果存在X和/或Y缩放/平移的历史记录,函数会将新的范围限制替换到历史记录向量的前面;只有当您撤销X轴上的所有缩放/平移后,您才会看到新的X限制,撤销Y历史记录后看到新的Y范围。

坐标系的原点和轴可以相对于数据空间矩形位于任何位置:内部、左侧、底部下方等。

我们可以使用整个容器数据空间或其中的一部分(考虑缩放和平移)。

在任何给定时刻,使用中的容器数据空间的边界都存储在CChartContainer数据成员中:

double m_minExtY;       // Min coordinate of the Y axis
double m_maxExtY;       // Max coordinate of the Y axis
double m_startX;        // Leftmost X coordinate
double m_endX;          // Rightmost X coordinate (not included)

我们将m_minExtYm_maxExtY对称为容器的Y范围,将m_startXm_endX称为容器的X范围。

当我们在容器窗口中绘制图表时,我们是在容器的客户区绘制。这里的坐标系原点位于客户区矩形的左上角,Y轴向下(顶部小于底部)。因此,在开始绘制之前,我们必须将容器数据空间映射到客户区。

变换矩阵 

映射到客户区包括平移和缩放。这些变换由变换矩阵描述。在二维图形中,它是

这里a11 = X缩放,a22 = Y缩放,a31= X偏移,a32 = Y偏移。对于图表控件,变换只是平移和缩放,所以a12和a21将始终等于零。

在GDI+中,我们有Matrix类。不幸的是,变换矩阵的元素类型为float

理论上,容器的数据空间可能完全或部分超出float类型的范围。将超出范围的数据传递给GDI+的变换或绘图函数可能会导致错误。因此,我们必须首先将数据空间变换到使用double值的客户区,然后将结果转换为float类型。这些转换始终在float的范围内,因为客户区坐标(逻辑像素)的值受int类型范围的限制。当然,我们会失去精度,但这无关紧要:float坐标最终会被四舍五入到像素。反向变换,从客户区到数据空间呢?好吧,我们必须小心,始终记住丢失的精度。例如,如果我们正在寻找与给定像素对应的数据点,我们必须查找最接近该像素变换到数据空间的点,而不是等于它。

使此矩阵成为通用模板类没有意义:要从屏幕变换到数据空间,我们必须在类中包含求逆运算。求逆需要除法,因此只能使用浮点数。我们已经有了floatGdipus::Matrix,所以有了double的变换矩阵类(*ChartDef.h*): 

// This MatrixD is only for translation and scaling of double numbers 
typedef class MatrixD
{
public:
    double m_scX;
    double m_scY;
    double m_offsX;
    double m_offsY;
public:
    // The constructor yields an identity matrix by default
    MatrixD(double scX = 1.0, double scY = 1.0, 
    double offsX = 0.0, double offsY = 0.0): m_scX(scX), 
                        m_scY(scY), m_offsX(offsX), m_offsY(offsY) {}
    // Transforms
    void Translate(double offsX, double offsY)
    {m_offsX += offsX*m_scX; m_offsY += offsY*m_scY;}
    void Scale(double scX, double scY) 
    {m_scX *= scX; m_scY *= scY;}

    // Operations on matrixD; if the matrix is not invertible, 
    //returns false; uses explicit formulae for inversion  
    bool Invert(void); 

    MatrixD* Clone(void) 
    { 
        MatrixD* pMatrix = new MatrixD;
        pMatrix->m_scX = m_scX;
        pMatrix->m_scY = m_scY;
        pMatrix->m_offsX = m_offsX;
        pMatrix->m_offsY = m_offsY;
        return pMatrix;
    }

    // Transforms PointD to PointF and PointF to PointD
    Gdiplus::PointF TransformToPntF(double locScY, const PointD& pntD);
    PointD TransformToPntD(double locScY, const Gdiplus::PointF& pntF);
private:
    MatrixD(const MatrixD& src);
    MatrixD operator =(const MatrixD& src);
} MATRIX_D; 

Gdipus::Matrix只允许克隆操作,所以为了行为相似,MATRIX_D中的复制构造函数和赋值运算符被设为私有。

默认构造函数创建一个单位矩阵I。为了计算变换矩阵,图表控件必须将单位矩阵乘以缩放矩阵S和/或平移矩阵T

这是通过对MATRIX_D实例应用ScaleTranslate函数来完成的。矩阵乘法不是可交换的。我们在图表控件中使用MatrixOrderPrepend顺序,其中乘数位于原始矩阵的左侧。这意味着如果我们想先缩放,然后平移缩放后的结果,我们必须按此顺序相乘:

这相当于: 

myMatrix.Translate(...); //First
myMatrix.Scale (...);    // Second 

结果是向量: 

为了求逆矩阵,我对只有平移和缩放成员的矩阵使用了显式公式

a31 = -a31/a11

a32 = -a32/a22

a33 = 1.0; 

a11 = 1.0/a11

a22 = 1.0/a22

a12 = a13= a21 = a23 = 0. 

为了正确绘制,我们必须将容器数据空间缩放到客户区,并将结果平移到客户区的原点。

任务:获取变换矩阵 

首先,我们应该在哪个函数中计算变换矩阵?每次我们要绘制图表时,都必须拥有正确的变换矩阵。最好的地方是CChartContainer::OnPaint()。我们总是调用此函数,直接或间接,在对容器进行更改之后。

我们从容器的X和Y范围开始,这些范围由容器计算或由应用程序设置。对于客户区的平移,我们需要知道轴的原点。

函数: 

PAIR_XAXPOS CChartContainer::GetXAxisPos(RectF rChartF, 
                                           double minY, double maxY)
PAIR_YAXPOS CChartContainer::GetYAxisPos(RectF rChartF, 
                                         double startX, double endX)

计算X轴的Y位置和Y轴的X位置。

这个想法很简单:如果最小值是负数,最大值是正数,则将轴放在它们之间;否则,将其附加到客户区矩形的相应边界。例如,对于Y轴的X位置

if ((startX < 0)&&(endX > 0))
{
    double offsYX = rChartF.Width*fabs(startX)/(endX - startX);
    horzOffs = rChartF.GetLeft() + float(offsYX);
    // Somewhere between minX, maxX
}
else if (startX >= 0)
    horzOffs = rChartF.GetLeft();
else if (endX <= 0)
    horzOffs = rChartF.GetRight(); 

我们将offsYX声明为double,因为X和Y范围是double,但计算在客户区。

客户区的轴原点是Gdiplus::PointF(offsXY, offsYX)

对于缩放,函数CChartContainer::UpdateScales(drawRF, m_startX, m_endX, m_minExtY, m_maxExtY)完成了这项工作。它只是计算

scX = drawRectWidth/rangeX;
scY = drawRectHeight/rangeY; 

计算之前,我们将客户区矩形的宽度和高度缩小了10%,使图像看起来更好。

有了偏移量和缩放比例,我们就可以计算变换矩阵了

MatrixD matrixD;
matrixD.Translate(pntOrigF.X, pntOrigF.Y);
matrixD.Scale(m_scX, -m_scY); 

第三行中的负号反转了Y轴的方向。

我们还没有完成。请记住,当原点在客户区矩形之外时,我们只是将轴放在矩形的左侧或右侧或顶部或底部边界。在这种情况下,还需要一个额外的平移;这是在数据空间中: 

matrixD.Translate(translateX, translateY);

这是到数据空间边界的平移:translateX-startX(Y轴的左侧位置)或-endX,而translateY-minExtY(X轴的顶部位置)或-maxExtY。变换的顺序是:首先,如果需要,在数据空间中平移原点;然后,缩放;最后,将结果移动到客户区的原点。

有时我们需要在调用OnPaint()之前获取变换矩阵,例如,跟踪数据标签。函数MatrixD* CChartContainer::GetTransformMatrixD(double startX, double endX, double minY, double maxY)OnPaint()之外计算给定X和Y范围的矩阵。

要查看变换代码,您需要转到该函数或CChartContainer::OnPaint()

任务:绘制图表 

现在我们有了变换矩阵,可以绘制可见的图表了。这项任务由函数完成

bool CChart::DrawChartCurve(V_CHARTDATAD& vDataPntsD, double startX, double endX, 
                 MatrixD* pMatrixD, GraphicsPath* grPathPtr, Graphics* grPtr, float dpiRatio)
{
  if (vDataPntsD.size()== 0)
  // Just for safe programming; the function is never called on count zero
    return false;

  V_CHARTDATAF vDataPntsF;
// Convert the pntsD to the screen pntsF
  if (!ConvertChartData(vDataPntsD, vDataPntsF, pMatrixD, startX, endX)) 
    return false;

  V_CHARTDATAF::iterator itF = vDataPntsF.begin();
  V_CHARTDATAF::pointer ptrDataPntsF = vDataPntsF.data();
  size_t vSize = vDataPntsF.size();

// Add the curve to grPath
  Pen pen(m_colChart, m_fPenWidth*dpiRatio);
  pen.SetDashStyle(m_dashStyle);
  if (!m_bShowPnts&&(vSize == 2))   // Are outside or at boundaries of clipping area
  {                                 // Make special semi-transparent dash pen
    Color col(SetAlpha(m_colChart, ALPHA_NOPNT));
    pen.SetColor(col);
  }

  if (m_dashStyle != DashStyleCustom)
  {
    if (vSize > 1)
    {
      grPtr->DrawCurve(&pen, ptrDataPntsF, vSize, m_fTension);

      if (m_bSelected && (dpiRatio == 1.0f))  // Mark the chart as selectes on screen only
      {
        Pen selPen(Color(SetAlpha(m_colChart, ALPHA_SELECT)), 
                   (m_fPenWidth + PEN_SELWIDTH)*dpiRatio);
        grPtr->DrawCurve(&selPen, ptrDataPntsF, vSize, m_fTension);
      }
    }

// Now add the points
    if (m_bShowPnts || (vSize == 1))
    {
      itF = adjacent_find(vDataPntsF.begin(), vDataPntsF.end() , 
                         lesser_adjacent_interval<PointF, 
                         false>(PointF(dpiRatio*CHART_PNTSTRSH, 0.0f)));
      if (itF == vDataPntsF.end())    // All intervals are greater than CHART_PNTSTRSH   
      {
        itF = vDataPntsF.begin();    // Base
        for (; itF != vDataPntsF.end(); ++itF)
        {
          RectF rPntF = RectFFromCenterF(*itF, dpiRatio*CHART_DTPNTSZ, 
                                                                dpiRatio*CHART_DTPNTSZ);
          grPathPtr->AddEllipse(rPntF);
        }
      }
    }
  }
  else
  {
    PointF pntF;
    PointF pntFX(dpiRatio*CHART_DTPNTSZ/2, 0.0f);
    PointF pntFY(0.0f, dpiRatio*CHART_DTPNTSZ/2);

    for (; itF != vDataPntsF.end(); ++itF)
    {
      pntF = *itF;
      grPathPtr->StartFigure();
      grPathPtr->AddLine(pntF - pntFX, pntF + pntFX);
      grPathPtr->StartFigure();
      grPathPtr->AddLine(pntF - pntFY, pntF + pntFY);
    }
    if (vSize == 1)
    {
      grPathPtr->StartFigure();
      grPathPtr->AddEllipse(RectFFromCenterF(pntF, 2.0f*pntFX.X, 2.0f*pntFY.Y));
    }
  }

  if (grPathPtr->GetPointCount() > 0)          // Has points to draw
  {  
    pen.SetWidth(1.0f*dpiRatio);
    pen.SetDashStyle(DashStyleSolid);
    grPtr->DrawPath(&pen, grPathPtr);
    if (((m_dashStyle == DashStyleCustom)||(vSize == 1))&& m_bSelected && (dpiRatio == 1.0f))
    {
      pen.SetColor(Color(SetAlpha(m_colChart, ALPHA_SELECT)));
      pen.SetWidth(m_fPenWidth + PEN_SELWIDTH);
      grPtr->DrawPath(&pen, grPathPtr);
    }
    grPathPtr->Reset();
  }
  return true;
}

参数startXendX可以覆盖整个容器的X范围或其中一部分(在缩放或平移之后)。参数dpiRatio用于调整打印的画笔宽度。我们稍后会讨论它。

函数DrawChartCurve执行以下操作

  • 它搜索两个边界数据点。这些点应该最接近startXendX范围,但不在其中。
  • 使用参数startXendX和变换矩阵pMatrixD的指针,将这些数据点之间的数据向量部分转换为客户区的浮点坐标。
  • 它使用图表的颜色、虚线样式、宽度以及图表的张力来绘制变换后的数据点的曲线。如果虚线样式为DashStyleCustom,则函数不绘制曲线,而是将图表数据点渲染为十字。
  • 如果图表被选中,则使用半透明颜色和增加的画笔宽度再次绘制图表(请参阅本文开头的截图)。
  • 获取两个相邻数据点之间的最小屏幕距离;如果大于六个像素,则在所有可见图表的数据点周围绘制圆圈。
  • 如果图表只有一个数据点,则只在该点周围绘制一个圆圈。

首先,我们来考虑搜索边界数据点。

假设我们有数据空间的X范围-10.0、10.0,数据点X坐标之间的间隔为2.0。如果startX = -7.0,endX = +7.0,则绘制范围内的最左边的点X=-6.0,绘制范围内的最右边的点X=6.0。

如果我们只在内部点上调用Gdiplus::DrawCurve,曲线将从-6.0运行到+6.0,而不是从-7.0到+7.0。我们将它视为一条以X=-6.0开始,以X=6.0结束的曲线。但是,曲线肯定存在于这些点之外。为了得到一条覆盖所有X范围的美丽曲线,我们必须将数据点集扩展到X范围的限制之外或在其限制内的最近的左侧和右侧数据点。

用于此任务的算法和谓词位于Util.h中。该算法由函数使用

PAIR_ITS CChart::GetStartEndDataIterators(V_CHARTDATAD& vDataPnts, double startX, double endX)
{
    PAIR_ITS pair_its = find_border_pnts(vDataPnts.begin(), 
       vDataPnts.end(), not_inside_range<double, false>(startX, endX));
    return pair_its;
}  

谓词是: 

template <typename T, bool bY> struct not_inside_range

我们只对X坐标使用部分特化: 

template <typename T >
struct not_inside_range<T, false>
{
    T _lhs;
    T _rhs;
    bool _bFnd;
    not_inside_range(T lhs, T rhs) : _lhs(lhs), _rhs(rhs), _bFnd(false) {}

    inline std::pair<bool, bool> operator () (const PointT<T>& pntT)
    {
         bool bLeft = false;
         bool bRight = false;
         if (pntT.X < _lhs)
               bLeft = true;
         else if (!_bFnd && (pntT.X == _lhs))
         { 
                bLeft = true;
                _bFnd = true;
         }
         else if (pntT.X >= _rhs)
                bRight = true;
         std::pair<bool, bool> pair_res(bLeft, bRight);
         return pair_res;
    }
};  

谓词获取点的坐标(此处为pntT.X),并首先将其与区间的左边界进行比较。如果坐标位于左边界的左侧或等于左边界,则谓词返回std::pair(true, false)。如果坐标位于右边界的右侧或等于右边界,则谓词返回std::pair(false, true)。如果坐标位于区间内,则返回值为pair(false, false)

对于多值函数,谓词选择具有相同X坐标的点组中的第一个点,并忽略该组中的所有其他点。

该算法遍历迭代器范围_First_Last在有序序列上。每次谓词返回pair_res.first = true时,算法都会刷新内部迭代器对的第一个迭代器。在第一次返回pair_res.second = true时,算法会将当前迭代器保存在对的第二个成员中。因为序列是有序的,所以不需要继续测试。算法返回此迭代器对。

接下来,我们必须将GetStartEndDataIterators返回的迭代器之间的点从数据空间映射到客户区。算法std::transform以及自定义谓词transform_and_cast_to_pntF (ChartDef)完成了这项工作

// Predicate to use with the STL algorithm transform
typedef struct transform_and_cast_to_pntF
{
  double _locScY;
  MatrixD* _pMatrixD;

  transform_and_cast_to_pntF(double locScY, MatrixD* pMatrixD) : 
                              _locScY(locScY), _pMatrixD(pMatrixD) {}
  inline Gdiplus::PointF operator() (const PointD& pntD)
  {
    double X = _pMatrixD->m_scX*pntD.X + _pMatrixD->m_offsX;
    double Y = _locScY == 1.0 ? pntD.Y : _locScY*pntD.Y;
    Y = _pMatrixD->m_scY*Y + _pMatrixD->m_offsY;
    return Gdiplus::PointF(float(X), float(Y));
  }
} TRANSFORM_TO_PNTF; 

谓词只是将变换矩阵_pMatrixD应用于算法范围中的每个点,并将结果强制转换为Cdiplus::PointF。在应用矩阵之前,谓词将点的Y坐标乘以某个值。该值存储在谓词成员_locScY中。它允许我们修改给定图表的Y缩放比例,以更改图表曲线的垂直大小,而无需修改图表的数据空间。谓词在构造时获取_locScY_pMatrixD

所有这些准备工作、边界点的搜索、变换和强制转换都在函数Chart::ConvertChartData中完成。该函数将结果PointF向量作为输出参数std::vector<Gdiplus::PointF> vDataPntsF返回。

为了绘制图表,我们只需对除DashStyleCustom以外的所有虚线样式调用Gdiplus::DrawCurve

Gdiplus::DrawCurve只接受指向Cdiplus::PointF数组的指针。要获取指针,我们调用std::vector::data()函数: 

V_CHARTDATAF::pointer ptrDataPntsF = vDataPntsF.data()

每像素数据点的数量会随着用户更改容器的X范围而改变。例如,在演示应用程序中,客户区矩形的宽度为476像素。如果图表有1000个数据点,则等于每像素2.1个数据点。假设我们缩放了容器,现在客户区矩形中只有10个数据点可见。现在相邻数据点之间有47.6个像素。为了区分实际数据点和样条插值像素,我们必须以某种方式标记实际数据点。我们通过在数据点周围绘制圆圈来实现这一点。

如何决定何时绘制这些圆圈?我们选择当相邻数据点之间的最小距离大于或等于六个像素时绘制圆圈。这个决定完全基于美学考虑:在这个距离下,带有圆圈的图表曲线看起来不会太拥挤。

因此,DrawChartCurve使用谓词adjacent_interval_pntF(*Util.h*)对vDataPntsF应用算法std::adjacent_find。如果相邻数据点之间的最小距离大于或等于6.0像素,DrawChartCurve会将圆圈添加到图形路径周围,然后绘制路径。我们使用最小值作为标准来避免歧义。否则,无法知道某个曲线段是空的还是数据点过多。

有时不希望绘制这些圆圈。如果成员CChart::m_bShowPnts设置为false,则会阻止绘制圆圈。

那么DashStyleCustom呢?我们保留它来将图表绘制为一组不连接的数据点。每个数据点表示为一个小的十字。没有办法让Gdiplus::DrawCurve使用任何DashStyle绘制不连接的十字。因此,我们使用DashStyleCustom作为标志来切换到另一个绘图例程。为了绘制点集,我们将每个十字添加到GraphicsPass实例grPass。为了使十字的线彼此不连接,我们必须在每个十字的每条线的插入前加上grPath::StartFigure(): 

for (; itF != vDataPntsF.end(); ++itF)
{
  pntF = *itF;
  grPathPtr->StartFigure();
  grPathPtr->AddLine(pntF - pntFX, pntF + pntFX);
  grPathPtr->StartFigure();
  grPathPtr->AddLine(pntF - pntFY, pntF + pntFY);
}

Graphics* grPtr;
grPtr->DrawPath(&pen, grPath); 

任务:绘制容器(无闪烁绘图)

为了无闪烁地绘制,我们使用双缓冲绘图:我们先在内存中绘制,然后再将结果传输到显示器表面。在MFC GDI中,这 all about CreateCompatibleDC,选择一个位图放入此DC,最后将内存DC中的位图位传输到屏幕DC。

此任务的GDI+模拟是

CPaintDC dc(this);     // Device context for painting
Graphics gr(dc.m_hDC); // Graphics object from paintDC
// Although we use only floating-point numbers with Gdiplus 
// functions, we start with integers because no Bitmap constructor 
// accepts REAL (float) numbers
Rect rGdi;
gr.GetVisibleClipBounds(&rGdi);  // Same as GetClentRect

Bitmap clBmp(rGdi.Width, rGdi.Height);          // Mem bitmap
Graphics* grPtr = Graphics::FromImage(&clBmp);  // In-memory

RectF rGdiF = GdiRectToRectF(rGdi); // From Util.h, to allow 
// float numbers

// Draw with grPtr 
................................................

gr.DrawImage(&clBmp, rGdi);      // Transfer into the screen
delete grPtr;                   // Free the memory

此外,当我们需要更新容器窗口时,我们调用函数

void CChartContainer::RefreshWnd()

这些函数计算排除容器子窗口下方区域的区域,并在该区域上调用RedrawWindow()。我们使用区域是因为即使是双缓冲绘图有时也会闪烁。

如果CChartContainer的子窗口可见,和/或其中一个可见图表的数据表显示在数据视图窗口中,RefreshWnd()将不会更新这些窗口。用户应该调用

void CChartContainer::UpdateContainerWnds(int chartIdx, bool bMatrix, DATAVIEW_FLAGS dataChange)
{
  if (m_bTracking && IsLabWndExist(false))
  {
    UpdateDataLegend(bMatrix);
  }
  else
    RefreshWnd();

  if (IsLabWndVisible(true))
    ShowNamesLegend();

  UpdateDataView(chartIdx, dataChange);
}

此函数更新数据标签窗口(如果存在),重绘图表名称标签(同样,如果可见),并更新数据视图。由于可能需要更新数据标签,而X范围在缩放或平移时发生更改,因此我们需要重新计算变换矩阵(bMatrix == true)。标志dataChange告诉数据视图图表的数据向量已更改,需要特殊处理。该函数具有默认参数:chartIdx = -1bMatrix = falseDATAVIEW_FLAGS = F_NODATACHANGE。如果不存在数据视图窗口,X范围未更改(例如,我们隐藏或显示图表),则我们使用默认值。如果存在数据视图,并且更改了图表名称、格式化函数或其他图表属性(但不是数据向量),则我们只需将图表索引传递给函数。

任务:显示数据点值

我们在容器窗口中看到图表,并且我们想知道图表数据点在X轴选定值X0处的确切值。这对用户来说必须很简单:只需选择X0,容器就会显示最接近此X0的图表数据点的X和Y值。X0应通过左键单击或编程方式选择。我们将X0称为请求点。如果用户完成了,他可以命令容器删除此信息或转到下一个X。否则,当容器客户区发生变化时,容器应跟踪X0

现在我们来谈谈细节。该过程包含三个任务

  • 收集最接近请求点X0且在容器窗口中可见的数据点集
  • 将收集到的数据点渲染到屏幕上
  • 跟踪收集到的点及其在屏幕上的图像

让我们从第一个任务开始。

MFC框架提供鼠标单击在客户区的坐标。图表数据点位于数据空间中。我们不能在客户区工作,因为从数据空间的double到客户区的float的转换可能会导致精度损失。例如,float(1.0e-234) = 0.0f。所以我们必须在数据空间中工作。要将鼠标点映射到数据空间点,我们使用函数(*ChartDef.h*)

PointD MatrixD::TransformToPntD(double locScY, const Gdiplus::PointF& pntF)
{
    ENSURE(m_scX*m_scY != 0.0);  // The matrix must be invertible
    MatrixD* matrixDI = Clone(); // Do not change the original
    matrixDI->Invert();
    // Map back into data space   
    double X = pntF.X*matrixDI->m_scX + matrixDI->m_offsX;
    double Y = pntF.Y*matrixDI->m_scY + matrixDI->m_offsY;
    Y /= locScY;                // Correct for the local scaleY

    delete matrixDI;
    return PointD(X, Y);
}

这里有很多有趣的事情在发生。

显然,要从屏幕映射回数据空间,我们必须求逆用于将数据空间变换到客户区的变换矩阵。函数体中的第一行检查矩阵是否可逆。其次,Invert函数计算逆矩阵的数据成员。因为我们在每个绘图过程中都使用直接矩阵,所以我们必须操作它的克隆。

您还记得,在直接变换之前,图表数据点的X坐标乘以图表的局部Y缩放比例以更改屏幕上图表曲线的Y大小。因此,求逆后,我们必须将Y坐标除以locScY

给定请求点在数据空间中的坐标不会改变。如果它是X = 4.5,它将保持X = 4.5,直到下一次单击或请求。在客户区,由于缩放、平移或容器窗口大小更改,给定请求点的位置可能会改变。因此,让我们将请求点在数据空间中的坐标保存在数据成员CChartContainer::m_dataLegPntD中。我们将使用此数据成员来跟踪屏幕上的请求点。

任务:选择要显示的数据点

我们将所有最接近X0且位于容器窗口中的图表数据点收集到std::multimap CChartContainer::MAP_SELPNTSD m_mapSelPntsD中。我们必须使用 multimap,因为某些图表可能具有相同的X值(例如,演示应用程序中的方波)。

“最接近”是什么意思?显然,在选择时,“最接近”意味着“视觉上接近”鼠标单击的位置。在此图表控件中,“视觉上接近”定义为位于以X0为中心且宽度为六个像素的区间内的数据点。同样,此区间在数据空间中的图像在请求点生命周期内是恒定的。我们将其保存在数据成员CChartContainer::m_epsX中。在单击时,m_epsX在客户区的值始终是六个像素,但它应该跟随容器窗口X范围的大小。例如,如果容器X范围缩小到先前范围的一半,则六像素区间将是十二像素宽。对于这个新的X范围内的下一次单击,新区间再次是六个像素。

六像素区间可能包含大量图表数据点,也可能不包含任何数据点。(在演示应用程序中,在X范围-10.0...10.0下,每像素可能有12个或更多数据点。)因此,“最接近”的图表数据点应该在m_epsX中,并且与X0的距离最小。此外,如果几个数据点满足此条件(多值数据系列),我们必须选择所有这些点。

函数GetNearestPoint选择点: 

PAIR_ITNEAREST CChart::GetNearestPointD(const PointD& origPntD, double dist, PointD& selPntD)
{
    V_CHARTDATAD::iterator it = m_vDataPnts.begin(), 
    itE = m_vDataPnts.end();
    int nmbMultPntsD = 0;
    double leftX = origPntD.X - dist/2.0;
    double rightX = origPntD.X + dist/2.0;
    // Find the first point in vicinity of the origPntsD.X, if it exists 
    it = find_if(it, itE, 
    coord_in_range<double, false>(leftX, rightX));
    if (it != itE)  // Than find closest to origPntD.X
    {
        it = find_nearest(it, itE, 
        nearest_to<double, false>(origPntD, dist));
        if (it != itE)
        // Always true; will return
        // found_if result at least
        {
            // Now get the number of multi-valued points (the same X's, different Y's)
            selPntD = *it; 
            nmbMultPntsD = count_if(m_vDataPnts.begin(), it, 
              count_in_range<double, false>(selPntD.X, selPntD.X));
            return make_pair(it, nmbMultPntsD + 1);
        }
    }
    return make_pair(itE, 0);
}

PAIR_ITNEAREST的第一个元素中,函数返回指向图表数据向量中离origPntD.X最近的数据点的迭代器。具有相同X坐标的数据点数量在对的第二个元素中返回。

该函数使用三个STL算法。

首先,它将算法std::find_if应用于整个数据向量,以搜索X坐标在区间origPntD.X ± dist/2.0中的第一个数据点。参数dist定义了“最近”点可能所在的区间,该区间位于数据空间中。通常,它是转换到数据空间的相同六像素“视觉上接近”区间。

接下来是搜索与X0距离最小的数据点。搜索从std::find_if返回的迭代器开始。搜索在相同的区间origPntD.X ±dist/2.0中进行。该算法是自定义的,具有自定义谓词

//Find closest to X or Y coord of some point of origin; apply to sorted 
// sequences only
template<class _InIt, class _Pr> 
inline _InIt find_nearest(_InIt _First, _InIt _Last, _Pr _Pred)
{
     _DEBUG_RANGE(_First, _Last);
     _DEBUG_POINTER(_Pred);

     _InIt _NearestIt = _First; // Find first _InIt satisfying _Pred
     for (; _First != _Last; ++_First)
     {
       if (_Pred(*_First))
            break;
      _NearestIt = _First; // Continue
     }
    return (_NearestIt);
}

谓词_Pred将相邻点之间的距离fabs(dataPntD.X - origPntD.X)与存储在谓词数据成员中的前一个值进行比较。数据向量按X排序,因此当移动到origPnt.X时,距离将始终减小,在移动到origPnt.X之后的第一个点时可能会减小,之后总是增加。在第一次产生距离增加的迭代中,谓词将返回true。算法立即中断循环并返回最接近origPntD.X的上一个点的迭代器。再次,我们对搜索Y坐标使用模板定义,对搜索X使用部分特化。

最后,算法std::count_if计算与最近点具有相同X坐标的数据点的数量。请记住,只有第一次搜索才对整个数据向量进行操作。第二次搜索和计数在请求点周围的微小区间dist中进行。

所有选定的数据点,无论是否可见,都存储在 multimapCChartContainer::m_mapSelPntsD中。对于请求点的生命周期,如果容器不添加或删除图表,或更改图表数据向量,则 multimap 不会改变。选择“最近”点后,我们可以讨论如何渲染它们。

任务:显示数据标签

为了渲染选定的点,我们需要显示请求点、可见的选定点以及它们的X和Y值,以及它们所属的图表的名称,以及图表的Y值名称。将请求点显示为穿过origPntD.X的垂直数据线(我应该说请求线吗?)很方便。我们将可见的选定数据点显示为相应数据点周围的圆圈。这将导致窗口的形状非常不规则:一条从容器客户区顶部到底部的线,一组围绕选定数据点的圆圈,以及一个带有文本字符串的矩形。

更好的方法是使用分层窗口。不幸的是,分层窗口不能是另一个窗口的子窗口,例如图表容器的窗口。(它可以被拥有。)这意味着操作系统和MFC框架在所有者移动、调整大小或位于其他窗口下方时所做的所有工作,您都必须自己完成。

使用具有WS_EX_TRANSPARENT样式的子窗口,它覆盖了上面图片所示的所有区域,这会带来处理发生在子窗口上的鼠标事件的问题,但这些事件应该被传输到容器进行处理,以及其他复杂问题。

所以,我决定做一点小技巧:我将数据线和选定点的绘制留给了容器,并将数据点信息字符串的绘制委托给了容器的子窗口。这使我摆脱了基本维护(子窗口与父窗口一起移动、隐藏和关闭)。作为代价,我接受了同时在容器客户区和子客户区绘制,并在适当的时候刷新两个窗口的任务。

CDataWnd负责绘制数据点的信息。容器将其数据成员CDataWnd* m_pDataWnd作为指向该类的指针。容器为该指针在堆上分配内存,并将与可见选定数据点相关的信息字符串传递给m_pDataWnd。在收到此信息后,m_pDataWnd准备好绘制数据标签。

此数据点信息包含什么?

为了方便识别数据点,我们以其图表的颜色显示信息字符串。图表名称前面的短线与其图表具有相同的颜色、虚线样式和画笔宽度。这意味着容器必须不仅向m_pDataWnd传递数据点坐标值的字符串,还传递图表和X、Y值的名称,以及颜色、虚线样式和画笔宽度等视觉数据。

此信息作为元组传递(*ChartDef.h*)

typedef std::tuple<string_t, string_t,string_t, string_t, 
        string_t, Gdiplus::Color, Gdiplus::DashStyle, float> TUPLE_LABEL;

为什么我们传递五个字符串而不是一个?这是因为,为了使图像美观,我们希望按列显示数据点信息:图表名称、X值名称(对所有图表都相同)、格式化的X值、每个图表的Y值名称以及相应的格式化Y值。

我得坦白。起初,我把元组看作是ISO C++委员会为了让像我们这样的普通程序员的生活更艰难而发明的没用的玩意。我认为有结构就足够了。但后来,我开始欣赏访问元组元素是多么容易和统一。我使用枚举和函数std::get<> (...)来做到这一点。将结构成员的冗长访问与此进行比较

enum TUPLE_LIDX {IDX_LNAME, IDX_LNAMEX, IDX_LX, IDX_LNAMEY, IDX_LY, IDX_LCOLOR, IDX_LDASH, IDX_LPEN}; 
TUPLE_LABEL tuple_label;
get<IDX_LNAME>(tuple_label) = string_t(_T("SineWave_0"));
Gdiplus::Color chartCol     = get<IDX_COLOR>(tuple_label);

为了获得元组,容器对m_mapSelPntsD中的每个可见点调用函数

// Formats string and prepares chart visuals for the screen

TUPLE_LABEL CChart::GetSelValString(const PointD selPntD, string_t nameX, 
            int precision, val_label_str_fn pLabValXStrFnPtr)
{
  TUPLE_LABEL tuple_label;
  get<IDX_LNAME>(tuple_label)  = m_label; 
  get<IDX_LNAMEX>(tuple_label) = nameX;
  bool bAddEqSign = nameX.empty() ? false : true;
  get<IDX_LX>(tuple_label)     = pLabValXStrFnPtr(selPntD.X, precision, bAddEqSign);
  get<IDX_LNAMEY>(tuple_label) = m_labelY;
  bAddEqSign = m_labelY.empty() ? false : true;
  get<IDX_LY>(tuple_label)     = m_pLabYValStrFn(selPntD.Y, m_precisionY, bAddEqSign);

  int alpha = max(m_colChart.GetAlpha(), 128);  // TODO: Use definition instead of number
  Color labCol = SetAlpha(m_colChart, alpha);
  get<IDX_LCOLOR>(tuple_label) = labCol;

  get<IDX_LDASH>(tuple_label) = m_dashStyle;
  get<IDX_LPEN>(tuple_label)  = m_fPenWidth;

  return tuple_label;
}

让我们谈谈精度。这是容器精度,由用户或外部应用程序设置。该函数只是将其传递给容器格式化函数的指针pLabValXStrFnPtr

get<IDX_LX>(tuple_label) = pLabValXStrFnPtr(selPntD.X, precision, bAddEqSign);

容器将元组打包到multimap std::multimap<int, TUPLE_LABEL>中,并将multimap传递给其成员CDataWnd* CChartContainer::m_dataWnd。Multimap键是图表ID。我们使用multimap是因为图表数据向量可能具有相同X值和不同或相同Y值的多个数据点(想想方波)。

图表容器仅用可见图表的数据点信息元组填充此multimap。因此,选定数据点的multimap可能比CDataWnd m_mapLabs的元素少,或者根本没有条目。

收到multimap后,m_dataWnd就可以开始渲染自身了。

绘制本身很简单。首先,如果尚未完成,我们需要将一个窗口附加到m_pDataWnd。我们调用CDataWnd::CreateLegend(CWnd* pParent, CPoint origPnt, bool bData)来做到这一点。它是MFC函数CreateEx的包装器。

显然,父窗口是图表容器。标志bool bData指定子窗口的类型:它是数据标签还是名称标签。

我们事先不知道选定的点和点的值X和Y,也不知道所有可见图集和点的时间。这意味着标签窗口的大小也未知。为了计算标签窗口矩形,我们需要paintDC(更准确地说,是Graphics对象)。因此,CreateEx在x、y、宽度和高度为零的情况下调用。创建后,我们可以从窗口的DC获取Graphics对象,计算矩形和窗口位置,并将窗口移动到该位置。但首先,我们必须计算包围所有要显示的字符串的文本矩形。

我们通过迭代m_pDataWndm_mapLabs来做到这一点。我们分别使用函数Gdiplus::MeasureString搜索最长的图表名称、最长的X值、最长的Y名称和最长的Y值字符串。不幸的是,固定字符宽度的字体在屏幕上看起来不太好,所以我不得不使用可变字符宽度的字体。这意味着MeasureString应该应用于每个字符串,而不是最长字符串。这对于数据和名称标签无关紧要,因为容器中只有10-20个图表,但当我们必须计算具有数千个数据点的数据视图的布局时,显示视图可能会出现明显的延迟。对于1000-5000个数据点,延迟仍然是可接受的。对于更大的向量,我们显示消息框“正在计算...”再次,这个问题只存在于数据视图窗口的大型数据向量中。

文本矩形的宽度是MeasureString返回的最大边界矩形宽度的总和。高度是边界矩形的高度乘以CDataWnd::m_mapLabs的大小。总宽度应包括额外的间距。

最后,我们必须决定如何放置标签相对于请求点。通常,如果请求点位于容器窗口的左半部分,我们将标签放在请求点的右侧;如果请求点位于右半部分,则放在左侧。如果请求点左侧没有足够的空间,我们将把标签的左边界放在靠近客户区左边界的地方。右边界也是如此。

任务:跟踪数据标签

请求点的位置及其中心所在的区间在请求的生命周期内是恒定的(在数据空间中)。选定点的 multimap 也是恒定的。因此,我们不必再次搜索最近的点。

改变的是请求点在客户区的位置以及区间的值。例如,假设容器的X范围是-10.0...10.0,请求点的X坐标是0.0。然后在客户区,该坐标映射到X = 0.5*clientRect.Width。现在,我们将容器缩放到范围-4.0...1.0。现在0.0映射到0.8*clientRect.Width

因此,我们需要将容器客户区矩形的边界映射到数据空间,并将适合此变换后的矩形且可见的选定点传递给m_pDataWnd。实际上,只有客户区矩形的Y边界应该映射到数据空间。X边界始终等于容器的X范围。我们还必须考虑每个可见图表的局部Y缩放比例。为了更新数据窗口,我们使用函数

size_t CChartContainer::UpdateDataLegend(MAP_SELPNTSD& mapSelPntsD, MAP_LABSTR& mapLabStr)
{
  mapLabStr.clear();
  if (!mapSelPntsD.empty()&& in_range(m_startX, m_endX, m_dataLegPntD.X))
  {
    CRect clRect;
    GetClientRect(&clRect);
    CPoint pntLimYL(0, clRect.bottom);
    CPoint pntLimYR(0, clRect.top);
    PointD pntLimYLD, pntLimYRD;
    MousePntToPntD(pntLimYL, pntLimYLD, m_pMatrixD);
    MousePntToPntD(pntLimYR, pntLimYRD, m_pMatrixD);

    MAP_SELPNTSD::iterator itSel = mapSelPntsD.begin();
    MAP_SELPNTSD::iterator itSelE = mapSelPntsD.end();
    while(itSel != itSelE)
    {
      int chartIdx = itSel->first;
      CChart* chartPtr = GetChart(chartIdx);
      if (chartPtr != NULL)
      {
        if (chartPtr->IsChartVisible())
        {
          PointD selPntD = itSel->second;
          if (in_range(m_startX, m_endX, selPntD.X)&&
            in_range(pntLimYLD.Y, pntLimYRD.Y, selPntD.Y*chartPtr->GetLocScaleY()))
          {
            TUPLE_LABEL tuple_res = chartPtr->GetSelValString(
                        selPntD, m_labelX, m_precision, m_pLabValStrFnPtr);
            mapLabStr.insert(MAP_LABSTR::value_type(chartIdx, tuple_res));
          }
        }
        ++itSel;
      }
      else
       itSel = mapSelPntsD.erase(itSel);
    }
  }

  CPoint origPnt(-1, -1); // Not used on empty mapLabStr
  if (!mapLabStr.empty())
  {
    PointF origPntF = m_pMatrixD->TransformToPntF(1.0, m_dataLegPntD);
    origPnt = CPointFromPntF(origPntF);
  }
// Recalc dataWnd window rects and show data wnd or will hide it
  m_pDataWnd->UpdateDataLegend(mapLabStr, this, origPnt);
  return mapLabStr.size();
}
 

此函数迭代mapSelPntsD。映射的键是图表ID,值是选定的数据点。如果图表可见且选定的数据点在客户区矩形内,则函数为该图表调用GetSelValString,并将结果添加到mapLabs。请注意,选定的点必须在客户区矩形内,而不是在epsX区间内。该区间之前用于搜索相邻点。

(如果mapSelPntsD即将被更改,则从头开始设置mapSelPntsD,使用CChartContainer::PrepareDataLegend(PointD origPntD, double epsX, MAP_LABSTR& mapLabels, MAP_SELPNTSD& mapSelPntsD, bool bChangeMatrix)以及m_dataLegPntDm_epsX会更便宜。)

任务:显示图表名称

为了显示图表名称,我们使用相同的技术和相同的CDataWnd类。容器的数据成员是指向该类实例的指针m_pLegWnd。名称字符串由一个短线组成,用于显示图表的颜色、虚线样式和画笔宽度,以及图表名称。图表名称窗口是容器的子窗口,并且始终位于容器窗口的右上角。

任务:沿X轴缩放和平移(保留X历史记录)

沿X轴的缩放和平移本身是平凡的工作。您只需设置容器新的X范围m_startXm_endX并要求容器更新其在屏幕上的图像。更复杂的问题是如何保留历史记录。我们需要历史记录来撤销缩放/平移。我们为X和Y轴分别保留历史记录。这里我们讨论X历史记录。

我们将历史记录存储为向量m_vHistXCChartContainer数据成员)中旧的m_startXm_endX对。在我们设置新的m_startXm_endX之前,我们只是push_back()旧的m_startXm_endX对。要撤销操作,我们将使用保存的值来重置m_startXm_endX

当改变容器的完整X范围时,情况变得更有趣。这可能发生在添加图表、追加或截断图表数据向量、删除图表或简单地更改X范围时。

为了理解这个问题,让我们考虑一个情况,当您想分析图表曲线的某个部分时。您已经缩放了容器并正在查看曲线,突然,应用程序决定向某个图表追加一部分数据点。如果容器立即更新其X范围,您正在忙于分析的图像将毁于一旦。如果它不更新,您将丢失新的范围。

容器的完整X范围始终保存在历史记录向量的第一个元素中。因此,解决此问题的方法是更新向量的第一个元素而不更改m_startXm_endX的当前值。

函数CChartContainer::UpdateExtX正是这样做的

void CChartContainer::UpdateExtX(double minExtX, double maxExtX, bool bRedraw)
{
  if (maxExtX < minExtX)                   // Possible if from app
    return;

  double initStartX = GetInitialStartX();   // Old initial m_startX, m_endX
  double initEndX   = GetInitialEndX();

  double startX, endX;

  if (initStartX > initEndX)  // The container is empty
  {
    startX = minExtX;
    endX   = maxExtX;
  }
  else
  {
    startX = min(minExtX, initStartX);
    endX   = max(maxExtX, initEndX);
  }

  if (startX == endX)
  {
    endX += fabs(startX*0.01);;
  }

  if (m_vHistX.size() > 0)   // Was zoomed or panned
    m_vHistX.front() = make_pair(startX, endX);
  else                      // Has no history
  {
   m_startX = startX;
   m_endX   = endX;
  }

  if (bRedraw)
  {
    if (m_bTracking&& IsLabWndExist(true))
      UpdateDataLegend(false);
    else
      RefreshWnd();
  }
}

请注意这段代码

if (startX == endX)
{
    endX += fabs(startX*0.01);;
}

如果我们只有每个图表一个数据点,并且这些数据点的X坐标相同,则startX = endX。为了绘制容器,我们需要一些非零的X范围。因此,我们人为地将endX设置为比startX远1%。

如果X范围更改被缩放或平移模式隐藏,应用程序应决定如何以及何时通知用户X范围更改。

任务:沿Y轴缩放和平移(保留Y历史记录)

事实证明,设计和编码垂直缩放和平移比水平缩放/平移要复杂得多。

首先,我们对图像的水平和垂直尺寸的感知不同。想想家庭聚会照片:我们会原谅从左侧或右侧稍微裁剪照片,但我们会隐含地要求并期望在亲戚头顶上留有一些空白空间。

我考虑了这一点:最初图表曲线只填充客户区矩形高度的0.8。这个绘图空间在客户区矩形中的位置取决于X轴的位置。这很简单:您只需将Y缩放比例计算为0.8*clientRect.Height/Yextent

现在进入垂直缩放。您划定缩放边界,并且希望图像填充整个垂直空间,即整个客户区矩形高度。因此,现在您必须将Y缩放比例计算为clientRect.Height/Yextent

与此同时,垂直平移只应移动上一次操作获得的绘图空间。

因此,垂直缩放/平移使用函数

void CChartContainer::UpdateExtY(double minExtY, double maxExtY, bool bRedraw)
{
  if (maxExtY < minExtY)                   // Possible if from app
    return;

  double initMinY = GetInitialMinExtY();   // Old initial m_startX, m_endX
  double initMaxY = GetInitialMaxExtY();

  double startY, endY;

  if (initMinY > initMaxY)  // The container is empty
  {
    startY = minExtY;
    endY   = maxExtY;
  }
  else
  {
    startY = min(minExtY, initMinY);
    endY   = max(maxExtY, initMaxY);
  }

  if (startY == endY)
  {
    double delta = fabs(startY*0.01);
    startY -= delta*4.0;
    endY   += delta;
  }

  if (m_vHistY.size() > 0)   // Was zoomed or panned
    m_vHistY.front() = make_pair(startY, endY);
  else                      // Has no history
  {
   m_minExtY = startY;
   m_maxExtY = endY;
  }

  if (bRedraw)
  {
    if (m_bTracking&& IsLabWndExist(false))
      UpdateDataLegend(true);
    else
      RefreshWnd();
  }
}

但它在函数中解决了垂直缩放比例的问题

PAIR_DBLS CChartContainer::UpdateScales(const RectF drawRectF, 
                                  double startX, double endX, double minY, double maxY)
{
  if (m_mapCharts.empty())
    return make_pair(1.0, 1.0);

  RectF dRF = drawRectF;
  if ((m_chModeY == MODE_FULLY)||(m_chModeY == MODE_MOVEDY)||(m_chModeY == MODE_MOVEY))
    dRF.Inflate(0.0f, -0.1f*drawRectF.Height); // Reserve 20% to beautify full picture
  double scX = UpdateScaleX(dRF.Width, startX, endX);
  double scY = UpdateScaleY(dRF.Height, minY, maxY);
  return make_pair(scX, scY);
}

同样,注意startY == endY的修正。 

我们还没有完成绘图空间的工作:我们必须解决撤销垂直缩放/平移的问题。问题是什么?假设我们已经从历史记录向量m_vHistY中恢复了之前的m_minExtY, m_maxExtY。我们需要使用哪个垂直绘图空间来计算scaleY?如果我们撤销一系列操作MoveY1 - ZoomY1 - MoveY2 - ZoomY2 - MoveY3,显然,直到ZoomY1,我们都必须使用完整的客户区矩形高度,并在撤销ZoomY1时返回到0.8H。幸运的是,区分移动和缩放很容易:如果我们平移,startYendY值的变化是相等的。

因此,有一个函数

void CChartContainer::UndoHistStepY(bool bRedraw)
{
  if (m_vHistY.empty())
    return;

  PAIR_POS zh = m_vHistY.back();
  m_minExtY = zh.first;
  m_maxExtY = zh.second;

  if (m_vHistY.size() > 1)    // Must check whether it is moves only
  {
    auto itZ = adjacent_find(m_vHistY.rbegin(), m_vHistY.rend(),
      [](const PAIR_POS& lhs, const PAIR_POS& rhs) ->bool 
          {return (fabs(1.0 - fabs((rhs.first - lhs.first)/(rhs.second - lhs.second))) > 
                                                                   4.0*DBL_EPSILON);});  
    if (itZ == m_vHistY.rend()) 
       m_chModeY = MODE_MOVEDY;
    else
      m_chModeY = MODE_ZOOMEDY;
  }
  else
    m_chModeY = MODE_FULLY;

  m_vHistY.pop_back();

  if (bRedraw && IsWindow(m_hWnd) && IsWindowVisible())
  {
    if (m_bTracking && (m_pDataWnd != NULL))
      UpdateDataLegend(true);
    else
      RefreshWnd();
  }
}

在那里,我们使用std::adjasent_find与lambda表达式。当minYmaxY的变化不相等时,表达式返回true。此算法从历史记录向量的末尾开始,并在找到zoomY时返回。如果没有保存缩放,您必须使用0.8H绘图空间。

平等度量是:1.0与两个相邻minY之间差值与两个相邻maxY之间差值之比的差值。标准是4*DBL_EPSILON,即最小的使得1.0+DBL_EPSILON !=1.0。由于浮点数的怪异之处,我不能只使用差值。

最后,我们必须决定如何处理空空间的缩放/平移。显然,缩放没有可见数据点的空间没有意义,但平移呢?如果您沿X轴平移,您可能会处于某个山谷中,并会看到一些现在被隐藏的数据点。但是对于Y轴平移,如果您在容器窗口中看不到任何数据点,那么如果您继续沿同一方向移动,您也不会看到它们。因此,当新的容器范围不包含任何可见数据点时,沿Y轴的缩放/平移将被阻止。

任务:显示图表数据  

图表数据视图将选定图表的数据向量显示为表格。您可以从容器的弹出菜单或通过编程方式调用数据视图。您可以选择要显示的数据视图。

显示整个表格可能需要很多行,所以我选择了一种分页结构来一次显示一页,而不是滚动。为了节省屏幕空间,我将尽可能多的行和列挤进一页。

为了在页面之间导航和打印数据,我们需要按钮。最好有带有位图的按钮,但您无法将资源文件、外部图标和位图嵌入MFC静态库中(*参见此处*)。因此,数据视图在运行时构建位图按钮(出于相同的原因,容器的弹出菜单也是在请求时构建的,在鼠标右键单击时)。

数据视图的所有功能都在类CChartDataView中实现。该类派生自CWnd

响应显示数据视图的请求,容器调用

bool CChartContainer::ShowDataView(CChart* chartPtr, bool bClearMap, bool bRefresh)
{
  if (m_pChartDataView == NULL)
    m_pChartDataView = new CChartDataView;

  if (m_pChartDataView != NULL)
  {
    if (!IsWindow(m_pChartDataView->m_hWnd))
    {
      CRect parentWndRect;
      GetParent()->GetWindowRect(&parentWndRect); // App main dlg window

      CRect workRect;
      SystemParametersInfo(SPI_GETWORKAREA, NULL, &workRect, 0);

      int leftX  = parentWndRect.right + DV_SPACE;
      int rightX = leftX + DV_RECTW;
      int topY   = parentWndRect.top - DV_SPACE;
      int bottomY = topY + DV_RECTH;

      CRect dataViewRect(leftX, topY, rightX, bottomY);
      CRect interRect;
      interRect.IntersectRect(&dataViewRect, workRect);
      if (interRect != dataViewRect)
      {
        dataViewRect.right = workRect.right - DV_SPACE;
        dataViewRect.left = max(dataViewRect.right - DV_RECTW, workRect.left + DV_SPACE);
        dataViewRect.top = workRect.top + DV_SPACE;
        dataViewRect.bottom = min(dataViewRect.top + DV_RECTH, workRect.bottom - DV_SPACE);
      }
      BOOL bRes =  m_pChartDataView->CreateEx(0, 
                                 AfxRegisterWndClass(CS_HREDRAW|CS_VREDRAW|CS_SAVEBITS),
                                 _T("Chart Data View"), 
                                 WS_POPUPWINDOW|WS_CAPTION|WS_MINIMIZEBOX|WS_VISIBLE,
                                 dataViewRect.left, dataViewRect.top, 
                                 dataViewRect.Width(), dataViewRect.Height(),
                                 NULL, 
                                 NULL,
                                 NULL);
      if (!bRes)
      {
        delete m_pChartDataView;
        m_pChartDataView = NULL;
        return false;
      }
    }
    else if (m_pChartDataView->IsIconic())
      m_pChartDataView->ShowWindow(SW_RESTORE);

    int chartIdx = chartPtr->GetChartIdx();
    m_pChartDataView->ShowWaitMessage(chartIdx, chartPtr->m_vDataPnts.size());
    m_pChartDataView->InitParams(chartPtr, bClearMap, this);

    if (m_dataViewChartIdx != chartIdx)
    {
      m_dataViewChartIdx = chartIdx;
      bClearMap =true;
    }

    if (bClearMap)
    {
      m_mapDataViewPntsD.clear();
      if (bRefresh)
        RefreshWnd();
    }
  }
  return true;
}

这里有趣的点是计算视图的位置,创建数据视图中的控件,以及数据视图和容器之间的通信。

我想将数据视图窗口的大小设置为接近信函格式(8.5"x11"),以获得所见即所得的打印效果。然而,这种格式对于大多数显示器来说太大了。我选择了尺寸DV_RECTW = 710DV_RECTH = 874像素。每英寸96像素,相当于7.4"x9.1"。

定义了尺寸后,我尝试将数据视图矩形放置在应用程序主窗口(容器的父窗口)的右侧和上方50像素处。接下来,我使用SystemParametersInfoSPI_GETWORKAREA来获取显示器的可用区域。(VS帮助:“可用区域是屏幕中未被系统任务栏或应用程序桌面工具栏遮挡的部分”。)如果可用区域与新创建的数据视图矩形的交集小于该矩形,我将矩形向左移动并调整其垂直位置。因此,如果空间足够,数据视图窗口就不会与应用程序主窗口重叠。

数据视图窗口被创建为弹出窗口,以便在屏幕上定位它时有一定的灵活性。

创建视图后,容器调用函数将图表数据传递给视图。

如果窗口已创建,并在某个时刻被最小化,然后又被调用ShowWindow,我们就会遇到问题:最小化窗口具有空的窗口矩形。这将导致InitParams中的CalcLayout函数崩溃。因此,在ShowWindow中有这样一行

else if (m_pChartDataView->IsIconic())
      m_pChartDataView->ShowWindow(SW_RESTORE);

函数CChartDataView::InitParams初始化数据视图

void CChartDataView::InitParams(const CChart* chartPtr, bool bClearMap, const CChartContainer* pHost)
{
  m_chartIdx      = chartPtr->GetChartIdx();
  m_precision     = pHost->GetContainerPrecisionX();
  m_precisionY    = chartPtr->GetPrecisionY();
  m_label         = chartPtr->GetChartName();
  string_t tmpStr = pHost->GetAxisXName();
  m_labelX        = tmpStr.empty() ? string_t(_T("X")) : tmpStr;
  tmpStr          = chartPtr->GetAxisYName();
  m_labelY        = tmpStr.empty() ? string_t(_T("Y")) : tmpStr;
  m_pXLabelStrFn  = pHost->GetLabXValStrFnPtr();
  m_pYLabelStrFn  = chartPtr->GetLabYValStrFnPtr(); 
  m_vDataPnts     = chartPtr->m_vDataPnts;

  m_vStrX.resize(m_vDataPnts.size());
  transform(m_vDataPnts.begin(), m_vDataPnts.end(), m_vStrX.begin(),
                   nmb_to_string<double, false>(m_precision, m_pXLabelStrFn));
  m_vStrY.resize(m_vDataPnts.size());
  transform(m_vDataPnts.begin(), m_vDataPnts.end(), m_vStrY.begin(),
                   nmb_to_string<double, true>(m_precisionY, m_pYLabelStrFn));
  m_currPageID = 0;

  SetOwner((CWnd*)pHost);
  m_vRows.clear();
  if (bClearMap)    
    m_mapSelCells.clear();
  else
    UpdateDataIdx();

  CalcLayout();
  m_header = GetTableHeader();   // Set the header string

  CreateChildren();

  bool bEnableLeft = m_currPageID == 0 ? false : true;
  bool bEnableRight = m_nPages == 1 ? false : true;

  m_leftEnd.EnableWindow(bEnableLeft ? TRUE:FALSE);
  m_leftArr.EnableWindow(bEnableLeft ? TRUE:FALSE);

  m_rightArr.EnableWindow(bEnableRight ? TRUE:FALSE);
  m_rightEnd.EnableWindow(bEnableRight ? TRUE:FALSE);

  if (IsWindow(m_hWnd)&&IsWindowVisible())
    RedrawWindow(NULL, NULL, RDW_INVALIDATE | 
                 RDW_UPDATENOW | RDW_NOERASE|RDW_ALLCHILDREN);
}

容器被设置为数据视图的所有者,因此视图将自动与容器一起隐藏、显示和关闭。

为了加速绘图,我们提供了两个辅助字符串向量,m_vStrX用于X值,m_vStrY用于图表数据向量的Y值。我们使用算法std::transform和自定义谓词模板<typename T, bool bY> struct nmb_to_string(请参阅*Util.h*)。

导航按钮是CPageCtrl : piblic CButton类的实例。按钮作为m_pDataView的子项创建。按钮的位图绘制嵌入在CPageCtrl::OnPaint中(有关详细信息,请参阅*DataView.cpp*)。

现在让我们讨论数据视图和容器之间的通信。我们需要通知容器何时在表中选择/取消选择一个单元格。然后,容器将在容器窗口的图表曲线上显示/隐藏在数据视图中选择的数据点。此外,数据视图需要信息来修改数据视图,如果容器名称、图表数据向量或/和X、Y轴名称、精度或/和格式化函数在容器中发生更改。

数据视图有一个它显示的图表数据向量的副本,CDataView::m_vDataPnts

它还将所有选定单元格的数据点保存在映射CDataView::m_mapSelCells中。映射元素的键是单元格的ID。当选择发生变化时,数据视图会更新映射。

容器中拥有此映射的副本CChartContainer::m_mapDataViewPntsD。在数据视图中选择更改后,数据视图调用容器的函数

CChartContainer* pContainer = static_cast<CChartContainer*>(GetOwner());
pContainer->UpdateDataViewPnts(m_chartIdx, dataID, dataPntD, bAdd)

容器使用m_mapDataViewPntsD在数据视图数据点中选定的数据点周围绘制圆圈。这使您能够确切地看到某个点在图表曲线上的位置。

显然,图表属性(如Y值的名称、Y精度和Y格式化函数)的变化可能会改变数据视图的布局。X值的名称和X格式化函数以及图表数据向量(例如,追加或截断)的变化也是如此。图表和容器名称的变化只影响页面标题。我们发现重新计算受影响的部分布局更方便。为此,我们使用函数CChartContainer::UpdateDataView。此函数调用CDataView::UpdateParams

bool CChartDataView::UpdateParams(const CChart* chartPtr, int flagsData)
{
  bool bRes = false;
  int flags = 0;
  size_t dataOffset = 0;

  int chartIdx = chartPtr->GetChartIdx();
  if (chartIdx == m_chartIdx)
  {
    CChartContainer* pHost = dynamic_cast<CChartContainer*>(GetOwner());
    ENSURE(pHost != NULL);
    if (!chartPtr->HasData())
    {
      pHost->DestroyChartDataView();
      return true;
    }

    m_label = chartPtr->GetChartName();    // Page header must be changed with new date stamp

    int precisionX = pHost->GetContainerPrecisionX();
    if (m_precision != precisionX)        // PrecisionX: entire X column is changing
    {
      m_precision = precisionX;
      flags |= F_VALX;
    }

    int precisionY = chartPtr->GetPrecisionY();
    if (m_precisionY != precisionY)       // PrecisionY: entire X column is changing
    {
      m_precisionY = precisionY;
      flags |= F_VALY;
    }

    string_t tmpStr = pHost->GetAxisXName();
    string_t labelX = tmpStr.empty() ? string_t(_T("X")) : tmpStr;
    if (m_labelX != labelX)               // X-axis name: column header and width might change
    {
      m_labelX = labelX;
      flags |= F_NAMEX;
    }

    tmpStr          = chartPtr->GetAxisYName();
    string_t labelY = tmpStr.empty() ? string_t(_T("Y")) : tmpStr;
    if (m_labelY != labelY)               // Y-axis name: column header and width might change
    {
      m_labelY = labelY;
      flags |= F_NAMEY;
    }

    val_label_str_fn pXLabelStrFn  = pHost->GetLabXValStrFnPtr();
    if (m_pXLabelStrFn != pXLabelStrFn)   // Entire X-column should be changed
    {
      m_pXLabelStrFn = pXLabelStrFn;
      flags |= F_VALX;
    }

    val_label_str_fn pYLabelStrFn = chartPtr->GetLabYValStrFnPtr(); 
    if (m_pYLabelStrFn != pYLabelStrFn)   // Entire Y-column should be changed
    {
      m_pYLabelStrFn = pYLabelStrFn;
      flags |= F_VALY;
    }

    if (flagsData != F_NODATACHANGE)
    {
      size_t endOffs = 0;
      switch (flagsData)
      {
      case F_APPEND:  
        endOffs = OnChartAppended(chartPtr->m_vDataPnts);
        if (!(flags & (F_VALX|F_VALY|F_DSIZE)))
        {
          dataOffset = endOffs;
        }
        flags |= (F_VALX|F_VALY|F_DSIZE);
        break;
      case F_TRUNCATE:
        endOffs = OnChartTruncated(chartPtr->m_vDataPnts);
        if (!(flags & (F_VALX|F_VALY|F_DSIZE)))
        {
          dataOffset = 0;
        }
        flags |= (F_VALX|F_VALY|F_DSIZE);
        break;
      case F_REPLACE:
      case F_REPLACE|F_HASCELLSMAP:
       dataOffset = OnChartDataReplaced(
            chartPtr->m_vDataPnts, flags&F_HASCELLSMAP ? true : false);
       flags |= (F_VALX|F_VALY|F_DSIZE);
       break;
      }
    }
    else
    {
      if (flags & F_VALX) 
      {
        transform(m_vDataPnts.begin() + dataOffset, m_vDataPnts.end(), 
              m_vStrX.begin() + dataOffset, nmb_to_string<double, 
              false>(m_precision, m_pXLabelStrFn));
      }

      if (flags & F_VALY)
      {
        transform(m_vDataPnts.begin() + dataOffset, m_vDataPnts.end(), 
              m_vStrY.begin() + dataOffset, nmb_to_string<double, 
              true>(m_precisionY, m_pYLabelStrFn));
      }
    }

    if (IsIconic())
      ShowWindow(SW_RESTORE);

// Set the header string
    m_header = GetTableHeader();

    m_vRows.clear();
    if ((flags != 0)&&(dataOffset != m_vDataPnts.size()))
      CalcLayout(flags, dataOffset);

    if (flagsData != F_NODATACHANGE)
    { 
      if (flagsData & F_TRUNCATE)
      {
        if (m_nPages <= m_currPageID)
          m_currPageID = 0;
      }
      else if ((flagsData & F_APPEND) == 0)
        m_currPageID = 0;
    }
    else 
      m_currPageID = 0;
     
 
    bool bEnableLeft = m_currPageID == 0 ? false : true;
    bool bEnableRight = (m_currPageID == (m_nPages - 1)) ? false : true;

    m_leftEnd.EnableWindow(bEnableLeft ? TRUE:FALSE);
    m_leftArr.EnableWindow(bEnableLeft ? TRUE:FALSE);

    m_rightArr.EnableWindow(bEnableRight ? TRUE:FALSE);
    m_rightEnd.EnableWindow(bEnableRight ? TRUE:FALSE);

    if (IsWindow(m_hWnd)&&IsWindowVisible())
      RedrawWindow(NULL, NULL, RDW_INVALIDATE | RDW_UPDATENOW | 
                   RDW_NOERASE|RDW_ALLCHILDREN);
    bRes = true;
  }
  return bRes;
}

此函数查找已更改的图表属性,并设置相应的标志。标志控制数据视图执行的任务以反映更改。作为参数传递给函数的flagsData告知函数图表数据向量的变化。此信息用于计算数据视图更新后将显示哪个页面。如果旧页面仍然包含一些数据点,则可能是旧页面,如果旧页面被截断,则可能是第一页。我建议您参考*ChartDataView.cpp*了解更多详细信息。

任务:打印

您可以从容器的弹出菜单或通过编程方式打印容器窗口。您还可以从数据视图窗口打印图表数据表。

让我们从容器开始。

首先,让我说明一下我们要打印的不是所见即所得。如果用户决定只打印一个图表,我们将只打印一个选定的图表。否则,我们将打印所有可见的图表。其次,在屏幕上,为了深入了解细节,我们始终可以移动图表、放大、隐藏数据和名称标签等。打印是永久的。因此,为了不遮挡图表曲线,我们将不显示数据和名称窗口。相反,我们将在容器窗口下方打印图表信息。为了使打印的测量和计算成为可能,我们将Y缩放值包含在图表信息中,并始终打印X轴标签。第三,打印的主体实现为静态函数CChartContainer::PrintCharts。我们这样做是为了允许从工作线程进行打印。

在文章KB133275中,微软解释了如何从MFCCView以外的类进行打印。此外,互联网上还有一个关于GDI打印、GDI+打印的教程。教程过于复杂;微软没有提及GDI+。

尽管如此,我还是遵循了微软示例代码的框架。

打印代码在函数CChartContainer::PrintCharts(CChartContaner* pContainer, float dpiRatioX, HDC printDC)(*ChartContainer.cpp*)中。

应用程序必须准备参数并将其传递给函数。代码应如下所示

 .................................................
    int scrDpiX = GetScreenDpi();
    SendNotification(CODE_PRINTING);
    CChartContainer* pContainer = CloneChartContainer(string_t(_T("")), true);
    SendNotification(CODE_PRINTED);
    PrintCharts(pContainer, scrDpiX, printDlg.GetPrinterDC());
    delete pContainer;

首先,我们克隆容器。克隆继承了祖先的名称和状态。没有窗口附加到克隆:我们不需要它。在克隆之前和之后,我们向容器的父窗口发送通知。它可以在多线程环境中使用,也可以完全忽略它们。

我们使用克隆是因为打印区域(页面)的大小与祖先客户区矩形的大小不同。我们的绘图函数使用容器的变换矩阵,因此我们必须为打印重新计算容器的变换矩阵。我们还将更改克隆的状态,以允许打印X轴标签。

其次,我们通过调用计算祖先的每英寸分辨率(DPI)

int CChartContainer::GetScreenDpi(void)
{
    CPaintDC containerDC(this);
    int scrDpiX = containerDC.GetDeviceCaps(LOGPIXELSX);
    int scrDpiY = containerDC.GetDeviceCaps(LOGPIXELSY);
    ENSURE(scrDpiX == scrDpiY);
    return scrDpiX;
} 

稍后我将解释为什么我们需要它进行打印。

第三,我们需要打印机DC。

如果我们从MFC对话框CPrintDialog开始,选择打印机并单击“确定”按钮后,打印机DC的句柄是:

HDC printDC = printDlg.GetPrinterDC(); 

现在我们可以调用PrintCharts

我们获取CDC的指针,并将printerDC附加到它,遵循KB133275

CDC* pDC = new CDC;
pDC->Attach(printDC); 

我们创建一个Gdiplus::Graphics对象并设置文档单位

Graphics* grPtr = new Graphics(printDC);
grPtr->SetPageUnit(UnitDocument);

此模式显示每英寸300 DPI。

设置页面单位后,所有GDI+函数都将把传递给它们的任何值理解为UnitDocument值。例如,如果我们将画笔宽度设置为2,则在屏幕上是英寸,在纸上是英寸。因此,我们必须纠正打印中使用的所有文字的字面值。

我们使用scrDpiX参数来获取dpiRatioX = 300.0f/scrDpiX。对于屏幕,此比率为1.0,因此如果我们想调整打印的画笔宽度,我们应该编写pen.SetWIdth(width*dpiRatio)

最后一个准备工作:获取客户区矩形

RectF rGdiF;
grPtr->GetVisibleClipBounds(&rGdiF); 
// The same as the clip rect

最后,开始打印

pDC->StartDoc(pContainer->m_name.c_str());
// MFC functions

pDC->StartPage();

我之前提到图表信息字符串打印在容器窗口下方。图表信息包括图表名称、该图表在数据空间中的Y缩放比例(每英寸屏幕上的Y单位)、X轴名称、X值字符串、Y轴名称以及祖先中显示的数据标签中的数据点的Y值。如果没有选定的点,或者选定的点超出视图(由于缩放/平移),则打印图表的最小和最大Y值,而不是X和Y名称及值字符串。信息字符串前面的短线与图表的颜色、虚线样式和画笔宽度相同。这有助于轻松识别图表。

如果容器中的图表过多,图表信息行可能会延续到下一页。每页都有一个标题:容器的名称,以及打印开始的时间。

在打印标题和图表信息的绘图函数中,我们使用点单位来设置字体大小。因为我们将页面单位设置为UnitDocument,所以字体大小是相同的,无论打印机分辨率如何。

打印完成后,我们应该清理: 

// End printing
pDC->EndPage();
pDC->EndDoc();

delete grPtr;
pDC->Detach();
delete pDC; 

注意:有时您可能会看到数据点周围有圆圈,但这些圆圈在屏幕上不可见,因为打印的页面大小大于容器客户窗口。如果您不需要它们,请使用容器弹出菜单隐藏它们,或在打印前调用函数CChartContainer::ShowChartPoints(int chartIdx, bool bShow, bool bRedraw),其中chartIdx = -1bShow = false。打印后恢复ShowChartPoints的状态。

数据视图的打印也类似。 

任务:保存图表数据

您可以保存图表的数据向量。您还可以将选定的或所有可见的图表,或所有图表连同它们的视觉属性和数据系列保存到XML文件中。

要获取图表数据向量,您可以调用函数重载之一

CChartContainer::ExportChartData(string_t chartName, V_CHARTDATAD& vDataPnts);

重载函数用std::vector<std::pair<double, double> >或一对向量std::vector<double>& vXstd::vector<double>& vY代替数据点向量V_CHARTDATAD& vDataPnts。因为图表ID是图表控件的内部参数,所以我们按名称选择容器的图表。

要将数据保存到XML文件,我们使用函数HRESULT CChartContainer::SaveChartData(string_t pathName, bool bAll)

实际上,所有与XML文件转换相关的功能都放在类CChartsXMLSerializer中。

静态函数CChartsXMLSerializer::ChartDataToXML(pathName.c_str(), pContainer, chartName, bAll)将图表属性和数据向量转换为XML。SaveChartData为该函数提供参数。

首先,它处理pathName。如果名称为空字符串,则向用户显示MFCCFileDialog以设置路径和XML文件名。

其次,SaveCharts克隆容器以实现多线程。在克隆之前和之后,函数向容器的父窗口发送通知。我们克隆容器是因为对于大数据向量,转换为XML可能需要很长时间。XML转换器操作的是克隆,而不是容器本身。

最后,函数查看容器图表以将图表名称传递给序列化器。参数bAll告诉要保存哪些图表。如果bAll = true,则图表名称必须是可见图表的名称。如果图表不可见,则什么也不会保存。如果bAll = false,则图表名称可以是任何图表的名称,无论是否可见。如果chartName是空字符串,则将保存所有可见图表或所有容器图表,具体取决于参数bAll;如果传递了现有图表的名称,则只保存该图表。SaveChart查找选定的图表。如果存在一个,则将其名称传递给转换器函数,并且只保存它。在这种情况下,bAll没有区别,因为只有可见图表才能被选中。

您可以从容器的弹出菜单或直接从应用程序调用SaveChartData。弹出菜单会自动以空的pathNamebAll = false调用该函数。您的应用程序可以使用这些参数的任何组合。空pathName意味着用户将看到一个MFC CFileDialog实例来选择路径和文件名。请记住,bAll = true表示将保存容器中的所有图表(如果没有选定的图表);bAll = false表示只保存可见的图表。

用户或程序员可以间接控制图表的选择,方法是选择要保存的图表或使所有他想保存的图表可见。

我使用了MSXML6来完成这项工作。XML文件的结构如上所示。请注意,版本1.1中的XML架构已更改。容器无法将版本1.0保存的XML文件加载到版本1.1中。

任务:加载XML文件

要从XML文件加载图表,您必须调用函数

HRESULT CChartContainer::LoadCharts(LPCTSTR fileName, const MAP_CHARTCOLS& mapContent)
{
    HRESULT hr = CChartsXMLSerializer::XMLToCharts(fileName, this, mapContent);

    if (hr == S_OK)
    {
       if (IsLabWndExist(false))
         PrepareDataLegend(m_dataLegPntD, m_epsX, 
                           m_pDataWnd->m_mapLabs, 
                           m_mapSelPntsD, NULL);
 
       UpdateContainerWnds();
    }
    return hr; 

它调用: 

HRESULTULT CChartsXMLSerializer::XMLToCharts(LPCTSTR fileName, 
           CChartContainer* pContainer, const MAP_CHARTCOLS& mapContent)

XMLToCharts使用MSXML6读取XML文件,并将图表添加到容器。

这里我们有几个问题。首先,XML文件可能包含多个图表,但我们可能不想加载所有图表。其次,正在加载的图表的名称和视觉效果可能与容器中已有的图表的名称和视觉效果混淆。从一开始我们所知道的就是XML文件的名称。

要获取有关文件中图表的更多信息,请使用函数

HRESULT CChartContainer::GetChartNamesFromXMLFile(LPCTSTR fileName, MAP_CHARTCOLS& mapContent)
HRESULT CChartContainer::GetChartNamesFromXMLFile(LPCTSTR fileName, MAP_NAMES& mapNames)

最后一个函数是在版本1.1中引入的。

这些函数是CChartsXMLSerializer中具有相同名称和签名的函数的包装器。包装器使您不必在项目中包含额外的头文件*ChartXMLSerializer.h*。

第一个函数GetChartNamesFromXMLFile检索文件中的图表名称和颜色,并将它们存储在MAP_CHRTCOLS中。Map键是图表名称,值是图表颜色。给定此映射,您可以从中删除不需要的图表,并更改您决定加载到容器中的图表的颜色。调整映射后,将其传递给LoadCharts。当然,如果您知道图表的名称和颜色,也可以手动填充映射。也可以在加载图表后更改图表颜色。如果容器已包含同名图表,则LoadCharts可以在内部自动更改图表名称。XML文件中的名称不会改变。

第二个函数检索名称:图表名称、X和Y轴的名称,以及格式化的X和Y值字符串的样本。它为您提供了机会来编写并包含在您的应用程序中适当的格式化函数,并将它们注册到您的图表容器。

如果您将图表加载到已填充的容器中,并且容器处于跟踪模式,则必须更新数据和名称标签。事实证明,从头开始准备新的数据图例比更改选定数据点的现有映射在计算上更便宜。

函数UpdateContainerWnds重置容器窗口和标签到它们当前的状态。 

如果您只想用XML文件中的图表替换容器的图表,请使用函数HRESULT CChartContainer::ReplaceContainerCharts(LPCTSTR fileName)。颜色、名称等没有问题。

任务:将图表保存为图像

整体结构非常简单:生成一个带有图表的位图,然后使用 Gdiplus::Save(const WCHAR* filename, const CLSID* clsidEncoder, const EncoderParameters* encoderParams)将其保存为操作系统支持的任何图片格式。正如我上面提到的,在容器的OnPaint()函数中,所有绘图都是先在内存位图中完成,以避免闪烁,所以这部分任务可以使用OnPaint()中的代码来完成。尽管如此,问题还是存在:名称和数据标签显示为容器的子控件。子窗口有自己的OnPaint(),并且不会显示在父窗口的位图中。解决方案是使用子窗口位图中的代码。将子窗口绘制到主位图中,但要小心地定位子窗口布局矩形在主位图上。欲了解更多细节,请参阅ChartContainer.cpp中的CChartContainer::DrawContainerToBmp(Graphics*rGdi, Bitmap& bmp)

我认为支持的图片格式的枚举也很有趣。这是代码(在if (pathName.empty())之前)。

Status CChartContainer::SaveContainerImage(string_t pathName)
{
  if (!HasChartWithData(-1,true))   // Repeat to provide for standalone use
    return GenericError; 

  Status status = Aborted;
  UINT  num;        // number of image encoders
  UINT  size;       // size, in bytes, of the image encoder array
  
// How many encoders are there? How big (in bytes) is the array of all ImageCodecInfo objects?
  GetImageEncodersSize(&num, &size);
// Create a buffer large enough to hold the array of ImageCodecInfo objects
// that will be returned by GetImageEncoders.
  ImageCodecInfo* pImageCodecInfo = (ImageCodecInfo*)(malloc(size));;
// GetImageEncoders creates an array of ImageCodecInfo objects
// and copies that array into a previously allocated buffer. 
  GetImageEncoders(num, size, pImageCodecInfo);
// Get filter string
  sstream_t stream_t;
  string_t str_t, tmp_t;
  string_t szFilter;
  CLSID clsID;
  typedef std::map<string_t, CLSID> MAP_CLSID;
  typedef MAP_CLSID::value_type TYPE_VALCLSID;
  typedef MAP_CLSID::iterator IT_CLSID;

  MAP_CLSID mapCLSID;

  for(UINT j = 0; j < num; ++j)
  { 
    stream_t << pImageCodecInfo[j].MimeType <<_T("\n");   
    getline(stream_t, str_t);
    size_t delPos = str_t.find(TCHAR('/'), 0);
    str_t.erase(0, delPos + 1);
    clsID = pImageCodecInfo[j].Clsid;
    mapCLSID.insert(TYPE_VALCLSID(str_t, clsID));
    tmp_t = str_t;
    std::transform(tmp_t.begin(), tmp_t.end(), tmp_t.begin(), 
         [](const TCHAR&tch) ->TCHAR {return (TCHAR)toupper(tch);});
    szFilter += tmp_t + string_t(_T(" File|*.")) + str_t + string_t(_T("|")); 
  }
  szFilter += string_t(_T("|"));
  free(pImageCodecInfo);

  if (pathName.empty())     // Let the user choose
  {
    TCHAR szWorkDirPath[255];
    GetModuleFileName(NULL, szWorkDirPath, 255);
    PathRemoveFileSpec(szWorkDirPath);

    string_t dirStr(szWorkDirPath);
    size_t lastSlash = dirStr.find_last_of(_T("\\")) + 1;
    dirStr.erase(lastSlash, dirStr.size() - lastSlash);
    dirStr += string_t(_T("Images"));
    szFilter += string_t(_T("|"));
    CFileDialog fileDlg(FALSE, _T("BMP File"), _T("*.bmp"), 
        OFN_HIDEREADONLY|OFN_OVERWRITEPROMPT|OFN_NOCHANGEDIR|OFN_EXPLORER,
        szFilter.c_str(), this);

    fileDlg.m_ofn.lpstrInitialDir = dirStr.c_str();
    fileDlg.m_ofn.lpstrTitle = _T("Save As Image");
 
    string_t strTitle(_T("Save "));

    if (fileDlg.DoModal() == IDOK)
    {
      pathName = string_t(fileDlg.GetPathName());
    }
    else
      return Ok;
  }

  if (pathName.empty())
    return InvalidParameter;

  size_t pos = pathName.find(_T("."));
  if (pos == string_t::npos)
    return GenericError;
  
  string_t szExt = pathName.substr(pos);
  pos = szFilter.find(szExt);
  if (pos == string_t::npos)
    return UnknownImageFormat;
  szExt.erase(0, 1);

  IT_CLSID it = mapCLSID.find(szExt);
  if (it != mapCLSID.end())
  {
    SendNotification(CODE_SAVEIMAGE);
    CRect clR;
    GetClientRect(&clR);
    Rect rGdi = CRectToGdiRect(clR);
    Bitmap bmp(rGdi.Width, rGdi.Height);
    DrawContainerToBmp(rGdi, bmp);

    clsID = it->second;
    status = bmp.Save(pathName.c_str(), &clsID);
    SendNotification(CODE_SAVEDIMAGE);
  }
  else status = UnknownImageFormat; 
  return status;
}

就像将图表保存到XML文件一样,该函数接收要保存图片的文件的路径。路径可能是一个空字符串;如果是这种情况,将显示CFileDialog以要求用户选择路径和文件名。我在使用_tupr函数时遇到了问题,所以我使用了transform算法和lambda表达式将图片格式字符串转换为大写。当然,只有容器窗口中可见的内容才会被保存。

任务:改变容器大小

当容器父窗口的大小发生变化时,容器窗口可能不会收到WM_SIZE消息。例如,在基于对话框的应用程序中就会发生这种情况。

您必须从父窗口或容器所有者的适当处理程序中调用CChartContainer::OnChangedSize(int cx, int cy)来更改容器窗口的大小(请参阅演示中的“克隆容器”)。

OnChangedSize中有一个小技巧。当父窗口改变大小时,这不是一个连续的过程:用户有时会无意中中断鼠标的平滑移动。如果显示了数据和/或名称标签,它们会闪烁。因此,每次调用OnChangedSize都会隐藏可见标签,重绘容器,并重新启动定时器。定时器延迟为50毫秒,足够小以免造成干扰,但又足够大,可以在WM_SIZE中断之间保持定时器运行。最后,在调整大小结束后50毫秒,定时器过程会重新绘制屏幕上的标签。

在我的实践中,我有时会单独使用ChartCtrl(一个克隆),作为带有可调整边框的弹出窗口。在这种情况下,克隆会接收并必须处理WM_SIZE消息。所以现在图表容器有自己的OnSize处理程序。但请记住,如果图表容器是CDialog父窗口的子窗口,则不会调用此处理程序。

应用程序编程接口:图表接口

所有API函数都在ChartContainer.h文件中。我将只提及其中最重要的。

首先,有图表接口函数:AddChartAppendChartDataReplaceChartDataTruncateChartRemoveChart

我在“将图表添加到图表容器”一章中已经讨论了AddChartAppendChartDataTruncateChart

该函数

bool ReplaceChartData(int chartIdx, V_CHARTDATAD& vData, bool bClip = false, 
                         bool bUpdate = false,bool bVerbose = false, bool bRedraw = false);

用新的数据向量替换旧的图表数据。参数bUpdate定义是否应重新计算容器的X和Y扩展。如果bClip == true,则仅将vData中位于图表旧X范围之内的数据点复制过来。如果bVerbose == true,则会显示有关丢失旧数据点的警告。函数成功时返回true

有三种重载用于其他三种数据类型:时间序列、std::pair<double, double>向量,以及两个X和Y值向量。对于时间序列重载,您应该提供起始X坐标和X步长。

图表数据向量可以被空向量替换。这使得能够以相同的图表视觉效果重新开始。

该函数:

bool RemoveChart(int chartIdx, bool bCorrectMinMax, bool bRedraw)

正是如此:从容器中删除图表。如果bCorrectMinMax == true,容器将计算并设置X轴的新m_startXm_endX边界。函数成功时返回true

应用程序编程接口:访问图表属性

只能通过容器成员函数访问图表数据成员。大多数情况下,您需要将图表的ID传递给这些函数。图表在容器外部以其名称而闻名。要获取图表ID,您应该使用容器成员函数

int GetChartIdx(string_t chartName)

或存储并记住AddChart返回的值。

这里string_tstd::basic_string<TCHAR>的别名。

图表ID不能为负值。ID值-1具有特殊含义:如果它由“Get”函数返回,则表示失败(例如,图表不存在)。当它被传递给“Set”函数时,它表示“此容器中的所有图表”或“所有可见图表”。

改变容器外观的函数通常有一个参数bool bRedraw。如果设置为true,则容器将被重绘。

不幸的是,容器中的成员函数太多,无法一一讨论。请参阅ChartContainer.hChartContainer.cpp

应用程序编程接口:通知

正如我之前提到的,当用户更改容器的X扩展或图表的“显示/隐藏数据点”标志或/和从弹出菜单中可见性时,容器会向其父窗口发送通知。这使得父窗口能够对这些操作做出反应。通知是标准的MFC/Win32过程。容器将WM_NOTIFY消息发送到父窗口

LRESULT CChartContainer::SendNotification(UINT code, int chartIdx)
{
  NMCHART nmchart;
  nmchart.hdr.hwndFrom = m_hWnd;
  nmchart.hdr.idFrom = GetDlgCtrlID();
  nmchart.hdr.code = code;
  nmchart.chartIdx = chartIdx;

  switch (code)
  {
  case CODE_VISIBILITY: nmchart.bState = IsChartVisible(chartIdx);            break;
  case CODE_SHOWPNTS:   nmchart.bState = AreChartPntsAllowed(chartIdx).first; break;
  case CODE_EXTX:  
  case CODE_EXTY:
                        nmchart.minX   = GetStartX();
                        nmchart.maxX   = GetEndX();
                        nmchart.minY   = GetMinY();
                        nmchart.maxY   = GetMaxY();
                        break;
  case CODE_REFRESH:    nmchart.minX   = GetInitialStartX();
                        nmchart.maxX   = GetInitialEndX();
                        nmchart.minY   = GetInitialMinExtY();
                        nmchart.maxY   = GetInitialMaxExtY();
                        break;
  case CODE_SAVEIMAGE:
  case CODE_SAVEDIMAGE: 
  case CODE_SAVECHARTS:
  case CODE_SAVEDCHARTS: 
  case CODE_PRINTING:
  case CODE_PRINTED:    
  case CODE_SCY:        break;
  case CODE_TRACKING:   nmchart.bState = m_bTracking;
                        break;
  default:              return 0;
  }

  CWnd* parentPtr = (CWnd*)GetParent();
  if (parentPtr != NULL)
    return parentPtr->SendMessage(WM_NOTIFY, WPARAM(nmchart.hdr.hwndFrom), LPARAM(&nmchart));
  return 0;
}
  

通知代码和NMHDR结构的扩展定义在ChartDef.h中。

 
typedef struct tagNMCHART
{
  NMHDR hdr;
  int chartIdx;
  bool bState;
  double minX;
  double maxX;
  double minY;
  double maxY;
} NMCHART, *PNMCHART;


// Codes: Toggle Visibility
#define CODE_VISIBILITY  1U
// Show points
#define CODE_SHOWPNTS    2U
// Ext X was changed
#define CODE_EXTX        3U
#define CODE_EXTY        4u
#define CODE_REFRESH     5U
// Save (to use in multithreading)
#define CODE_SAVEIMAGE   6U
#define CODE_SAVEDIMAGE  7U
#define CODE_SAVECHARTS  8U
#define CODE_SAVEDCHARTS 9U
// Printing
#define CODE_PRINTING   10U
#define CODE_PRINTED    11U
// Scale change
#define CODE_SCY        12U
// For enabling tracking from popup menu
#define CODE_TRACKING   14U

一如既往,父窗口应该实现通知的MFC处理程序。例如

BEGIN_MESSAGE_MAP(CChartCtrlDemoDlg, CDialogEx)
  .................................
  ON_NOTIFY(CODE_VISIBILITY, IDC_STCHARTCONTAINER, OnChartVisibilityChanged)
  ...................................
END_MESSAGE_MAP()

afx_msg void OnChartVisibilityChanged(NMHDR*, LRESULT*); 

版本信息

在应用程序(.exe)中,所有版本信息都在.rc文件中的版本资源中。我们已经知道,资源文件不能包含在静态库文件中。所以我使用了占位符:根据MS的建议,我在ChartDef.h的开头放置了定义

// Version
//

#define FILEVER            2,0,1,1
#define PRODUCTVER         2,0,1,1
#define STRFILEVER         _T("2.0.1.1")
#define STRPRODUCTVER      _T("2.0.1.1")
#define STRCOMPNAME        _T("geoyar")
#define STRFILEDESCRIPTION _T("ChartCtrlLib")
#define STRFILENAME        _T("ChartCtrlLib.lib")
#define STRPRODNAME        _T("ChartCtrlLib")In the same file, I have defined access functions:
 
// Retrieve version info
inline string_t GetLibFileVersion(void){return string_t(STRFILEVER);}
inline string_t GetLibProductVersion(void) {return string_t(STRPRODUCTVER);}
inline string_t GetLibCompName(void) {return string_t(STRCOMPNAME);}
inline string_t GetLibFileDescr(void) {return string_t(STRFILEDESCRIPTION);}
inline string_t GetLibFileName(void) {return string_t(STRFILENAME );}
inline string_t GetLibProdName(void) {return string_t(STRPRODNAME);}

所以乍一看ChartDef.h,您将获得ChartCtrlLib.lib版本的信息。您的应用程序可以使用版本访问函数来做同样的事情。

演示应用程序

这是一个基于对话框的应用程序。图表容器是主应用程序对话框中的一个控件。

用于操作容器的所有控件(添加/更改/删除/追加/截断/删除/从XML文件加载)都位于容器窗口右侧的标签控件中。控件的标签如下所示。

该应用程序具有数据生成器,用于生成正弦波、sin(x)/x、指数、矩形波(多值函数)和随机数据序列。

.

标签1是“添加图表”标签。它是默认标签。启动演示时,您将首先看到它。

“添加图表”标签有编辑框用于输入图表名称和/或Y值名称,控件用于设置视觉属性、图表的X范围,以及数据系列中的数据点数量。有一个滑块用于设置Y精度。Y乘数滑块设置Y坐标的数量级。例如,如果滑块设置为-2,则所有Y坐标将乘以10-2。如果您不输入图表名称,应用程序将为您生成名称。

标签0是“容器属性”标签。

“设置颜色”控件组仅在容器为空时启用。“精度”和“设置X范围”滑块以及“X轴名称”编辑框仅在容器中至少有一个图表时启用。当然,在实际应用中,您可以随时更改容器元素的颜色。但是,在演示中,我决定仅允许在空容器上更改颜色,以便在您已经知道容器颜色之后,可以轻松设置正确的图表颜色。X轴名称编辑控件允许更改X轴名称。该名称对容器中的所有图表都相同。默认名称是“X”。

当您在此标签页上时,容器的用户输入被阻止:您可以缩放/平移、调用弹出菜单等。我这样做是为了能够撤销您在点击“应用”按钮时所做的更改。您可以撤销一步或恢复到打开标签页时的状态。

退出此标签页时,用户输入将被解除阻止。

标签2用于更改容器中已有的图表的属性。您可以从列表框中选择图表,然后使用标签控件设置图表及其Y值的名称、Y精度以及视觉效果:颜色、线条样式、画笔宽度和张力。您可以通过选择列表框中的图表并在此标签页上单击“撤销”按钮来撤销您所做的所有更改。切换到任何其他标签页都会清除更改历史记录。图表名称对于给定会话必须是唯一的。图表和Y名称必须少于28个字符。如果您违反这些规则,容器的“Set”函数将截断输入的名称或/和添加后缀。

标签3是“追加图表”标签。列表框控件仅用于信息显示。对于此演示,您不能选择一个或多个图表进行追加;容器中的所有图表都将被追加。“动画”复选框。如果选中它,“追加”命令会模拟示波器(某种程度上)。您可以使用“撤销追加”来丢弃对容器的更改。

标签4是“截断图表”标签。选择图表、起始X坐标和结束X坐标,然后截断图表。如果选中“重新计算比例”复选框,容器将被强制重新计算其X和Y比例以缩小到新的最大X和Y范围。仅在第一次成功截断后才能激活“保留X范围”复选框。如果选中它,它将锁定新的X范围以将所有其他图表截断到相同的X范围。您可以使用“撤销”按钮选择图表并将其恢复到初始状态。

标签5是“删除图表”标签。只需选择并删除(删除)图表。“重新计算比例”会更新容器的X和Y范围。

标签6是“克隆/加载”标签。

“克隆”按钮将容器复制到一个新的弹出窗口中,该窗口具有可调整大小的边框。该窗口的所有者是主应用程序对话框中的源容器。

“从XML文件加载”组中的控件正是做这件事:它们帮助从XML文件中选择和加载图表。选择文件后,文件中的图表名称将显示在多选列表框中。选择图表。使用下面的列表框和颜色按钮根据需要更改图表颜色,然后单击“应用加载”。

在标签页中,我使用SliderGdiCtrl控件作为滑块。要将滑块的拇指精确地定位到您想要的位置,请左键单击滑块以使其获得焦点,然后使用箭头键移动拇指。

如果用户在更改图表属性、追加、截断或删除图表时数据视图窗口可见或最小化,则数据视图将自动更新。

我建议以下场景进行尝试:

  1. 向容器添加4-5个图表。使用不同的线条样式、画笔宽度、曲线类型和数据点数量。
  2. 玩转容器:沿X和Y轴缩放,平移,调用数据和名称标签。尝试弹出菜单的所有项。不要忘记将容器保存到XML文件(请参阅上面的用户手册章节)。
  3. 将容器图像保存为任何您喜欢的格式(使用容器的弹出菜单)。
  4. 克隆容器并尝试调整克隆窗口的大小(使用标签6)。
  5. 在容器中追加图表(标签3)。
  6. 截断一个或所有图表(标签4)。
  7. 从容器中删除所有图表(标签5)。
  8. 更改背景颜色并调整容器其他元素的颜色(标签0)。
  9. 从已保存的XML文件加载图表。加载之前,请调整所选图表的颜色(使用标签5)。
  10. 向容器添加数据点数量为630且Y幅度顺序为-1的图表。选择此图表并使用鼠标滚轮或向上/向下箭头键更改其Y比例。
  11. 在容器中选择此图表,然后在弹出菜单中选择“显示图表数据”项。数据视图窗口将弹出在主演示对话框框的右侧。使用数据视图按钮在数据视图中导航。
  12. 在屏幕上调用名称和数据图例。名称图例从上下文菜单调用,数据图例通过单击鼠标中键来启用跟踪模式。您将看到光标变为十字形。然后单击容器窗口内的任何位置以调用数据图例。转到标签2,“更改图表属性”。在列表框中,选择与容器窗口中选择的图表相同的图表,其数据在数据视图中显示。更改任何属性或属性组合,然后单击“应用”。观察标签和数据视图中的变化。
  13. 发明您自己的场景。

演示源代码可作为参考设计。

任务

参考头文件

参考源文件

更改容器元素的颜色

DlgGenProp.h

DlgGenProp.cpp

设置精度和X范围

DlgGenProp.h

DlgGenProp.cpp

添加图表

DlgAddChart.h

DlgAddChart.cpp

更改图表属性

DlgChangeChart.h

DlgChangeChart.cpp

追加图表

DlgAppendChart.h

DlgAppendChart.cpp

截断图表

DlgTruncate.h

DlgTruncate.cpp

删除图表

DlgRemoveChart.h

DlgRemoveChart.cpp

从XML文件加载图表

DlgMisc.h

DlgMisc.cpp

克隆容器

DlgMisc.h, DlgCharts.h

DlgMisc.cpp, DlgCharts.cpp

源代码和演示项目

文件ChartCtrlLib.zip包含ChartCtrlLib静态库的所有源代码文件。它包括

  • ChartCtrlLibSource.zip - 静态库ChartCtrlLib的所有源文件。
  • ChartCtrlDemoSource.zip - 演示应用程序的所有源代码文件。
  • ChartCtrlLibKit.zip - 文件ChartDef.hChartContainer.h以及编译好的库ChartCtrlLibD.libChartCtrlLib.lib。它包含在您的应用程序中使用图表控件所需的一切。
  • ChartCtrlLibKitVS2012.zip - 文件ChartDef.hChartContainer.h以及用于VS 2012 VC++ 11项目的编译好的库ChartCtrlLibD2012.libChartCtrlLib2012.lib
  • ChartCtrlLibDoxigen.zip - 包含ChartCtrlLib类文档的HTML文件。请注意,要使用指向源文件的链接,您必须首先将它们提取到文件夹C:/VS2010/Projects/317712/ChartCtrlLib
  • ChartCtrlDemo.exe - 演示应用程序的发布版本。它已与静态MFC库一起编译和链接。

历史记录

  • 2012年1月20日:初始版本。
  • 2012年4月28日:版本1.1。

    更改和添加

    • 移除了与优化相关的编译器选项,如/GL和/LTCG。
    • 在图表线条样式中添加了新的曲线样式,该样式将图表数据点绘制为不连接的十字。
    • 用户可以根据自己的选择设置X轴名称,而不是默认的“X”。
    • 用户可以为每个图表单独设置Y轴名称,而不是默认的“Y”。
    • Y 精度可以为每个图表单独设置。
    • 用户可以为X值和每个图表的Y值提供格式化函数。
    • 图表的“Set”函数现在接受-1作为图表索引。它表示“所有可见图表”。
    • ChartContainer现在在图表可见性、数据点表示或容器X扩展更改时向其父窗口发送通知消息。
    • 包含了版本信息定义和访问函数。
  • 2013年1月26日:版本1.2

    更改和添加

    • 新功能:现在可以将图表保存为Windows支持的任何图片格式的图像。
    • 新功能:以编程方式均衡图表的可见垂直大小。
    • 新功能:阻止用户访问,使容器“只读”。
    • 修改了CChartContainer::SaveChartData的签名,允许保存容器中所有可见和不可见的图表。
    • 从函数AddChartAppendChartData中移除了约束pntNmb >= 3
    • 时间序列的重载函数AddChartAppendChartDataReplaceChartData的签名已更改,允许程序员设置时间序列的时间起点和时间步长。
    • 函数CChartContainer::SetChartVisibilityCChartContainer::GetChart现在接受参数chartIdx = -1。
    • 添加了通知代码CODE_REFRESH
    • 添加了库移植到VS 2012 VC++ 11(ChartCtrlLibKitVS2012.zip)。
  • 2013年2月28日:版本1.2.1。修复了v. 1.2.0中引入的DrawLabel(..)函数中的错误(文件DataLabel.cpp)。
  • 2013年6月15日:版本2.0。

    更改和添加

    • 新功能:现在容器接受没有数据以及只有一两个数据点的图表。
    • 新功能:沿Y轴缩放和平移。
    • 修改了SaveChartDataSaveContainerImage的签名,使这些函数可以从容器的父窗口调用。
    • 改进了许多函数的功能。
    • 添加了用于克隆容器和其他事件的通知代码(请参阅文章上方和ChartDef.h)。
    • 修复了我和读者发现的bug。
© . All rights reserved.