OpenWPFChart:从组件组装图表:第 1 部分 - 部件






4.29/5 (16投票s)
提供组件模型和基本组件来组装图表。
摘要
OpenWPFChart 库是一个开源项目,托管在 CodePlex。其目标是提供一个组件模型以及基本组件(部件),以便能够从这些部件组装出不同的图表控件。部件集是可扩展的,因此开发人员可以添加自己的新组件。由这些部件组成的图表控件可以具有完全不同的外观和感觉,正如您在上面的图表中看到的。
原理
图表控件的设计是一项相当复杂的任务。公司和个人已经提出了各种各样的图表控件,包括开源和专有的。问题在于,设计一个能够满足所有用户需求(甚至预见所有这些需求)的图表控件或控件套件实际上是不可能的。首先,这是由于以下方面的差异:
- 控件的视觉外观。
- 控件的元素集——函数图、散点图、曲线之间的彩色区域、图表、坐标网格和轴、图例、标签、各种标记等。
- 这些元素在图表区域内或周围的定位方式;例如,坐标轴;图例和标签可以放置在图表区域内,也可以放置在其边界旁边或别处。
如果一个图表控件需要提供其显示数据的编辑功能,问题就更加严重了。据我所知,没有一个图表控件足够可定制以满足特殊的开发人员需求。结果是,我们看到越来越多的图表控件被从头开始编写。
为什么不提供一个可扩展的基本元素集的可伸缩架构,使开发人员能够组装不同的图表控件,以精确地满足她的/他的需求和偏好(尤其是在 WPF 为我们提供了其出色的组合功能的情况下)?
解决方案
通常,图表表现为一组视觉元素,这些元素可以支持或不支持“选定元素”的习语。因此,基本上,一个图表控件可以从 ItemsControl
或其派生类(如 Selector
、ListBox
等)派生。由于数据呈现的视觉图表项应该共享一个共同的图表区域屏幕空间,我们可以使用 Canvas
(或类似控件)作为 ItemsControl.ItemsPanel
来在 ItemsControl
中定位视觉元素。此外,在 ItemsControl
的模板中,我们可以放置所需的装饰性或功能性元素,如坐标轴、网格、图例、标签等。
我们可以开发一组元素(Visuals、FrameworkElements)
- 数据呈现视觉元素:函数曲线、散点图等。
- 坐标网格。
- 坐标轴。
有了这些元素,我们就可以像这样构建一个由它们组成的图表:
- 数据呈现视觉元素(图表项)显示为
ItemsControl
的项。我们将网格放在ItemsControl
的模板中。如果需要,我们也可以在此处放置坐标轴(如果它们应该与图表项并行)。ItemsControl
构成“图表区域”。 - 图表区域的周边装饰有坐标轴、图例、标签等。
为了管理上述所有内容,我们甚至不需要创建图表控件;我们可以直接在应用程序窗口中组合所有部件。但请注意,在这种情况下,窗口的 XAML 会变得相当冗长,所以这种方法不切实际;最好是创建一个或多个具有所需外观和感觉的图表控件(自定义控件或用户控件)。
零件
OpenWPFChart 的一部分是图表组件(部件)的对象模型以及这些元素的基本集。
图表项
图表项是数据和视觉元素的组合,用于在图表区域中显示数据。其设计方式如下:
首先,有一个数据对象 (Data
),我们希望在图表中看到它。
Data
对象可以以多种方式显示:例如,点序列可以显示为曲线、条形图或仅显示为点云;同一个数据对象可以在同一图表或应用程序中的另一个图表中显示多次。
DataView
对象是为了实现这一切而存在的。本质上,DataView
是 Data
的包装器。DataView
包含有关数据呈现的一些附加信息:
- 有关呈现状态的信息。其中最重要的是水平和垂直坐标刻度(稍后将详细介绍)。
- 绘图工具,如画笔、钢笔、几何图形等。
Data
与 DataView
是一对多的关系。
DataView
对象本身不包含任何渲染代码。要将其推送到视图,我们需要一个视觉元素 (DataVisualElement
),它可以是 WPF 视觉树的一部分。
例如,假设我们有一个 DataView
,其中包含一组数据点、用于绘制点之间线条的画笔,以及用于绘制点标记的几何图形和画笔。为了渲染这个 DataView
,我们可以有几个 DataVisualElement
:一个用于将其绘制为阶梯线,另一个用于将其绘制为折线,还有一个用于将其绘制为某种平滑曲线,依此类推。因此,DataView
与 DataVisualElement
之间是一对多的关系。
DataView
- DataVisualElement
关联是通过 WPF DataTemplate
机制建立的。但是有一个技巧。因为 DataView
类型可以用不同的 DataVisualElement
进行模板化,所以它应该成为选择的提示。DataView
包含一个类型为 object
的属性,通常设置为所需的 DataVisualElement
类型(尽管任何其他方法都可以)。然后,为了将 DataView
与该 DataVisualElement
关联,可以使用 DataTemplateSelector
。
DataVisualElement
应该参与 WPF 的渲染和布局,因此它继承自 FrameworkElement
。但请注意,在上面的示例中,三个 DataVisualElement
各自以自己的方式绘制曲线,但点绘制在所有三种情况下都是相同的。因此,将曲线和点的绘制分离到不同的视觉元素中会很方便。这可以避免代码重复,并且更重要的是,将不同的视觉元素分离到视觉树中,使它们的命中测试变得简单。这就是为什么,如上所示,DataVisualElement
通常拥有一个或多个执行实际渲染的视觉元素(DataVisual
)。
注意:我知道像 Infragistics 和 ComponentOne 这样的著名公司使用的技术,它们将图表视觉元素从 FrameworkElement
派生而不是从 FrameworkContentElement
派生:FrameworkContentElement
比 FrameworkElement
更轻量级。我认为这种方法不会为 OpenWPFChart 带来任何实际的好处:只有图表项在这里继承自 FrameworkElement
,而不是单个点或曲线段。但是,一个图表最多只能包含十个图表项,以免造成视觉空间混乱。所以,为什么要费心呢?
坐标刻度
坐标刻度定义了坐标轴的间隔及其刻度——WPF 像素中的坐标刻度范围与数据单位之间的关系。每个 DataView
对象都有两个坐标刻度——横坐标(X)和纵坐标(Y)。
OpenWPFChart 提供了一些坐标刻度类,它们都从抽象基类 OpenWPFChart.ChartScale
派生,该类定义了三个属性:Start
、Stop
和 Scale
。每个具体的 ChartScale
派生类都根据该轴的基本数据类型描述坐标轴:例如,double
、DateTime
或 object
。此基本数据类型对应于 DataView
对象在 X 和 Y 轴上包装的数据类型。
可能存在具有相同基本数据类型的不同 ChartScale
类。例如,ChartLinearScale
和 ChartLogarithmicScale
都具有 double
基本类型,但具有不同的坐标轴间隔限制:ChartLogarithmicScale
间隔必须适合正双精度半轴。
不同 ChartScale
类的 Scale
属性具有不同的含义。例如,对于 ChartLinearScale
,Scale
值是每数据单位的 WPF 像素数,而对于 ChartLogarithmicScale
,它是每 Log10 基数的 WPF 像素数。
除了定义间隔外,每个 ChartScale
类还具有描述图表刻度标记的属性:坐标轴和网格元素使用的刻度线序列。此功能通过绑定实现了坐标轴和网格与 DataView
对象之间的无缝集成。
坐标轴和网格
轴元素具有丰富的视觉表示。虽然可以使用单个轴元素来显示任何轴,但在实践中,某些 ChartScale
类型需要特定的轴元素类型。例如,对数轴标签通常显示为 10,并带有上标字体。如果需要,开发人员可以提供具有特定外观和感觉的自己的轴类。
在图表组合中,轴通过 WPF DataTemplate
连接到它显示的 ChartScale
数据对象。
得益于 ChartScale
类族的精心设计,一个 OpenWPFChart Grid
元素足以显示图表区域中的任何坐标网格。尽管有可能,但很少有人会想要设计任何其他的 Grid
元素。
组成
在开发人员选择了 OpenWPFChart 预定义部件或编写了他们希望在图表中看到的扩展部件后,我们可以继续进行图表组合。
图表可以作为可重用的代码块(如自定义控件或用户控件)进行组合,也可以直接在 WPF Window
或 Page
中进行组合。后一种方法存在一些缺点,因为它迫使在 Windows 或 Page 的 XAML 中放置大量 XAML 代码,但除了可维护性和可重用性不足之外,这种方法没有任何问题。无论如何,组合原则保持不变。
开发人员需要执行的步骤如下:
- 选择图表区域控件。
- 定义
DataTemplate
以将ItemDataView
对象链接到图表项元素。 - 定义
DataTemplate
以将ChartScale
对象链接到轴元素。 - 定义步骤
ItemsControl
(图表区域控件)样式。 - 可选地,定义控件及其相应的
DataTemplate
和图表图例元素的样式。
图表区域
图表区域是图表中绘制图表项和网格线的矩形分区。图表区域控件是管理图表项的控件。它保存图表项以及装饰性元素,如坐标网格、坐标轴(如果需要在图表区域中渲染)、文本标签等。
最自然的情况是,图表区域控件是 ItemsControl
的派生类。如果新图表不需要支持选择,则可以是 ItemsControl
类本身。或者,它可以是 Selector
类,或者更方便的是 ListBox
。
图表项元素 DataTemplates
一如 WPF 数据呈现的惯例,类和视觉元素是解耦的。两者之间的链接通过 DataTemplate
建立。根据设计,图表中的每个图表项都可以拥有自己特定的视觉元素集。我们可以通过两种方式将数据呈现对象与视觉元素关联:(1) 按类型或 (2) 按对象。
在第一种情况下,我们可以像这样定义模板:
<DataTemplate DataType="{x:Type parts:SampledCurveDataView">
<parts:PolylineSampledCurve ItemDataView="{Binding}"/>
</DataTemplate>
这意味着,我们希望将一组数据点(SampledCurveDataView
)显示为折线(以及点本身的可视化)。但是,这里有一个缺点:如果我们想将相同类型或另一对象 SampledCurveDataView
显示为,例如,贝塞尔样条(即,使用 BezierSampledCurve
视觉元素)怎么办?如何将相同的 SampledCurveDataView
类型与不同的视觉元素关联,以及谁来决定何时显示哪个视觉元素?
有几种方法可以解决这个问题,但在此情况下最合适的方法是采用第二种情况:按对象建立关联。为此,我们应该在数据呈现对象中提供一些指示,说明要使用哪个模板。为此,ItemDataView
基类定义了 VisualCue
属性(类型为 object
)。有了这个属性,我们就可以使用 WPF DataTemplateSelector
来选择适合 VisualCue
属性值的模板。我们现在应该使用命名的 DataTemplate
而不是类型。
<DataTemplate x:Key="polylineTemplate">
<parts:PolylineSampledCurve ItemDataView="{Binding}"/>
</DataTemplate>
<DataTemplate x:Key="bezierTemplate">
<parts:BezierSampledCurve ItemDataView="{Binding}"/>
</DataTemplate>
<DataTemplate x:Key="splineTemplate">
<parts:SplineSampledCurve ItemDataView="{Binding}"/>
</DataTemplate>
<DataTemplate x:Key="scatteredPointsTemplate">
<parts:ScatteredPoints ItemDataView="{Binding}"/>
</DataTemplate>
从本质上讲,DataTemplateSelector
必须在代码中实现。但是良好的设计要求所有组合都应该在 XAML 中完成。碰巧有一些方法可以实现一个通用的 DataTemplateSelector
类,它可以为我们所有的组合场景服务,因为它可以通过 XAML 进行配置。OpenWPFChart 使用了 Nick Zhebrun 提出的 GenericDataTemplateSelector
的修改版本(参见 Nick Zhebrun GenericDataTemplateSelector)。
OpenWPFChart.Parts.GenericDataTemplateSelector
允许指定数据呈现对象与视觉元素之间的关联,如下所示:
<parts:GenericDataTemplateSelector x:Key="chartItemsTemplateSelector">
<parts:GenericDataTemplateSelectorItem
PropertyName="VisualCue"
Value="{x:Type parts:PolylineSampledCurve}"
Template="{StaticResource polylineTemplate}"
TemplatedType="{x:Type parts:SampledCurveDataView}"
Description="Polyline Sampled Curve"/>
<parts:GenericDataTemplateSelectorItem
PropertyName="VisualCue"
Value="{x:Type parts:BezierSampledCurve}"
Template="{StaticResource bezierTemplate}"
TemplatedType="{x:Type parts:SampledCurveDataView}"
Description="Bezier Sampled Curve"/>
<parts:GenericDataTemplateSelectorItem
PropertyName="VisualCue"
Value="{x:Type parts:SplineSampledCurve}"
Template="{StaticResource splineTemplate}"
TemplatedType="{x:Type parts:SampledCurveDataView}"
Description="Spline Sampled Curve"/>
<parts:GenericDataTemplateSelectorItem
PropertyName="VisualCue"
Value="{x:Type parts:ScatteredPoints}"
Template="{StaticResource scatteredPointsTemplate}"
TemplatedType="{x:Type parts:ScatteredPointsDataView}"
Description="Scattered points cloud"/>
</parts:GenericDataTemplateSelector>
此代码片段表示,我们将类型为 TemplatedType
、属性名为 PropertyName
、值为 Value
的对象与模板 Template
关联起来。
然后,这个 GenericDataTemplateSelector
资源被引用在图表区域控件定义中,大致如下:
<ListBox
ItemsSource="{Binding}"
ItemTemplateSelector="{StaticResource chartItemsTemplateSelector}"
... />
坐标轴 DataTemplates
在 OpenWPFChart 库中,坐标轴可以被看作是 ChartScale
派生类型的视觉表示。Axis
元素类型可以取决于具体的 ChartScale
类型以及 ChartScale
的基本类型(例如,数字、DateTime
等)。
例如,线性坐标轴可以显示为 LinearAxis
、DateTimeAxis
或 SeriesAxis
元素,具体取决于绑定的 ChartScale
基本类型。或者,任何线性坐标轴都可以显示为 GenericLinearAxis
。数字坐标轴可以以线性或对数方式显示,具体取决于它绑定到 ChartLinearScale
或 ChartLogarithmicScale
类型。
通常重要的是,当绑定的 ChartScale
类型在运行时发生变化时,轴元素类型能够无缝地变化。
Axis
元素的 AxisScale
属性应该绑定到 ChartScale
派生类型的某个源。它可以是 ItemDataView
的 HorizontalScale
或 VerticalScale
属性之一,或者是图表控件或构造的窗口的类似属性。为了实现这一能力,Axis
元素类型应该能够随着绑定 ChartScale
类型变化的无缝变化;Axis
元素不应该直接绑定到上述源。相反,它应该被包装到某个 WPF ContentElement
中,该元素绑定到 ChartScale
源。它可以看起来像:
<Grid>
<Grid.RowDefinitions>
<RowDefinition/>
<RowDefinition/>
</Grid.RowDefinitions>
<ContentPresenter Name="hAxisHost"
Content="{Binding ElementName=mainWindow, Path=HorizontalScale}"/>
<TextBlock Grid.Row="1" HorizontalAlignment="Center">
Axis of abscissas
</TextBlock>
</Grid>
对于水平轴,以及像:
<Grid>
<Grid.RowDefinitions>
<RowDefinition/>
<RowDefinition/>
</Grid.RowDefinitions>
<ContentPresenter Name="vAxisHost"
Content="{Binding ElementName=mainWindow, Path=VerticalScale}"/>
<TextBlock Grid.Row="1" HorizontalAlignment="Center">Axis of ordinates</TextBlock>
<Grid.LayoutTransform>
<RotateTransform Angle="90"/>
</Grid.LayoutTransform>
</Grid>
对于垂直轴。
在上面的代码片段中,假设继承的 DataContext
对象包含 ChartScale
类型的 HorizontalScale
和 VerticalScale
属性。轴与轴标签一起定义,在示例中它们只是 TextBlock
。对于垂直轴,其容器 Grid
元素被旋转,以便轴变成垂直的。
轴主机包装器元素通过其资源查找范围中的一个类型化轴 DataTemplate
来解析其绑定。下面是一个轴 DataTemplate
的示例:
<DataTemplate DataType="{x:Type parts:ChartLinearScale}">
<parts:LinearAxis AxisScale="{Binding}"/>
</DataTemplate>
<DataTemplate DataType="{x:Type parts:ChartLogarithmicScale}">
<parts:LogarithmicAxis AxisScale="{Binding}"/>
</DataTemplate>
<DataTemplate DataType="{x:Type parts:ChartDateTimeScale}">
<parts:DateTimeAxis AxisScale="{Binding}"/>
</DataTemplate>
<DataTemplate DataType="{x:Type parts:ChartSeriesScale}">
<parts:SeriesAxis AxisScale="{Binding}"/>
</DataTemplate>
通过这种设计,Axis
元素类型跟随绑定的属性 ChartScale
;前者在后者改变时自动改变。
图表组合
我们可以将图表组合为自定义控件、用户控件,或者作为 Window
或 Page
的一部分。但原则保持不变,并且有一些共同的步骤需要完成。
首先,我们必须选择图表区域控件。最自然的情况是,图表区域控件是 ItemsControl
的派生类。如果新图表不需要支持选择,则可以是 ItemsControl
类本身。或者,它可以是 Selector
类,或者更方便的是 ListBox
。
其次,我们必须将图表区域控件的 ItemsPanel
替换为 Canvas
或其他布局容器,允许我们的图表项重叠。在控件样式中,我们应该添加一个像这样的设置器:
<Setter Property="ItemsPanel">
<Setter.Value>
<ItemsPanelTemplate>
<Canvas />
</ItemsPanelTemplate>
</Setter.Value>
</Setter>
第三,我们应该将控件的 ItemTemplateSelector
设置为我们在资源中定义的选择器 (参见图表项元素 DataTemplates)。
<Setter Property="ItemTemplateSelector"
Value="{StaticResource chartItemsTemplateSelector}"/>
第四步是定义图表区域控件的 ControlTemplate
。在那里我们定义图表的外观,因此,这很大程度上取决于我们的意图。但是,有一些常见的问题值得在此提及。
在图表区域,我们应该为我们的图表项定义一个占位符,其中包含一个 WPF ItemsPresenter
和两个坐标 Grid
(垂直和水平),位于项下方。
让我们想象一下,我们正在开发一个派生自 ListBox
的自定义图表控件。控件样式应包含以下代码片段:
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type local:CurveChart}">
……
<!-- CurveChart area -->
<Grid Grid.Column="1">
<!-- Coordinate grids -->
<parts:Grid Name="PART_VerticalGrid"
HorizontalScale="{TemplateBinding HorizontalScale}"
VerticalScale="{TemplateBinding VerticalScale}"
GridVisibility="{TemplateBinding VerticalGridVisibility}"
/>
<parts:Grid Name="PART_HorizontalGrid" Orientation="Horizontal"
HorizontalScale="{TemplateBinding HorizontalScale}"
VerticalScale="{TemplateBinding VerticalScale}"
GridVisibility="{TemplateBinding HorizontalGridVisibility}"
/>
<!-- CurveChart Items -->
<ItemsPresenter Name="PART_ItemsHost"
SnapsToDevicePixels="{TemplateBinding UIElement.SnapsToDevicePixels}" />
</Grid>
……
</ControlTemplate>
</Setter.Value>
</Setter>
在上面的代码片段中,假定控件具有某些属性(VerticalScale
、VerticalGridVisibility
等)。坐标网格放置在 WPF Grid
容器中,并且 ItemsPresenter
位于这些网格的顶部,因此图表项不会被网格线弄得混乱。网格属性绑定到控件属性。ItemsPresenter
将通过图表项 DataTemplate
显示其项 (参见图表项元素 DataTemplates),这些 DataTemplate
又会将图表项属性绑定到控件属性。
Using the Code
本文附带的代码是针对 .NET Framework 3.5 的 Visual Studio 2008 SP1 解决方案。
它不仅包含本文讨论的 OpenWPFChart 部件,还包含了一系列示例,说明如何将这些部件组合到 WPF 窗口中,或者作为自定义控件。还提供了输入数据示例文件。
为了减小下载大小,解决方案中已删除了测试项目和 HTML 帮助。要获取完整的代码包和文档,请访问 CodePlex 上的 OpenWPFChart。
关注点
本文使用的方法是 WPF 组合模型的固有组成部分,并在 Dr. WPF 发表的精彩系列文章 ItemsControl: A to Z 中有详细描述。这里只是将其应用于特定领域:图表开发。
了解更多
请参阅本系列第二篇文章中 OpenWPFChart 控件的示例。