简单的 WPF 折线图控件





5.00/5 (13投票s)
一个简单的WPF图表控件,用于绘制2D折线图。
引言
网上有很多图表控件,甚至来自WPF Toolkit。我遇到的问题是这些实现对我来说并不好用。我只是想要一个快速易用的折线图,我称之为BasicChart。它将显示绑定到BasicChart的ItemsSource中的ObservableCollection中提供的值。
如何使用
我假设在引言中你只想知道如何使用它,所以我将快速介绍基本功能。
我将假设你的系列数据保存在一个ObservableCollection<LineSeries>变量中。LineSeries类应该包含用于创建2D图表的实际数据,一个ObservableCollection,或者任何可以被表示为IEnumerable的对象,其中包含用于绘制单个X和Y值的类,以及一个可选的Title属性来标识单个曲线。如果未设置单个曲线的Title,则它们将根据在ObservableCollection中的位置简单地标记为Curve Nr. 1等。
所以Data类看起来是这样的,NotifierBase只是一个PropertyChanged包装类
public class Data : NotifierBase
{
private double m_Frequency = new double();
public double Frequency
{
get { return m_Frequency; }
set
{
SetProperty(ref m_Frequency, value);
}
}
private double m_Value = new double();
public double Value
{
get { return m_Value; }
set
{
SetProperty(ref m_Value, value);
}
}
}
LineSeries类看起来是这样的
public class LineSeries : NotifierBase
{
private ObservableCollection<Data> m_MyData = new ObservableCollection<Data>();
public ObservableCollection<Data> MyData
{
get { return m_MyData; }
set
{
SetProperty(ref m_MyData, value);
}
}
private string m_Name = "";
public string Name
{
get { return m_Name; }
set
{
SetProperty(ref m_Name, value);
}
}
}
以及实现图表的XAML代码
<Chart:BasicChart x:Name="MyChart" Height="350" Width="500"
DataCollectionName="MyData"
DisplayMemberLabels="Frequency"
DisplayMemberValues="Value"
SkipLabels="3"
StartSkipAt="1"
ShowGraphPoints="True"
ChartTitle="Calcualted values" YLabel="Magnitude"
XLabel="Freqency [Hz]" YMax="60" YMin="0" DoubleToString="N0"
XMin="1" XMax="24"/>
这包含了BasicChart控件的大部分常规设置。
布局
BasicChart被创建为一个UserControl,在折线图中最有效地放置必需对象的方法是使用具有行和列定义的Grid。
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="40"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="50"></RowDefinition>
<RowDefinition Height="*"/>
<RowDefinition Height="40"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
Grid将提供以下布局
标题“Calculated values”位于第一行,高度为50,图表位于第二行,设置为占据网格的剩余空间。“Frequency”标签位于第三行,固定高度为40。最后一行用于允许您打开/关闭单个曲线的复选框。此处的行高设置为Auto,因为它允许根据内容调整高度。
标签、标题、Y轴和X轴只是具有一些轻微样式的TextBlock。它们都绑定到代码中的依赖属性,所以废话不多说
<TextBlock Grid.Column="0" Grid.Row="0" Grid.ColumnSpan="2"
FontSize="16" FontWeight="ExtraBold" TextAlignment="Center"
Text="{Binding RelativeSource={RelativeSource AncestorType={x:Type local:BasicChart}}, Path=ChartTitle}"></TextBlock>
<TextBlock Text="{Binding Path=YLabel}" Width="200" Grid.Column="0" Grid.Row="1"
TextAlignment="center" VerticalAlignment="Center" HorizontalAlignment="Center" >
<TextBlock.LayoutTransform>
<RotateTransform Angle="-90" />
</TextBlock.LayoutTransform>
</TextBlock>
<TextBlock Grid.Column="1" Grid.Row="2"
Text="{Binding RelativeSource={RelativeSource AncestorType={x:Type local:BasicChart}}, Path=XLabel}"
Width="100" Margin="10" TextAlignment="Center" />
所有有趣和复杂的事情都发生在主绘图区域。但是,网格单元格的高度和宽度设置为“*”,表示占用所有剩余的可用空间。我需要网格单元格的宽度和高度,为此,单元格内的所有对象都放置在一个带有x:Name属性的Border中。为了将此FrameworkElement隐藏给控件的用户,我将其标记为私有(x:FieldModifier)
<Border x:FieldModifier="private" x:Name="PlotAreaBorder"
SizeChanged="PlotAreaBorder_SizeChanged"
Grid.Row="1" Grid.Column="1"
HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
...
</Border>
这样,PlotAreaBorder将在用户控件的代码隐藏中可访问,但在父UserControl外部不可访问。还为该Border挂接了一个SizeChange事件。
Border内部只有一个Canvas,其中包含所有相关的图表对象作为其子项
<Canvas Background="White" >
<Canvas.Children>
<Polyline x:Name="YAxisLine" ...
<Polyline x:Name="XAxisLine" ...
<ItemsControl x:Name="PlotArea" ...
<ItemsControl x:Name="YAxis" ...
<ItemsControl x:Name="XAxis" ...
</Canvas.Children>
</Canvas>
前两个折线图以所有可用大小绘制,即它们都从左下角向上40个单位和向右40个单位开始。左侧空间用于显示Y轴值标签,底部空间用于X轴标签。我应该说,所有这些字段都声明为x:FieldModefier="private",因为我想在设置/更新主控件的ItemsSource时设置它们。
ItemsControls本身有点意思。它只是使我能够将列表中的所有项目(在本例中是ObservableCollection)绑定到一个Canvas。这很有优势,因为如果您暂时禁用其中一条曲线,X和Y字段不应该被更改。它们看起来几乎相同,除了改变的变量
<ItemsControl x:FieldModifier="private" x:Name="YAxis" Canvas.Bottom="40" Canvas.Left="0" Width="40" Height="170" ItemsSource="{Binding YItems}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<Canvas/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemContainerStyle>
<Style>
<Setter Property="Canvas.Bottom" Value="{Binding ElementName=YAxis, Path=YLocation}"/>
<Setter Property="Canvas.Left" Value="40"/>
</Style>
</ItemsControl.ItemContainerStyle>
</ItemsControl>
重要的是要注意,您需要为User Control赋予x:Name属性,以便能够绑定到列表中的子控件。简而言之,您需要这个才能在XAML文件中唯一地定义和找到相关的控件。这同样适用于UserControl的任何更改方面,应用动画等。
PlotArea ItemsControl略有不同,我只是想直接绘制PolyLines,而无需通过缩放Canvas来操纵Y值以将其放入正确位置。
<ItemsControl x:FieldModifier="private" x:Name="PlotArea" Canvas.Bottom="40" Canvas.Left="40" ClipToBounds="True" ItemsSource="{Binding}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<Canvas >
<Canvas.LayoutTransform>
<ScaleTransform ScaleX="1" ScaleY="-1"></ScaleTransform>
</Canvas.LayoutTransform>
</Canvas>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
实际的X和Y标签用户控件都标记为ClassModefier="internal",如果您将Chart编译为单独的DLL文件,它们将无法访问。
ColorGenerator取自StackOverflow的此问题。
代码后台
正如您对UserControl所期望的那样,有许多依赖属性可以设置,这些属性会影响图表的行为和外观。最重要的依赖属性是ItemsSource属性,它包含我们想要绘制的所有数据。
public static readonly DependencyProperty ItemsSourceProperty = DependencyProperty.Register("ItemsSource", typeof(IEnumerable), typeof(BasicChart), new FrameworkPropertyMetadata(null, new PropertyChangedCallback(OnItemsSourceChanged))); public IEnumerable ItemsSource { get { return (IEnumerable)GetValue(ItemsSourceProperty); } set { SetValue(ItemsSourceProperty, value); } }
与所有更改外观或绑定集合的依赖属性一样,它实现了PropertyChangedCallback,并且所有实际绑定都在那里发生。代码首先设置属性和集合更改侦听器,这些侦听器在添加/删除项以及禁用曲线绘图时使用。元素绘制发生在 SetUpYAxis、SetUpXAxis 和 SetUpGraph 调用中。
private static void OnItemsSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var MyBasicChart = (BasicChart)d;
foreach (var item in MyBasicChart.ItemsSource)
{
int i = MyBasicChart.CurveVisibility.Count;
// Set up a Notification if the IsChecked property is changed
MyBasicChart.CurveVisibility.Add(new CheckBoxClass() { BackColor = DistinctColorList[i], Name = "Curve nr: " + (i+1).ToString() });
((INotifyPropertyChanged)MyBasicChart.CurveVisibility[MyBasicChart.CurveVisibility.Count - 1]).PropertyChanged +=
(s, ee) => OnCurveVisibilityChanged(MyBasicChart, (IEnumerable)e.NewValue);
}
if (e.NewValue != null)
{
// Assuming that the curves are binded using an ObservableCollection,
// it needs to update the Layout if items are added, removed etc.
if (e.NewValue is INotifyCollectionChanged)
((INotifyCollectionChanged)e.NewValue).CollectionChanged += (s, ee) =>
ItemsSource_CollectionChanged(MyBasicChart, ee, (IEnumerable)e.NewValue);
}
if (e.OldValue != null)
{
// Unhook the Event
if (e.OldValue is INotifyCollectionChanged)
((INotifyCollectionChanged)e.OldValue).CollectionChanged -=
(s, ee) => ItemsSource_CollectionChanged(MyBasicChart, ee, (IEnumerable)e.OldValue);
}
// Check that the properties to bind to is set
if (MyBasicChart.DisplayMemberValues != "" && MyBasicChart.DisplayMemberLabels != "" && MyBasicChart.DataCollectionName != "")
{
SetUpYAxis(MyBasicChart);
SetUpXAxis(MyBasicChart);
SetUpGraph(MyBasicChart, (IEnumerable)e.NewValue);
}
else
{
MessageBox.Show("Values that indicate the X value and the resulting Y value must be given, as well as the name of the Collection");
}
}
现在只剩下让UserControl响应集合更改或其中一条曲线可见性值更改了。我将从CollectionChanged事件开始
private static void ItemsSource_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e, IEnumerable eNewValue)
{
var MyClass = (BasicChart)sender;
if (e.Action == NotifyCollectionChangedAction.Add)
{
MyClass.CurveVisibility.Add(new CheckBoxClass() {
BackColor = DistinctColorList[MyClass.CurveVisibility.Count],
IsChecked = true,
Name = "Curve nr: " + (MyClass.CurveVisibility.Count+1).ToString() });
((INotifyPropertyChanged)MyClass.CurveVisibility[MyClass.CurveVisibility.Count - 1]).PropertyChanged
+= (s, ee) => OnCurveVisibilityChanged(MyClass, eNewValue);
}
else if (e.Action == NotifyCollectionChangedAction.Remove)
{
((INotifyPropertyChanged)MyClass.CurveVisibility[e.OldStartingIndex]).PropertyChanged
-= (s, ee) => OnCurveVisibilityChanged(MyClass, eNewValue);
MyClass.CurveVisibility.RemoveAt(e.OldStartingIndex);
}
if (MyClass.DisplayMemberValues != "" && MyClass.DisplayMemberLabels != "" && MyClass.DataCollectionName!= "")
{
SetUpYAxis(MyClass);
SetUpXAxis(MyClass);
SetUpGraph(MyClass, eNewValue);
}
}
正如您所见,我只添加了对函数调用Add和Remove的支持,而我实际上所做的只是为每个操作添加或删除处理可见性的处理程序。如果集合更改,我会重绘所有内容,因为新的Y或额外的X值可能会使整个曲线看起来不同(尽管我目前不支持每条曲线的X值长度不同,您需要将不想显示的值设置为Double.NaN)。
当曲线项的可见性发生变化时,足以重绘绘图区域,因为Y和Y值不会改变,因为没有添加新项。
private static void OnCurveVisibilityChanged(BasicChart sender, IEnumerable NewValues)
{
SetUpGraph(sender, NewValues);
}
接下来是低级绘图功能。
绘制轴和曲线
如前所述,曲线以及Y轴和X轴的UI元素是通过使用ItemsControl完成的,该控件在Canvas上绘制元素。这意味着元素绑定到ItemsContol的ItemsSource属性,而ItemsSource属性又绑定到ObservableCollection类型的依赖属性。
X和Y轴实际上是一个UserControl,X轴项的XAML如下
<UserControl x:Class="WpfAcousticTransferMatrix.ChartControl.XAxisLabels"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:ClassModifier="internal"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:WpfAcousticTransferMatrix.ChartControl"
x:Name="XAxis"
mc:Ignorable="d"
d:DesignHeight="40" d:DesignWidth="20">
<UserControl.Resources>
<BooleanToVisibilityConverter x:Key="BoolConverter"></BooleanToVisibilityConverter>
</UserControl.Resources>
<Canvas>
<Canvas.Children>
<Polyline x:Name="XLine" Points="0,0 0,5" Stroke="{Binding RelativeSource={RelativeSource
AncestorType=UserControl}, Path=LineColor}" StrokeThickness="1"/>
<TextBlock x:Name="MyLabel" Width="50" Margin="-25,0,0,0" TextAlignment="Center" Text="{Binding RelativeSource={RelativeSource AncestorType=UserControl}, Path=XLabel}" Visibility="{Binding RelativeSource={RelativeSource AncestorType=UserControl}, Path=XLabelVisible, Converter={StaticResource BoolConverter}}" Canvas.Top="10">
<TextBlock.LayoutTransform>
<RotateTransform Angle="{Binding RelativeSource={RelativeSource
AncestorType=UserControl}, Path=LabelAngle}"></RotateTransform>
</TextBlock.LayoutTransform>
</TextBlock>
</Canvas.Children>
</Canvas>
</UserControl>
代码相当直接,除了关于文本旋转字段的代码。如果非角度(0度)居中,而倾斜设置为左侧,则文本看起来更好,这会旋转文本的起始点。
public static void OnLabelAngleChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var test = (XAxisLabels)d;
double value = (double)e.NewValue;
if (value == 0)
{
test.MyLabel.Margin = new Thickness(-25, 0, 0, 0);
test.MyLabel.TextAlignment = TextAlignment.Center;
}
else
{
test.MyLabel.Margin = new Thickness(0, 0, 0, 0);
test.MyLabel.TextAlignment = TextAlignment.Left;
}
}
我在控件上做的另一个美学技巧是,如果标签缺失,则线长
private static void XLabelChange(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var MyLabelClass = (XAxisLabels)d;
if (MyLabelClass.XLabel == "")
{
MyLabelClass.XLine.Points[1] = new Point(0, 5);
}
else
{
MyLabelClass.XLine.Points[1] = new Point(0, 10);
}
}
Y轴UserControl元素几乎相同,只是没有可能旋转文本字段。