Introduction
There are quite a few chart controls around the Web, even from the WPF Toolkit. The problem I had with these implementations were that they did not work right for me. I just wanted a fast and easy to use Line series chart, which I have called BasicChart. It will just show the values given in an ObservableCollection that is bound to the ItemsSource in the BasicChart.
How to use it
I'm going to assume that you actually just want to know how to use it in the introduction, so I will just quickly run through the basic features.
I will assume that your series data are held in an ObservableCollection<LineSeries> variable. The LineSeries class should hold the actual data used to create a 2D chart, an ObservableCollection, or any object that can be represented by an IEnumerable that has a class that contains the individual X and Y values used, and an optional Title name property to identify the individual curve. If the Title is not set on the individual curves they are simply labeled Curve Nr. 1 etc based on the placement in the ObservableCollection.
So the Data class looks like this the NotifierBase is just a PropertyChanged wrapper class:
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);
}
}
}
The LineSeries class looks like this:
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);
}
}
}
And the XAML code that implements the Chart
<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"/>
That includes most of the normal settings with the BasicChart control.
The Layout
The BasicChart is created as a UserControl, and the most efficient way of placing the necessary objects in a line series chart, is by the use of a Grid with Column- and Row-definitions.
<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>
The Grid will give the following layout:
The title "Calculated values" is in the first row with a height of 50, the chart is in the second row, which is set to occupy the remaining space of the grid. The "Frequency" label is placed In the 3rd row with a fixed height of 40. The last row is reserved for the CheckBoxes that allows you to turn the individual curves on and off. The row height is set to Auto here, as it will allow the height to be adjusted to the content.
The labels, Title, Y-Axis and X-Axis, are just TextBlocks that have some minor styling applied to them. They are all bounded to Dependency Properties in the code, so without further ado:
<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" />
All the interesting and complex things happen in the main plot area. However, the grid cell height and width is set to "*", meaning take all the available space left over. I need the width and height of the grid cell, so to do that all objects inside the cell is placed inside a Border with a x:Name property set. In order to hide this FrameworkElement from the users of the control, I marked them as private with the x:FieldModifier:
<Border x:FieldModifier="private" x:Name="PlotAreaBorder"
SizeChanged="PlotAreaBorder_SizeChanged"
Grid.Row="1" Grid.Column="1"
HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
...
</Border>
This way, the PlotAreaBorder will be accessible in the code behind in the user control, but not outside the parent UserControl. A SizeChange event is also hooked to this Border.
Inside the border there is just a single canvas that holds all relevant Chart objects as its children:
<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>
The first two polylines is drawn at all the available size, that is they both start at 40 units to the right and 40 units up from the bottom left corner. The space to the left is used to show the Y axis value labels and the space at the bottom is used for the X-axis labels. I should say that all of these fields are declared with the x:FieldModefier="private" as I want it to be set while setting/updating the ItemsSource for the main control.
The ItemsControls are a bit interesting in itself, It just enables me to bind all items in a list (an ObservableCollection in my cast) to a single canvas. This is advantageous as the X and Y fields aren't supposed to be changed if you temporarily disable one of the curves. They both look almost the same, except for the variable that is changed:
<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>
It is important to notice that you need to give the User Control a x:Name attribute in order to bind to the child controls in the list. In short, you need this in order to be able to uniquely define and find the relevant control in the XAML file. This also applies to any aspect of change on the UserControl, applying an animation etc.
The PlotArea ItemsControl is a little different, I just want it to draw the PolyLines directly without manipulating the Y values in order to get them into the right position by using a ScaleTransform on the Canvas:
<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>
The actual X and Y label user controls are both marked with the ClassModefier="internal", that would make them inaccessible if you compile the Chart in a separate DLL file.
The ColorGenerator is taken from this question from StackOverflow.
The Code behind
As you would expect from a UserControl there is a whole host of Dependency Properties that can be set, that influences how the Chart will behave and look. The most important Dependency Property is indeed the ItemsSource property that holds all the data we want to plot.
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); }
}
As with all Dependency Properties that change the look or the collection that is bounded, it implements a PropertyChangedCallback, and all the actual binding happens there. The code first set up the Property and Collection changed listeners, these are used when you add/remove items and when you want to disable plotting of a curve. The drawing of elements happens in the SetUpYAxis, SetUpXAxis and SetUpGraph calls.
private static void OnItemsSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var MyBasicChart = (BasicChart)d;
foreach (var item in MyBasicChart.ItemsSource)
{
int i = MyBasicChart.CurveVisibility.Count;
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)
{
if (e.NewValue is INotifyCollectionChanged)
((INotifyCollectionChanged)e.NewValue).CollectionChanged += (s, ee) =>
ItemsSource_CollectionChanged(MyBasicChart, ee, (IEnumerable)e.NewValue);
}
if (e.OldValue != null)
{
if (e.OldValue is INotifyCollectionChanged)
((INotifyCollectionChanged)e.OldValue).CollectionChanged -=
(s, ee) => ItemsSource_CollectionChanged(MyBasicChart, ee, (IEnumerable)e.OldValue);
}
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");
}
}
All that is left to do is now to have the UserControl react to changes either in the Collection or a changed visibility value for one of the curves. I'll start off with the CollectionChanged event:
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);
}
}
As you can see I have only added support for the function calls Add and Remove, and all I really do is to add or remove the handler dealing with visibility for each of the actions. If the collection changes I redraw everything, as a new Y or additional X values could give a different look for the entire curve (although I currently do not support having different lengths on the X values for each curve, you will have to set the values you don't want to show to Double.NaN).
When visibility changed on the curve item it is enough to redraw the plot area, as the Y and Y values don't change as no new items have been added.
private static void OnCurveVisibilityChanged(BasicChart sender, IEnumerable NewValues)
{
SetUpGraph(sender, NewValues);
}
Next up, the low-level drawing functions.
Drawing the axis and curves
As described previously, the UI elements of the curve, and of the Y and X-axis as well, is done with the use of an ItemsControl that draws the items on a canvas. This means that the elements are bounded to the ItemsContol's ItemsSource property, with in turn is bounded to a Dependency Property of the type ObservableCollection.
The X and Y-axis are actually a UserControl with the XAML of the X-axis items as the following:
<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>
The code is pretty straightforward, except for the code regarding the text rotation field. It looks better if the non-angle (0 degrees) is centered, while the tilted is set to the left which in turn will rotate the text at the point where the line begins.
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;
}
}
The other aesthetic trick I do on the control, is the line length if the label is missing:
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);
}
}
The Y-axis UserControl element is almost the same, except there is no possibility to rotate the text field.