登峰造极的 UI

使用数据模板创建折线图

Charles Petzold

下载代码示例

尽管现在涌现出了许多各种各样的高级计算机图形(包括动画和 3-D),但我认为,最重要的永远是使用条形图、饼图和折线构建的传统图表中基本直观的数据表示形式。

虽然数据表可能看起来像是一堆杂乱的随机数字,但图形中隐藏的任何趋势或有趣的信息比在图表中显示更易于理解。

借助 Windows Presentation Foundation (WPF) 及其基于 Web 的分支 Silverlight,我们发现了在标记(而不是代码)中定义图形画面的优点。与代码相比,可扩展应用程序标记语言 (XAML) 更易于更改、体验且易于作为工具使用,从而允许我们以交互方式定义我们的画面并试用可选方法。

事实上,完全在 XAML 中定义画面非常有利,这样,WPF 编程人员可以将大量时间用于专门编写代码,从而使 XAML 功能更强大、更灵活。这种现象我称之为“对 XAML 进行编码”,这也是 WPF 更改应用程序开发方法的几种方式之一。

WPF 中许多功能最强大的技术都包括 ItemsControl,它是通常显示同一类型的项目集合的基本控件。(ItemsControl 派生的一种常见控件是 ListBox,可通过它进行导航、选择和显示。)

ItemsControl 可以包含任何类型的对象,甚至是不具有固有的文本或直观表示形式的业务对象。神奇的组成之处是 DataTemplate(它几乎始终在 XAML 中进行定义),它基于对象的属性为这些业务对象提供一种直观的表示形式。

三月的期刊中,我介绍了如何使用 ItemsControl 和 DataTemplate 在 XAML 中定义条形图和饼图。起初,我曾打算在那篇文章中介绍折线图,但折线图的重要性(和难度)使我不得不在整个专栏中专门介绍该主题。

折线图问题

折线图实际上是分散曲线的形式 — 在水平轴和垂直轴上各有一个变量的笛卡尔坐标系统。折线图的一个明显的区别在于图表上水平方向的值通常都是经过分类的。这些值通常是日期或时间,也就是说,折线图通常显示变量随时间的变化情况。

另一个明显的区别在于单个数据点通过直线连接。虽然从表面上看直线是折线图画面的基本组成部分,但实际上它是在 XAML 中绘制图表过程中的重大破坏性因素。DataTemplate 介绍如何在 ItemsControl 中呈现每个项,但是连接这些项要求访问多个点,从理论上讲,PointCollection 随后可与 Polyline 元素结合使用。第一个重要事项是需要生成此 PointCollection,因为对折线图数据执行预处理需要自定义类。

与其他图形相比,折线图更要求多加注意坐标轴。事实上,水平轴和垂直轴本身成为其他 ItemsControl 是有用的!然后,这两个 ItemsControl 的其他 DataTemplate 可完全用于在 XAML 中定义轴刻度线和标签的格式。

总之,首先是具有以下两个属性的数据项集合:这两个属性分别对应于水平轴和垂直轴。要在 XAML 中绘制图表,您需要从数据中获取特定的项。首先,您需要找到每个数据项对应的点对象(用于呈现各个数据点)。还需要所有数据项的 PointCollection(连接点的直线)和两个包含用于在 XAML 中呈现水平轴和垂直轴的充足信息的附加集合,其中包含标签数据以及用于定位标签和刻度线的偏移量。

很显然,要计算这些 Point 对象和偏移量需要某些信息:图表的宽度和高度以及在水平轴和垂直轴上制成图表的数据的最大值和最小值。

但这还远远不够。假设垂直轴上的最小值是 127,最大值是 232。在这种情况下,您可能希望垂直轴实际上从 100 延长到 250,每 25 个单位一个刻度线。或者对于此特定图形,您可能希望始终包括 0,这样垂直轴可从 0 延长到 250。又或许您希望最大值始终是 100 的倍数,这样该值便位于 0 到 300 之间。如果这些值的范围为 -125 到 237,或许您希望 0 位于中间,因此该轴的范围可能为 -300 到 300。

可能有许多不同的策略用于确定坐标轴显示哪些值,然后这些策略会控制与各个数据项相关联的 Point 值的计算。这些策略可能各不相同,因此提供一个“插件”选项以根据需要为特定图表定义其他坐标轴策略非常有用。

初次尝试

有时,编程失败与编程成功一样有意义。虽然我第一次尝试创建可从 XAML 访问的折线图类算不上是完全失败,但确实为我指明了方向。

我知道,若要生成 Point 对象的集合,很显然,我需要访问 ItemsControl 中的项目集合和控件的 ActualWidth 和 ActualHeight。鉴于以上原因,从我称之为 LineChartItemsControl 的 ItemsControl 中派生一个类似乎很合乎逻辑。

LineChartItemsControl 定义了几个新的读/写属性:HorizontalAxisPropertyName 和 VerticalAxisPropertyName 提供了将制成图表的项目的属性名称。其他四个新属性提供了 LineChartItemsControl 以及水平轴和垂直轴的最大值和最小值。(这是一种非常简单的处理坐标轴的方法,我知道这种方法稍后需要提高。)

自定义控件还为 XAML 中的数据绑定定义了三个只读依赖关系属性:一个类型为 PointCollection 的 Points 属性和两个用于呈现坐标轴的 HorizontalAxisInfo 和 VerticalAxisInfo 属性。

LineChartItemsControl 覆盖了项目集合中发生更改时需要通知的 OnItemsSourceChanged 和 OnItemsChanged 方法,它还为 SizeChanged 事件安装了一个处理程序。然后非常简单,整理出所有可用信息以计算这三个只读依赖关系属性。

然而,实际在 XAML 中使用 LineChartItemsControl 却很乱。较容易的部分是呈现连接的直线。此操作由 Polyline 元素及其 Points 属性(此属性已绑定到 LineChartItemsControl 的 Points 属性)完成。但是,定义用于定位单个数据的 DataTemplate 则十分困难。DataTemplate 只能访问一个特定数据项的属性。通过绑定,DataTemplate 可以访问 ItemsControl 本身,但您如何才能访问与该特定数据项对应的定位信息?

我的解决方案涉及 MultiBinding 中的 RenderTransform 集,该集包含 RelativeSource 绑定并引用了 BindingConverter。此解决方案非常复杂,以至于在我对它进行编码的第二天,仍然不能真正明白它的工作原理!

此解决方案的复杂性使我明白,我需要一个完全不同的方法。

实际折线图生成器

重新构思的解决方案是一个类,我称之为 LineChartGenerator,因为它可以完全在 XAML 中生成用于定义图表画面所需的所有原始材料。输入一个集合(实际业务对象),输出四个集合,其中一个用于数据点、一个用于绘制连接的直线、另外两个用于水平轴和垂直轴。您可以使用它在 XAML 中构建一个图表,该图表包含多个 ItemsControl(通常排列在 4 乘 4 的网格中,如果您还希望包含标题和其他标签,则可以使用更大的网格),每个都有其自己的 DataTemplate 以显示这些集合。

让我们来看一下实际的工作原理。(所有可下载源代码都包含在一个名为 LineChartsWithDataTemplates 的 Visual Studio 项目中。此解决方案包含一个名为 LineChartLib 的 DLL 项目和三个演示程序。)

PopulationLineChart 项目包含一个名为 CensusDatum 的结构,该结构定义两个名为 Year 和 Population 的整数类型的属性。CensusData 类派生自 CensusDatum 类型的 ObservableCollection 并使用美国从 1790 年(人口为 3,929,214)到 2000 年(人口为 281,421,906)十年一度的人口普查数据填充该集合。如图 1 显示的是生成的图表。

图 1 PopulationLineChart 显示

image: The PopulationLineChart Display

此图表的所有 XAML 都在 PopulationLineChart 项目的 Window1.xaml 文件中。图 2 显示的是此文件的“资源”部分。LineChartGenerator 具有其自己的 ItemsSource 属性,在此示例中,该属性被设置为 CensusData 对象。还需要在此处设置 Width 和 Height 属性。(我知道此处不是设置这些值的最佳位置,而且对 WPF 中的首选布局方法也不是非常有益,但是我想不出更好的解决方案。)这些值表示图表(不包括水平轴和垂直轴)的内部尺寸。

图 2 PopulationLineChart 的“资源”部分

<Window.Resources>
    <src:CensusData x:Key="censusData" />

    <charts:LineChartGenerator 
            x:Key="generator"
            ItemsSource="{Binding Source={StaticResource censusData}}"
            Width="300"
            Height="200">

        <charts:LineChartGenerator.HorizontalAxis>
            <charts:AutoAxis PropertyName="Year" />
        </charts:LineChartGenerator.HorizontalAxis>

        <charts:LineChartGenerator.VerticalAxis>
            <charts:IncrementAxis PropertyName="Population"
                                  Increment="50000000"
                                  IsFlipped="True" />
        </charts:LineChartGenerator.VerticalAxis>
    </charts:LineChartGenerator>
</Window.Resources>

LineChartGenerator 也具有两个 AxisStrategy 类型的属性,名称分别为 HorizontalAxis 和 VerticalAxis。AxisStrategy 是一个定义多个属性(包括 PropertyName)的抽象类,您可以在其中指明希望将其绘制在此坐标轴上的数据对象的属性。根据 WPF 的坐标系统,从左到右、从上到下,值逐渐递增。您几乎总是希望将垂直轴上的 IsFlipped 属性设置为 True,以便值从下到上逐渐递增。

从 AxisStrategy 派生的其中一个类是 IncrementAxis,该类定义一个名为 Increment 的属性。借助 IncrementAxis 策略,您可以指定刻度线之间的增量。将最大值和最小值设置为增量的倍数。我已对人口规模使用了 IncrementAxis。

从 AxisStrategy 派生的另一个类是 AutoAxis,该类没有定义其自己的附加属性。我已对水平轴使用此类:它的唯一用途就是在坐标轴上使用实际值。(我还没有介绍的另一个明显的 AxisStrategy 派生类是 ExplicitAxis,您可以在其中提供坐标轴上显示的值列表。)

LineChartGenerator 类定义了两个只读依赖关系属性。第一个是 PointCollection 类型的名为 Points 的属性;使用此属性绘制连接各个点的直线:

<Polyline Points="{Binding Source={StaticResource generator}, 
                           Path=Points}"
          Stroke="Blue" />

第二个 LineChartGenerator 属性名为 ItemPoints,类型为 ItemPointCollection。ItemPoint 具有两个属性,名称分别为 Item 和 Point。Item 是集合中的原始对象,在此具体示例中,Item 是类型为 CensusDatum 的对象。Point 是一个点,该点是项目在图形中的显示位置。

图 3 显示的是表示图表主体的 ItemsControl。请注意,其 ItemsSource 绑定到 LineChartGenerator 的 ItemPoints 属性。ItemsPanel 模板是一个网格,ItemTemplate 是一个包含 EllipseGeometry 和 ToolTip 的路径。EllipseGeometry 的 Center 属性绑定到 ItemPoint 对象的 Point 属性,而 ToolTip 访问 Item 属性的 Year 和 Population 属性。

图 3 PopulationLineChart 的主 ItemsControl

<ItemsControl ItemsSource="{Binding Source={StaticResource generator}, 
                                    Path=ItemPoints}">
    <ItemsControl.ItemsPanel>
        <ItemsPanelTemplate>
            <Grid IsItemsHost="True" />
        </ItemsPanelTemplate>
    </ItemsControl.ItemsPanel>

    <ItemsControl.ItemTemplate>
        <DataTemplate>
            <Path Fill="Red" RenderTransform="2 0 0 2 0 0">
                <Path.Data>
                    <EllipseGeometry Center="{Binding Point}"
                                     RadiusX="4"
                                     RadiusY="4"
                                     Transform="0.5 0 0 0.5 0 0" />
                </Path.Data>
                <Path.ToolTip>
                    <StackPanel Orientation="Horizontal">
                        <TextBlock Text="{Binding Item.Year}" />
                        <TextBlock Text="{Binding Item.Population, 
                            StringFormat=’: {0:N0}’}" />
                    </StackPanel>
                </Path.ToolTip>
            </Path>
        </DataTemplate>
    </ItemsControl.ItemTemplate>
</ItemsControl>

您可能想了解 EllipseGeometry 对象上的 Transform 集,它是针对 Path 元素上的 RenderTransform 属性集的偏移量。这是一台组装电脑:但是没有它,只能部分裁剪最右侧的椭圆,而且我无法使用 ClipToBounds 对其进行修复。

Polyline 和此主 ItemsControl 共享同一单个单元格 Grid,其 Width 和 Height 绑定到 LineChartGenerator 中的值:

<Grid Width="{Binding Source=
{StaticResource generator}, Path=Width}"
      Height="{Binding Source=
{StaticResource generator}, Path=Height}">

在此示例中,Polyline 位于 ItemsControl 下方。

AxisStrategy 类定义其自己的只读依赖关系属性 AxisItems,这是一个类型为 AxisItem 的对象集合,具有两个属性,名称分别为 Item 和 Offset。该集合供各个坐标轴的 ItemsControl 使用。尽管 Item 属性被定义为 Object 类型,但实际上,其类型和与该坐标轴相关联的属性型相同。偏移量是指到顶部或左侧的距离。

图 4 显示的是水平轴的 ItemsControl;垂直轴与水平轴类似。ItemsControl 的 ItemsSource 属性绑定到 LineChartGenerator 的 HorizontalAxis 属性的 AxisItems 属性。因此,使用 AxisItem 类型的对象填充该 ItemsControl。TextBlock 的 Text 属性绑定到 Items 属性,而 Offset 属性用于转换沿坐标轴的刻度线和文本。

图 4 PopulationLineChart 的水平轴上的标记

<ItemsControl Grid.Row="2"
              Grid.Column="1"
              Margin="4 0"
              ItemsSource="{Binding Source={StaticResource generator}, 
                                    Path=HorizontalAxis.AxisItems}">
    <ItemsControl.ItemsPanel>
        <ItemsPanelTemplate>
            <Grid IsItemsHost="True" />
        </ItemsPanelTemplate>
    </ItemsControl.ItemsPanel>

    <ItemsControl.ItemTemplate>
        <DataTemplate>
            <StackPanel>
                <Line Y2="10" Stroke="Black" />
                <TextBlock Text="{Binding Item}"
                           FontSize="8"
                           LayoutTransform="0 -1 1 0 0 0"
                           RenderTransform="1 0 0 1 -6 1"/>

                <StackPanel.RenderTransform>
                    <TranslateTransform X="{Binding Offset}" />
                </StackPanel.RenderTransform>
            </StackPanel>
        </DataTemplate>
    </ItemsControl.ItemTemplate>
</ItemsControl>

因为这三个 ItemsControl 仅位于 Grid 的三个单元格中,所以设计 XAML 布局的人负责确保他们能够正确对齐。应用到这些控件的任何边框或边距或填充必须保持一致。图 4 中 ItemsControl 的水平边距为 4;的垂直轴上的 ItemsControl 的垂直边距为 4。我选择这些值是为了与单个单元格 Grid 四周的边框的 BorderThickness 和 Padding 对应,该单个单元格包括 Polyline 和图表本身:

<Border Grid.Row="1"
        Grid.Column="1" 
        Background="Yellow"
        BorderBrush="Black"
        BorderThickness="1"
        Padding="3">

数据类型一致性

LineChartGenerator 类本身并没有多大意义。它假设 ItemsSource 集合已经过排序,主要用于确保当 ItemsSource 属性更改时更新所有内容。如果将该集合设置为 ItemsSource 会实现 CollectionChanged,则当对该集合添加项目或删除项目时也将更新该图表。如果该集合中的项目实现 InotifyPropertyChanged,则当项目本身发生更改时也会更新该图表。

实际上,大多数实际工作仍在 AxisStrategy 和派生类中进行。这些 AxisStrategy 派生类是您设置为 LineChartGenerator 的 HorizontalAxis 和 VerticalAxis 属性的类。

AxisStrategy 本身定义重要的 PropertyName 属性,该属性表明制成图表的对象的哪些属性与该坐标轴相关联。AxisStrategy 使用反射访问集合中对象的特定属性。但是,仅访问该属性是不够的。AxisStrategy(及其派生类)需要计算此属性的值以获取 Point 对象和刻度偏移量。这些计算包括乘法和除法。

计算的必要性强烈表明制成图表的属性必须是数值类型:整数或浮点。然而,通常在折线图的水平轴上使用的相当常见的数据类型根本不是数字,而是日期或时间。在 Microsoft .NET Framework 中,我们谈论的是类型为 DateTime 的对象。

所有数值数据类型和 DateTime 有什么共同点?它们都实现 IConvertible 接口,这就意味着它们都包含许多用于互相转换的方法并且都可以使用静态 Convert 类中的同名方法。因此,要求制成图表的属性实现 Iconvertible 对我来说似乎很合理。然后,AxisStrategy(及其派生类)可以直接将属性值转换成双精度以进行必要的计算。

但是,我很快发现实际上无法使用 ToDouble 方法或 Convert.ToDouble 静态方法将类型为 DateTime 的属性转换成双精度。这就意味着确实必须使用特殊逻辑处理类型为 DateTime 的属性,这幸好不是一个大问题。由 DateTime 定义的 Ticks 属性是一个 64 位整数,可转换成双精度;双精度可以通过首先转换为一个 64 位整数,然后将该值传递给 DateTime 构造函数来转换回 DateTime。小型实验表明往返转换精确到毫秒。

AxisStrategy 包括一个 Recalculate 方法,该方法遍历其父项的 ItemsSource 集合中的所有项、将每个对象的指定属性转换为双精度并确定最大值和最小值。AxisStrategy 定义可能影响这两个值的三个属性:Margin 属性(允许最大值和最小值稍微超出实际值的范围);IncludeZero 属性(使坐标轴始终包含零值,即使所有值都大于零或小于零);IsSymmetricAroundZero 属性,表示坐标轴上的最大值应为正数、最小值应为负数,但它们的绝对值应相同。

经过这些调整后,AxisStrategy 调用抽象的 CalculateAxisItems 方法:

protected abstract void CalculateAxisItems(Type propertyType, ref double minValue, ref double maxValue);

第一个参数是与该坐标轴对应的属性的类型。从 AxisStrategy 派生的任何类必须实现此方法并借此机会定义构成其 AxisItems 集合的项目和偏移量。

CalculateAxisItems 很可能还会设置新的最大值和最小值。CalculateAxisItems 返回后,AxisStrategy 使用这些值以及图表的宽度和高度计算所有项的 Point 值。

XML 数据源

除了处理数值属性和类型为 DateTime 的属性外,AxisStategy 还必须处理 ItemsSource 集合中的项目为 XmlNode 类型时的情况。这是当 ItemsSource 绑定到 XmlDataProvider 时该集合中包含的内容,或引用外部 XAML 文件或是 XAML 文件中的 XAML 数据岛。

AxisStrategy 与 DataTemplates 使用相同的约定:名称本身指的是 XML 元素,前面带有 @ 符号的名称是 XML 属性。AxisStrategy 将这些值作为字符串获取。以防这些字符串实际上是日期或时间,AxisStrategy 在将它们转换成双精度之前首先尝试将这些字符串转换成 DateTime 对象。DateTime.TryParseExact 用于执行这项操作且仅针对固定文化格式规范:“R”、“s”、“u”和“o”。

SalesByMonth 项目演示绘制 XML 数据关系图和一些其他功能。Window1.xaml 文件包含 XmlDataProvider 和两个名为 Widgets 和 Doodads 的产品在 12 个月内的虚构销售数据:

<XmlDataProvider x:Key="sales"
                 XPath="YearSales">
    <x:XData>
        <YearSales >
            <MonthSales Date="2009-01-01T00:00:00">
                <Widgets>13</Widgets>
                <Doodads>285</Doodads>
            </MonthSales>

        ...

            <MonthSales Date="2009-12-01T00:00:00">
                <Widgets>29</Widgets>
                <Doodads>160</Doodads>
            </MonthSales>
        </YearSales>
    </x:XData>
</XmlDataProvider>

Resources 部分还包含这两种产品的两个非常类似的 LineChartGenerator 对象。下面是 Widgets 的一个对象:

<charts:LineChartGenerator 
               x:Key="widgetsGenerator"
               ItemsSource=
               "{Binding Source={StaticResource sales}, 
                                     XPath=MonthSales}"
               Width="250" Height="150">
    <charts:LineChartGenerator.HorizontalAxis>
        <charts:AutoAxis PropertyName="@Date" />
    </charts:LineChartGenerator.HorizontalAxis>
    
    <charts:LineChartGenerator.VerticalAxis>
        <charts:AdaptableIncrementAxis 
        PropertyName="Widgets"
        IncludeZero="True"
        IsFlipped="True" />
    </charts:LineChartGenerator.VerticalAxis>
</charts:LineChartGenerator>

请注意,水平轴与日期的 XML 属性相关联。垂直轴的类型为 AdaptableIncrementAxis,派生自 AxisStrategy 并定义两个其他属性:

•       Increments 属性,类型为 DoubleCollection

•       MaximumItems 属性,类型为 int

Increments 集合的默认值是 1、2 和 5,MaximumItems 属性的默认值是 10。SalesByMonth 项目仅使用这些默认值。AdaptableIncrementAxis 确定刻度线之间的最佳增量,因此坐标轴项目数不会超过 MaximumItems。按照默认设置,首先测试增量值 1、2 和 5,接着测试 0、20 和 50,然后是 100、200 和 500,依此类推。也将按相反方向进行测试:测试增量 0.5、0.2、0.1 等等。

当然,您可以使用其他值填充 AdaptableIncrementAxis 的 Increments 属性。如果您希望增量始终是 10 的倍数,则只需使用单个值 1。1、2 和 5 的备选项 1、2.5 和 5 可能更适合某些情况。

在坐标轴的数值不可预测,特别是在图表包含总大小动态更改或不断增加的数据时,AdaptableIncrementAxis(或您自己的发明中与之类似的内容)可能是最佳选择。由于 AdaptableIncrementAxis 的 Increments 属性的类型为 DoubleCollection,因此它不适用于 DateTime 值。我将在本专栏的后面部分介绍一个备用的 DateTime。

SalesByMonth 项目中的 XAML 文件为这两种产品定义了两个 LineChartGenerator 对象,它允许使用复合图表,如图 5 中所示。

图 5 SalesByMonth 显示

image: The SalesByMonth Display

创建复合图表的此选项不要求在组成 LineChartLib 的类中执行任何特殊操作。此代码的唯一用途是生成可在 XAML 中灵活处理的集合。

为了适应所有标签和坐标轴,整个图表呈现在由 4 行、5 列组成的网格中,这 5 个列中包含 5 个 ItemsControl — 其中两个用于图表本身中数据项的两个集合、两个用于左侧和右侧的坐标轴比例、还有一个用于水平轴。

在 XAML 中采用彩色编码区分这两种产品非常简单。但是另请注意,使用三角形和方形数据点进一步区分这两种产品。三角形项通过此 DataTemplate 呈现:

<DataTemplate>
    <Path Fill="Blue"
          Data="M 0 -4 L 4 4 -4 4Z">
        <Path.RenderTransform>
            <TranslateTransform X="{Binding Point.X}"
                                Y="{Binding Point.Y}" />
        </Path.RenderTransform>
    </Path>
</DataTemplate>

在实际的例子中,您可能使用实际与这两种产品相关联的形状或甚至使用更小的位图。

在此示例中,连接点的线不是标准 Polyline 元素,而是名为 CanonicalSpline 的自定义 Shape 派生类。(规范样条,也称为重要样条,是 Windows 窗体的一部分,但不属于 WPF。曲线连接每两个点,该曲线在算法上取决于这两个点四周的其他两个点。)也可以写入其他自定义类以达到此目的,也许是在点上执行最小的方块插值并显示结果的类。

LineChartChartGenerator 的 HorizontalAxis.AxisItems 属性是一个 ObservableCollection,类型为 DateTime,这就意味着可使用 Binding 类的 StringFormat 功能和标准的日期/时间格式字符串对这些项进行格式化。

水平轴的 DataTemplate 使用“MMMM”格式字符串显示整个月份名称:

<DataTemplate>
    <StackPanel HorizontalAlignment="Left">
        <Line Y2="10" Stroke="Black" />
        <TextBlock Text="{Binding Item, StringFormat=MMMM}"
                   RenderTransform="1 0 0 1 -4 -4">
            <TextBlock.LayoutTransform>
                <RotateTransform Angle="45" />        
            </TextBlock.LayoutTransform>
        </TextBlock>
        
        <StackPanel.RenderTransform>
            <TranslateTransform X="{Binding Offset}" />
        </StackPanel.RenderTransform>
    </StackPanel>
</DataTemplate>

日期和时间

由于在折线图的水平轴上使用 DateTime 对象很常见,因此有必要花费精力对专门用于处理这些对象的 AxisStrategy 进行编码。某些折线图积累数据(例如,股票价格或环境读数),或许几乎每小时添加一个新项,最好使自我适应的 AxisStrategy 取决于已绘制成图表的项目中 DateTime 值的范围。

我使用 calledAdaptableDateTimeAxis 类进行尝试,该类的目的是适应从几秒到几年的大范围时间内的 DateTime 数据。

AdaptableDateTimeAxis 包含 1 个 MaximumItems 属性(默认设置为 10)和 6 个集合,名称分别为 SecondIncrements、MinuteIncrements、HourIncrements、DayIncrements、MonthIncrements 和 YearIncrements。此类系统地尝试找出刻度点之间的增量,以便项目数不会超过 MaximumItems。按照默认设置,AdaptableDateTimeAxis 将测试增量值 1 秒、2 秒、5 秒、15 秒和 30 秒,接着是 1 分钟、2 分钟、5 分钟、15 分钟和 30 分钟,然后是 1 小时、2 小时、4 小时、6 小时和 12 小时、1 天、2 天、5 天和 10 天以及 1 个月、2 个月、4 个月和 6 个月。达到年后,它将尝试测试增量值 1 年、2 年和 5 年,然后是 10 年、20 年和 50 年等等。

AdaptableDateTimeAxis 还定义只读依赖关系属性,名称为 DateTimeInterval(也是具有秒、分钟、小时等成员的枚举的名称),这表明坐标轴增量的单位由该类确定。此属性允许根据增量在 XAML 中定义更改 DateTime 格式的 DataTrigger。图 6 显示的是执行此类格式选择的 DataTemplate 示例。

图 6 TemperatureHistory 的水平轴上的 DataTemplate

<DataTemplate>
    <StackPanel HorizontalAlignment="Left">
        <Line Y2="10" Stroke="Black" />

        <TextBlock Name="txtblk"
                   RenderTransform="1 0 0 1 -4 -4">
            <TextBlock.LayoutTransform>
                <RotateTransform Angle="45" />        
            </TextBlock.LayoutTransform>
        </TextBlock>

        <StackPanel.RenderTransform>
            <TranslateTransform X="{Binding Offset}" />
        </StackPanel.RenderTransform>
    </StackPanel>

    <DataTemplate.Triggers>
        <DataTrigger Binding="{Binding Source={StaticResource generator}, 
                                       Path=HorizontalAxis.
                                       DateTimeInterval}" 
                     Value="Second">
            <Setter TargetName="txtblk" Property="Text" 
                 Value="{Binding Item, StringFormat=h:mm:ss d MMM yy}" />
        </DataTrigger>
        <DataTrigger Binding="{Binding Source={StaticResource generator}, 
                                       Path=HorizontalAxis.
                                       DateTimeInterval}" 
                     Value="Minute">
            <Setter TargetName="txtblk" Property="Text" 
                    Value="{Binding Item, StringFormat=h:mm d MMM yy}" />
        </DataTrigger>
        <DataTrigger Binding="{Binding Source={StaticResource generator}, 
                                       Path=HorizontalAxis.
                                       DateTimeInterval}" 
                     Value="Hour">
            <Setter TargetName="txtblk" Property="Text" 
                 Value="{Binding Item, StringFormat=’h tt, d MMM yy’}" />
        </DataTrigger>
        <DataTrigger Binding="{Binding Source={StaticResource generator}, 
                                       Path=HorizontalAxis.
                                       DateTimeInterval}" 
                     Value="Day">
            <Setter TargetName="txtblk" Property="Text" 
                    Value="{Binding Item, StringFormat=d}" />
        </DataTrigger>
        <DataTrigger Binding="{Binding Source={StaticResource generator}, 
                                       Path=HorizontalAxis.
                                       DateTimeInterval}" 
                     Value="Month">
            <Setter TargetName="txtblk" Property="Text" 
                    Value="{Binding Item, StringFormat=MMM yy}" />
        </DataTrigger>
        <DataTrigger Binding="{Binding Source={StaticResource generator}, 
                                       Path=HorizontalAxis.
                                       DateTimeInterval}" 
                     Value="Year">
            <Setter TargetName="txtblk" Property="Text" 
                    Value="{Binding Item, StringFormat=MMMM}" />
        </DataTrigger>
    </DataTemplate.Triggers>
</DataTemplate>

该模板来源于 TemperatureHistory 项目,该项目访问国家气象服务的网站获取纽约市中央公园的每小时温度读数。图 7 显示的是程序运行几小时后的 TemperatureHistory 显示;图 8 显示的是程序运行几天后的 TemperatureHistory 显示。

图 7 几小时后的 TemperatureHistory 显示

image: The TemperatureHistory Display with Hours

图 8 几天后的 TemperatureHistory 显示

image: The TemperatureHistory Display with Days

当然,我的折线图类并不完全灵活—例如,当前没有独立绘制与文本标签没有关联的刻度线的方法—但我认为它们引入了一个可行且有效的方法,为完全在 XAML 中定义折线图画面提供了足够的信息。

Charles Petzold 是《MSDN 杂志》的长期特约编辑。他的最新著作是  《The Annotated Turing: A Guided Tour through Alan Turing’s Historic Paper on Computability and the Turing Machine》(Wiley, 2008)。他的网站是 charlespetzold.com

衷心感谢以下技术专家审阅本文:David Teitelbaum