![]() |
Platforms, Frameworks & Libraries »
Windows Presentation Foundation »
Controls
Intermediate
License: The Code Project Open License (CPOL)
WPF : A graph controlBy Sacha Barber, Richard E KingA WPF graph control with autoscaling and historical data support |
C# (C# 3.0), .NET (.NET 3.5), WPF, CEO, Architect, Dev, Design
|
||||||||
|
Advanced Search Add to IE Search |
|
|
|
||||||||||||||||

I am now working in a company where we deal with lots of numbers (currency rates to be precise), and a work collegue and I thought it may be nice to try and show these change of rates using a graph. So we did a bit of looking around and couldn't really find what we were after, there was one ok'ish free graph out there by Andre de Cavaignac when he was at Lab49, but Andres graph was so tied into how Lab49 obviously needed it to work, that we decided to create our own. The code base presented in this article used literally no code from anywhere else, though the look of the graph is simliar to that of Anndre de Cavaignacs graph. Andre did a find job on making a sexy graph.
The graph that this demo project includes supports autoscaling y-axis dependant on the current window of readings, and is also allows panning left/right if you have enabled that option.
As the attached application is using SQL server functions you will need to make sure that you have created the WPFTicker database, and the database schema (there is only 1 table), which you can do using the SQL setup scripts which are part of the ZIP file at the top of the article. Once you have done that you should ammend the the App.Config in Visual Studio to suit your own SQL installation. I have left the attached App.Config with my SQL instance within the config file, so you can see what you need to change for your own installation.
At its simplist the graph is actually 2 controls, a GraphTicker
control which is a container for the actual Graph control. The
GraphTicker also has some extra buttons to allow the Graph
control to be paused, and it also supports PanLeft/PanRight functions if you
have that setting turned on.

In order to make the graph as flexable as possible, we decided to store some settings in a Settings file (that is a standard Settings file in VS2008).


Now if you have not worked with Settings in Visual Studio all that is important for you to know is that these settings can be altered without having to re-compile that application. Which is done via the App.Config file which includes all Settings. Here is the App.Config file for the attached code, where you can see that it suports all the Settings mentioned above.
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<configSections>
<sectionGroup name="applicationSettings"
type="System.Configuration.ApplicationSettingsGroup,
System, Version=2.0.0.0, Culture=neutral,
PublicKeyToken=b77a5c561934e089" >
<section name="WPFTicker.Properties.Settings"
type="System.Configuration.ClientSettingsSection,
System, Version=2.0.0.0, Culture=neutral,
PublicKeyToken=b77a5c561934e089" requirePermission="false" />
</sectionGroup>
</configSections>
<connectionStrings>
<add name="WPFTicker.Properties.Settings.WPF_TickerConnectionString"
connectionString="Data Source=YOUR_SQL_INSTALLATION;Initial
Catalog=WPF_Ticker;User ID=sa;Password=sa"
providerName="System.Data.SqlClient" />
</connectionStrings>
<applicationSettings>
<WPFTicker.Properties.Settings>
<setting name="SAMPLE_INTERVAL_IN_MS"
serializeAs="String">
<value>1000</value>
</setting>
<setting name="SAMPLE_WINDOW_SIZE"
serializeAs="String">
<value>10</value>
</setting>
<setting name="SUPPORTS_HISTORICAL_DATA"
serializeAs="String">
<value>True</value>
</setting>
<setting name="MAX_NUM_OF_ITEMS_BEFORE_PLOTTING_OCCCURS"
serializeAs="String">
<value>2</value>
</setting>
</WPFTicker.Properties.Settings>
</applicationSettings>
</configuration>
There are the following settings that you can change to what ever suits your needs, though we will be making some recommendations as to suitable values.
Graph window before the
items are removed and will no longer be shown in the Graph window.
You can make this what you like, its up to you how many readings you want
to show in the Graph at any one time. Obviously try and be sensible
as a huge value here, means more memory is eaten to provide all the objects
that the Graph will need to showGraph control, to determine how many points should
be seen before plotting starts. You should ALWAYS set this to something greater
than 2. Though the Graph control will automaticaly resolve this
to 2 if you put a value < 2 in for this setting.Note this assumes you have used the SQL Setup scripts at the top of the article to create the database and schema that this article requires.
As you may have guessed by now the Graph allows you to pan through
old readings and pan right if you are not at the end of the all available readings.
In order to allow for this, there must be some historical data stored somewhere.
Well yes there is, it is stored in SQL server. As we just mentioned in order
to allow the storing and panning through this SQL stored data you will need
to make sure that the SUPPORTS_HISTORICAL_DATA flag is turned on.
The dataprovider will need to check that the SUPPORTS_HISTORICAL_DATA flag, and if it is allowed to store historical data it will use some LINQ to SQL to store a new row within the TickerSamples table within SQL.
When the GraphTicker control is loaded it will allow the PanLeft/PanRight
functions to be shown. It essence if you have allowed the SUPPORTS_HISTORICAL_DATA
flag, the PanLeft/PanRight buttons will be shown, otherwise they will not.

In order to undestand the code, we thought it may be best to break it down into certain key areas, which will hopefully explain how it all hangs together.
The GraphTicker is expecting to use a ThreadSafeObservableCollection<GraphDataItem>
which in itself deserves another dicussion which we will get onto in just a
minute. The attached demo code provides an example data provider, which is currently
serving up Random values. Apart from the fact that it is currently using Random
values it is pretty much exactly what you would need to do for real data. Let
us have a look at it shall we.
The code looks like this.
public class DataValueSimulator
{
#region Data
private ThreadSafeObservableCollection<GraphDataItem> dataValues = null;
private Timer timer = new Timer(1000);
private Random rand = new Random(50);
private Int32 SamplesWindowSize = 30;
private WPFTickerDataContext dataContext = new WPFTickerDataContext();
private long currentSampleCounter = 0;
private Boolean SupportsHistoricalData = true;
#endregion
#region Ctor
public DataValueSimulator()
{
dataValues = new ThreadSafeObservableCollection<GraphDataItem>();
//Obtain the settings from the stored settings, these can be changed
//via the app.config
SamplesWindowSize =
WPFTicker.Properties.Settings.Default.SAMPLE_WINDOW_SIZE;
timer.Interval =
WPFTicker.Properties.Settings.Default.SAMPLE_INTERVAL_IN_MS;
SupportsHistoricalData =
WPFTicker.Properties.Settings.Default.SUPPORTS_HISTORICAL_DATA;
}
#endregion
#region Public Methods
/// <summary>
/// Creates a new timer and every timer tick, creates a new GraphDataItem
/// and also determines if an existing GraphDataItem should be removed from
/// the mantained DataValue, basically only maintain a window of data values
/// </summary>
public void Run()
{
//Make sure we start out with a blank database for our samples
dataContext = new WPFTickerDataContext();
dataContext.ExecuteCommand("DELETE FROM TickerSamples");
timer.Enabled = true;
timer.Start();
timer.Elapsed += (s, e) =>
{
if (dataValues.Count == SamplesWindowSize)
{
dataValues.RemoveAt(0);
GC.Collect();
}
//geta new item based on the values
GraphDataItem dataItem = new GraphDataItem
{
DataValue = (double)rand.NextDouble() * 50,
TimeSampled = DateTime.Now,
SampleSequenceNumber=currentSampleCounter++
};
//If Graph is not currently paused, add it to the list
//that the Graph uses
if (Graph.CanAcceptNewStreamingReadings)
dataValues.Add(dataItem);
//If the app is configured to allow historcal data
//we need to store some historical data in the database
if (SupportsHistoricalData)
{
dataContext = new WPFTickerDataContext();
//Now store the item in the Database, so we can see some historical values
//later on if we want to
dataContext.TickerSamples.InsertOnSubmit(
new TickerSample
{
SampleDate = dataItem.TimeSampled,
SampleValue = dataItem.DataValue,
SampleSequenceNumber = dataItem.SampleSequenceNumber
});
dataContext.SubmitChanges();
}
};
}
#endregion
#region IPublic Properties
public ThreadSafeObservableCollection<GraphDataItem> DataValues
{
get { return dataValues; }
set
{
dataValues = value;
}
}
#endregion
}
As you can see this class uses several of The Settings that were discussed earlier. This data provider will ensure that only a certain number of values will be stored (using the SAMPLE_WINDOW_SIZE setting). A new value will be created every x time tick (using the SAMPLE_INTERVAL_IN_MS setting). Also this provider will only store a value in SQL server if the SUPPORTS_HISTORICAL_DATA flag is turned on.
If you want to create a new data provider this class should hold all the answers, you should simply remove the Random value generation and replace that with your own obtained business values.
It can be seen from the code above that this provider actually makes use of
a ThreadSafeObservableCollection<GraphDataItem>. Well what
is that. Well is isn't a standard .NET framework class, it is a class that we
fashioned that allows thread safe access to an ObservableCollection<T>.
We actually needed such a class to ensure thread affinity. Anyway it is a fairly
useful class and it looks like this.
/// <summary>
/// Provides a threadsafe ObservableCollection of T
/// </summary>
public class ThreadSafeObservableCollection<T>
: ObservableCollection<T>
{
#region Data
private Dispatcher _dispatcher;
private ReaderWriterLockSlim _lock;
#endregion
#region Ctor
public ThreadSafeObservableCollection()
{
_dispatcher = Dispatcher.CurrentDispatcher;
_lock = new ReaderWriterLockSlim();
}
#endregion
#region Overrides
/// <summary>
/// Clear all items
/// </summary>
protected override void ClearItems()
{
_dispatcher.InvokeIfRequired(() =>
{
_lock.EnterWriteLock();
try
{
base.ClearItems();
}
finally
{
_lock.ExitWriteLock();
}
}, DispatcherPriority.DataBind);
}
/// <summary>
/// Inserts an item
/// </summary>
protected override void InsertItem(int index, T item)
{
_dispatcher.InvokeIfRequired(() =>
{
if (index > this.Count)
return;
_lock.EnterWriteLock();
try
{
base.InsertItem(index, item);
}
finally
{
_lock.ExitWriteLock();
}
}, DispatcherPriority.DataBind);
}
/// <summary>
/// Moves an item
/// </summary>
protected override void MoveItem(int oldIndex, int newIndex)
{
_dispatcher.InvokeIfRequired(() =>
{
_lock.EnterReadLock();
Int32 itemCount = this.Count;
_lock.ExitReadLock();
if (oldIndex >= itemCount |
newIndex >= itemCount |
oldIndex == newIndex)
return;
_lock.EnterWriteLock();
try
{
base.MoveItem(oldIndex, newIndex);
}
finally
{
_lock.ExitWriteLock();
}
}, DispatcherPriority.DataBind);
}
/// <summary>
/// Removes an item
/// </summary>
protected override void RemoveItem(int index)
{
_dispatcher.InvokeIfRequired(() =>
{
if (index >= this.Count)
return;
_lock.EnterWriteLock();
try
{
base.RemoveItem(index);
}
finally
{
_lock.ExitWriteLock();
}
}, DispatcherPriority.DataBind);
}
/// <summary>
/// Sets an item
/// </summary>
protected override void SetItem(int index, T item)
{
_dispatcher.InvokeIfRequired(() =>
{
_lock.EnterWriteLock();
try
{
base.SetItem(index, item);
}
finally
{
_lock.ExitWriteLock();
}
}, DispatcherPriority.DataBind);
}
#endregion
#region Public Methods
/// <summary>
/// Return as a cloned copy of this Collection
/// </summary>
public T[] ToSyncArray()
{
_lock.EnterReadLock();
try
{
T[] _sync = new T[this.Count];
this.CopyTo(_sync, 0);
return _sync;
}
finally
{
_lock.ExitReadLock();
}
}
#endregion
}
This class also relies on the following Extension method
/// <summary>
/// WPF Threading extension methods
/// </summary>
public static class WPFControlThreadingExtensions
{
#region Public Methods
/// <summary>
/// A simple WPF threading extension method, to invoke a delegate
/// on the correct thread if it is not currently on the correct thread
/// Which can be used with DispatcherObject types
/// </summary>
/// <param name="disp">The Dispatcher object on which to
///do the Invoke</param>
/// <param name="dotIt">The delegate to run</param>
/// <param name="priority">The DispatcherPriority</param>
public static void InvokeIfRequired(this Dispatcher disp,
Action dotIt, DispatcherPriority priority)
{
if (disp.Thread != Thread.CurrentThread)
{
disp.Invoke(priority, dotIt);
}
else
dotIt();
}
#endregion
}
In order to use the GraphTicker (which holds an internal Graph
control) within your own app, it could not be easier, simply set the object
in XAML and wire up the DataValues and set a title. Here is an example
<Window x:Class="WPFTicker.Window1"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:WPFTicker"
Title="A Simple WPF Ticker"
SizeToContent="WidthAndHeight"
WindowStartupLocation="CenterScreen" >
<local:GraphTicker x:Name="graphTicker"
Width="700" Height="450" Margin="10"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Window>
And here is the code behind.
/// <summary>
/// Holds a single instance of the WPFTicker.GraphTicker control
/// which is setup to use simulated values
/// </summary>
public partial class Window1 : Window
{
public Window1()
{
InitializeComponent();
this.Loaded += (s, e) =>
{
//Create a new simulator and graph the DataValues from it.
//It will be up to you how you create the actual data
//but the DataValues is expected to be of the type
//ThreadSafeObservableCollection<GraphDataItem>, and if you want
//to correctly support panning in the Graph you should
//make sure to only add items to the DataValues if the
//Graph.CanAcceptNewStreamingReadings is true. For example
//in your data provider, do something like
//<Code>
// if (Graph.CanAcceptNewStreamingReadings)
// dataValues.Add(dataItem);
//</Code>
DataValueSimulator simulator = new DataValueSimulator();
simulator.Run();
graphTicker.DataValues = simulator.DataValues;
graphTicker.GraphTitle = "Simulated Values";
};
}
}
So now we will go on to discuss the nitty gritty of the 2 controls where it all happens. So marching on..
The GraphTicker is the control that you will use on your Window,
and it holds an internal Graph control. The GraphTicker
control has the following Dependency properties declared. In both cases these
are simply used to set the matching DependencyProperty on the contained Graph
control. So they are not that exciting. However the GraphTicker
controld also deals with the panning of SQL stored data (if that is enabled
see SUPPORTS_HISTORICAL_DATA flag in The Settings section).
So let us examine how that works shall we.
Quite simply when the GraphTicker loads, it works out whether to show the PanLeft/PanRight buttons based on whether the SUPPORTS_HISTORICAL_DATA flag is on or off.
/// <summary>
/// Works out whether to show the historical buttons or not
/// based on the SUPPORTS_HISTORICAL_DATA Setting
/// </summary>
private void GraphTicker_Loaded(object sender, RoutedEventArgs e)
{
SupportsHistoricalData =
WPFTicker.Properties.Settings.Default.SUPPORTS_HISTORICAL_DATA;
BtnPanLeft.Visibility = SupportsHistoricalData ?
Visibility.Visible : Visibility.Collapsed;
BtnPanRight.Visibility = SupportsHistoricalData ?
Visibility.Visible : Visibility.Collapsed;
}
So how about this panning we keep mentioning, how does that work. Well basically
its all about where the current window of values is compared to those stored
in the database. To facilitate this, each GraphDataItem has a SampleSequenceNumber
property which is used to check against the TickerSample objects
that are stored in the database (using LINQ to SQL, see the SQL Interaction
folder in the VS2008 solution, its the WPFTicker.dbml file that is the LINQ
to SQL stuff).
If you are panning left, there must be at least a whole viewable windows worth
of TickerSample values stored in the database, before the first
item in the current viewable window of value. Recall the size of the viewable
window is defined by the SAMPLE_WINDOW_SIZE setting (see The
Settings section).
If there are enough stored values they are fetched.
Here is the code that deals with the PanLeft.
/// <summary>
/// Pauses the Graph, so will not accept any new live Stream values
/// until the Graph is taken out of Pause mode. And also checks if current
/// 1st sequence number - total number of sample points in window < 0.
/// And if this is ok, grabs a new window of points
/// </summary>
private void BtnPanLeft_Click(object sender, RoutedEventArgs e)
{
//Need to check if current 1st sequence number - total number of sample
//points in window > 0
graph.IsPaused = true;
Double firstItemInWindowSequenceNumber =
DataValues.First().SampleSequenceNumber;
SamplesWindowSize =
WPFTicker.Properties.Settings.Default.SAMPLE_WINDOW_SIZE;
try
{
Double lowerLimitSequenceToFetch = firstItemInWindowSequenceNumber -
SamplesWindowSize;
if (firstItemInWindowSequenceNumber - SamplesWindowSize > 0)
{
WPFTickerDataContext dataContext = new WPFTickerDataContext();
//need to get the values from the DB
//and ask graph to ObtainPointsForValues
var dbReadSamples =
(from samples in dataContext.TickerSamples
where samples.SampleSequenceNumber >=
lowerLimitSequenceToFetch &&
samples.SampleSequenceNumber <
firstItemInWindowSequenceNumber
select samples);
if (dbReadSamples.Count() > 0)
{
DataValues.Clear();
foreach (var sample in dbReadSamples)
{
DataValues.Add(new GraphDataItem
{
DataValue = sample.SampleValue,
SampleSequenceNumber = sample.SampleSequenceNumber,
TimeSampled = sample.SampleDate
});
}
graph.ObtainPointsForValues();
}
else
{
//if there are no values (there should be, but just to be safe)
//accept new streaming live values if we cant pan to old ones
graph.IsPaused = false;
}
}
}
catch
{
//if a database exception occurs with the LINQ to SQL stuff
//there is not much we can do abouu, apart from start streaming the
//current live values instead
graph.IsPaused = false;
}
}
Panning right is the same principle. It uses the last item in the current viewable window and inspects the database to see if there are enough stored values after the last item in the current viewable window, to fill a new viewable windows worth of values from the database. If there are, they are fetched.
Here is the code that deals with the PanRight.
/// <summary>
/// Pauses the Graph, so will not accept any new live Stream values
/// until the Graph is taken out of Pause mode. And also checks if current
/// last sequence number + total number of sample points in window <
/// last item stored in the database. And if this is ok, grabs a new window
/// of points
/// </summary>
private void BtnPanRight_Click(object sender, RoutedEventArgs e)
{
//Need to check if current last sequence number + total number of sample
//points in window < last item stored
graph.IsPaused = true;
Double lastItemInWindowSequenceNumber =
DataValues.Last().SampleSequenceNumber;
SamplesWindowSize =
WPFTicker.Properties.Settings.Default.SAMPLE_WINDOW_SIZE;
try
{
Double uppLimitSequenceToFetch = lastItemInWindowSequenceNumber +
SamplesWindowSize;
WPFTickerDataContext dataContext = new WPFTickerDataContext();
var highestSequenceNumberStored =
(from samples in dataContext.TickerSamples
select samples).Max(s => s.SampleSequenceNumber);
if (lastItemInWindowSequenceNumber + SamplesWindowSize <
highestSequenceNumberStored)
{
//need to get the values from the DB
//and ask graph to ObtainPointsForValues
var dbReadSamples =
(from samples in dataContext.TickerSamples
where samples.SampleSequenceNumber >=
lastItemInWindowSequenceNumber &&
samples.SampleSequenceNumber <
uppLimitSequenceToFetch
select samples);
if (dbReadSamples.Count() > 0)
{
DataValues.Clear();
foreach (var sample in dbReadSamples)
{
DataValues.Add(new GraphDataItem
{
DataValue = sample.SampleValue,
SampleSequenceNumber = sample.SampleSequenceNumber,
TimeSampled = sample.SampleDate
});
}
graph.ObtainPointsForValues();
}
else
{
//if there are no values (there should be, but just to be safe)
//accept new streaming live values if we cant pan to old ones
graph.IsPaused = false;
}
}
}
catch
{
//if a database exception occurs with the LINQ to SQL stuff
//there is not much we can do about, apart from start streaming the
//current live values instead
graph.IsPaused = false;
}
}
What actually happens in both these cases is that if new values can be read
from the database, they are, and the current ThreadSafeObservableCollection<GraphDataItem>
is cleared and then the new items are added to the ThreadSafeObservableCollection<GraphDataItem>,
after which the internal Graph control is instructed to create points (the actual
X/Y points, but more on this in the Graph Section below) for its current values.
This is done by the call to the graph.ObtainPointsForValues() method
you can see in the code above.
One thing that is worth a mention is that when the panning functions are used
the Graph is put into a Paused state. When the Graph
is in the paused state it sets a CanAcceptNewStreamingReadings
static field on the Graph type to false. It is the job of the data provider
to not provide any new values while the Graph.CanAcceptNewStreamingReadings
is false. If we go back and look at the code for the example data provider
you can see this more clearly.
//If Graph is not currently paused, add it to the list
//that the Graph uses
if (Graph.CanAcceptNewStreamingReadings)
dataValues.Add(dataItem);
To take the Graph out of a paused state, which will reset the
Graph.CanAcceptNewStreamingReadings flag which will allow new
data through, you can use the Pause/Resume button.
Ok so now that we have seen that a GraphTicker holds a Graph
and that the GraphTicker can force the Graph to display
some different values that are the result of panning left/right, which is all
good, but how does the Graph work. Well in reality the Graph
is one of the simpler classes, as really all it does is accept a ThreadSafeObservableCollection<GraphDataItem>
which it then uses to create an internal collection of Point objects
which represent the strokes of the values, which are then used to create a collection
of points that are used to as Polyline.Points for a Polyline
object that is used within the Graph controls XAML. Here is XAML
for the Graph control.
<UserControl x:Class="WPFTicker.Graph"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Name="parent"
Height="350" Width="700">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="50"/>
<RowDefinition Height="300"/>
</Grid.RowDefinitions>
<!-- Title and Time left/right-->
<Grid Grid.Row="0" >
<Label x:Name="lblTimeSeriesLeft"
HorizontalAlignment="Left" Foreground="Orange"
FontFamily="Tahoma" FontSize="20"
FontWeight="Bold" VerticalAlignment="Top" Content=""/>
<Label x:Name="lblTitle" HorizontalAlignment="Center"
Foreground="Orange"
FontFamily="Tahoma" FontSize="20"
FontWeight="Bold" VerticalAlignment="Top" Content=""/>
<Label x:Name="lblTimeSeriesRight"
HorizontalAlignment="Right" Foreground="Orange"
FontFamily="Tahoma" FontSize="20"
FontWeight="Bold" VerticalAlignment="Top"
Margin="0,0,5,0" Content=""/>
</Grid>
<Grid Grid.Row="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="90"/>
<ColumnDefinition Width="520"/>
<ColumnDefinition Width="90"/>
</Grid.ColumnDefinitions>
<!-- Left hand side Scale-->
<Canvas Grid.Column="0" HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<Label x:Name="LeftScaleMax" FontFamily="Tahoma"
Foreground="White"
FontSize="20" FontWeight="Bold" Canvas.Left="5"
Canvas.Top="0" HorizontalAlignment="Left"/>
<Label x:Name="LeftScaleCurrentValue" FontFamily="Tahoma"
Foreground="White"
FontSize="20" FontWeight="Bold" Canvas.Left="5"
HorizontalAlignment="Left"/>
<Label x:Name="LeftScaleMin" FontFamily="Tahoma"
Foreground="White"
FontSize="20" FontWeight="Bold" Canvas.Left="5"
Canvas.Top="275" HorizontalAlignment="Left"/>
</Canvas>
<!-- Graph area-->
<Border x:Name="overallContainer" Grid.Column="1" BorderBrush="Black"
BorderThickness="2"
Background="Black" CornerRadius="5" Visibility="Hidden"
HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
<Canvas x:Name="container" ClipToBounds="True"
HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
Margin="10">
<Canvas.Background>
<ImageBrush TileMode="Tile" Viewport="0,0,0.02,0.02">
<ImageBrush.ImageSource>
<BitmapImage UriSource="ChartBg.png"/>
</ImageBrush.ImageSource>
</ImageBrush>
</Canvas.Background>
<!-- Start of Children-->
<Line x:Name="LastPointLine" Stroke="White" X1="0"
X2="{Binding ElementName=parent, Path=ActualWidth}"
StrokeThickness="4"/>
<Polyline x:Name="GraphLine" Canvas.Left="0"
StrokeLineJoin="Round" Stroke="Orange"
StrokeThickness="4"/>
<Ellipse x:Name="LastPointMarkerEllipse" Fill="White"
Width="15" Height="15"/>
</Canvas>
</Border>
<!-- Right hand side Scale-->
<Canvas Grid.Column="2" HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<Label x:Name="RightScaleMax" FontFamily="Tahoma"
Foreground="White"
FontSize="20" FontWeight="Bold" Canvas.Left="5"
Canvas.Top="0" HorizontalAlignment="Left"/>
<Label x:Name="RightScaleCurrentValue" FontFamily="Tahoma"
Foreground="White"
FontSize="20" FontWeight="Bold" Canvas.Left="5"
HorizontalAlignment="Left"/>
<Label x:Name="RightScaleMin" FontFamily="Tahoma"
Foreground="White"
FontSize="20" FontWeight="Bold" Canvas.Left="5"
Canvas.Top="275" HorizontalAlignment="Left"/>
</Canvas>
</Grid>
</Grid>
</UserControl>
Here it is, where we have drawn boxes on it, to show you the layout sections.

The rather medical looking background is achieved by the use of a Tiled ImageBrush, here is the XAML that does that.
<Canvas.Background>
<ImageBrush TileMode="Tile" Viewport="0,0,0.02,0.02">
<ImageBrush.ImageSource>
<BitmapImage UriSource="ChartBg.png"/>
</ImageBrush.ImageSource>
</ImageBrush>
</Canvas.Background>
Can you also see from the entire Graph XAML that there is a Polyline
and a Line object within a Canvas control. This should
give you an idea of how the code works even before we get to it. Anyway, let
us actually have a look at the code. The most important (well only real important
method) is the one that translates the actual ThreadSafeObservableCollection<GraphDataItem>
values into an actual collection of Points that can be used by
the Polyline.
Essentially what happens is that the Min/Max values of the ThreadSafeObservableCollection<GraphDataItem>
values are found, these form the Y-Axis and the time difference between the
1st and last sample in the ThreadSafeObservableCollection<GraphDataItem>
values form the X-Axis. That only really leaves the Polyline and
the last point Line (White horizintal line).
Here is the code that works out what Points the Polyline
should use and also what the current Y1 and Y2 values should be for the last
point Line, and all the labels etc etc.
/// <summary>
/// Works out the actual X/Y points for the each value within the
/// ObservableCollection<GraphDataItem> property.
///
/// There is a tolerance, such that there must be a certain amount
/// of items seen before plotting starts. This is easily changed using
/// the MaxNumberOfItemsBeforePlottingStarts const.
/// </summary>
public void ObtainPointsForValues()
{
//Clear old points
if (this.DataItemXYPoints != null)
this.DataItemXYPoints.Clear();
//Only proceed if there are some actual values and there are enough values
if (DataValues != null)
{
if (DataValues.Count < MaxNumberOfItemsBeforePlottingStarts)
{
overallContainer.Visibility = Visibility.Hidden;
return;
}
#region MinMax
//work out min/max for Y-Scale
maxValue = 0;
minValue = Double.MaxValue;
foreach (var dataValue in DataValues)
{
if (dataValue.DataValue > maxValue)
maxValue = dataValue.DataValue;
if (dataValue.DataValue < minValue)
minValue = dataValue.DataValue;
}
#endregion
#region Workout Points
Double scale = maxValue - minValue;
Double valuePerPoint = container.ActualHeight / scale;
Double constantOffset = container.ActualWidth / DataValues.Count;
Double xOffSet = 0;
///for each item seen work out what the actual X/Y should be
///based on a bit of Maths
for (int i = 0; i < DataValues.Count; i++)
{
Double trueDiff = DataValues[i].DataValue - minValue;
Double heightPx = trueDiff * valuePerPoint;
Double yValue = container.ActualHeight - heightPx;
this.DataItemXYPoints.Add(new Point(xOffSet, yValue));
xOffSet += constantOffset;
}
//Keep the last point so we can position the horizontal last position
//line and ellipse (see XAML, and LastPointValue DP code)
this.LastPointValue = this.DataItemXYPoints.Last();
#endregion
#region Do Labels
//LHS Scale : Build up all the scale labels, showing 2 decimal places
LeftScaleMax.Content = maxValue.ToString("N2");
LeftScaleMin.Content = minValue.ToString("N2");
LeftScaleCurrentValue.Content =
this.DataValues.Last().DataValue.ToString("N2");
LeftScaleCurrentValue.SetValue(
Canvas.TopProperty, this.LastPointValue.Y);
lblTimeSeriesLeft.Content =
this.DataValues.First().TimeSampled.ToLongTimeString();
//RHS Scale : Build up all the scale labels, showing 2 decimal places
RightScaleMax.Content = maxValue.ToString("N2");
RightScaleMin.Content = minValue.ToString("N2");
RightScaleCurrentValue.Content =
this.DataValues.Last().DataValue.ToString("N2");
RightScaleCurrentValue.SetValue(
Canvas.TopProperty, this.LastPointValue.Y);
lblTimeSeriesRight.Content =
this.DataValues.Last().TimeSampled.ToLongTimeString();
#endregion
//Add Polygon Points
GraphLine.Points = DataItemXYPoints;
//Got points now so show graph
overallContainer.Visibility = Visibility.Visible;
}
else
{
//Got points now so show graph
overallContainer.Visibility = Visibility.Hidden;
}
}
NOTE : When the GraphTicker control is panning, it will firstly
pause the Graph (stopping it from accepting any new live values)
and then set a new collection of GraphDataItem values on the Graph
and will then call the Graphs ObtainPointsForValues()
method, thus drawing the SQL stored historical data. The Graph
will only start to accept new live values when the GraphTicker
PauseResume button is clicked again.
It can be seen that the Canvas.ActualHeight and Canvas.ActualWidth
are used in order to help with the translation into Points. The X-Axis is easy,
as you know how wide the Canvas is and you know how many values
there are, so that is simply container.ActualWidth / DataValues.Count
gives a constant X offset for each point.
The Y-Axis is a little trickier as you must first find the Min/Max from the
ThreadSafeObservableCollection<GraphDataItem> values, and
then do the following bit of maths from the whole code portion above.
Double scale = maxValue - minValue;
Double valuePerPoint = container.ActualHeight / scale;
Double constantOffset = container.ActualWidth / DataValues.Count;
Double xOffSet = 0;
///for each item seen work out what the actual X/Y should be
///based on a bit of Maths
for (int i = 0; i < DataValues.Count; i++)
{
Double trueDiff = DataValues[i].DataValue - minValue;
Double heightPx = trueDiff * valuePerPoint;
Double yValue = container.ActualHeight - heightPx;
this.DataItemXYPoints.Add(new Point(xOffSet, yValue));
xOffSet += constantOffset;
}
As previously stated the rest of ObtainPointsForValues() method
shown above is simply getting the correct values for the labels, and the last
point Line (White horizintal line).
I would just like to ask, if you liked the article please vote for it, and leave some comments, as it lets me know if the article was at the right level or not, and whether it contained what people need to know.
General
News
Question
Answer
Joke
Rant
Admin
|
PermaLink |
Privacy |
Terms of Use
Last Updated: 1 Feb 2009 Editor: |
Copyright 2009 by Sacha Barber, Richard E King Everything else Copyright © CodeProject, 1999-2009 Web17 | Advertise on the Code Project |