Contents
I am now working in a company where we deal with lots of numbers (currency rates to be precise), and a work colleague 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 Andre's 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 similar to that of Andre de Cavaignac's graph. Andre did a find job on making a sexy graph.
The graph that this demo project includes supports auto-scaling y-axis dependant on the current window of readings, and 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 one 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 amend the the App.Config in Visual Studio to suit your own SQL Server installation. I have left the attached App.Config with my SQL Server instance within the config file, so you can see what you need to change for your own installation.
At its simplest, the graph is actually two 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 flexible 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 the application. Which is done via the App.Config file which includes all the settings. Here is the App.Config file for the attached code, where you can see that it supports all the settings mentioned above.
="1.0" ="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 whatever suits your needs, though we will be making some recommendations as to suitable values.
SAMPLE_INTERVAL_IN_MS
(double
): This is the time between samples for the data provider (more on this later). I would not make this < 500, which is 500 milliseconds.
SAMPLE_WINDOW_SIZE
(int
): This is the number of readings that will be stored within one viewable Graph
window before the items are removed and will no longer be shown in the Graph
window. You can make this what you like; it's 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 show.
SUPPORTS_HISTORICAL_DATA
(bool
): This is a simple Boolean flag that lets the application know if it should firstly allow data to be saved to SQL Server and also whether the PanLeft/PanRight functions should be allowed. Basically, there is no point allowing the user to pan if there is no SQL stored data to pan through.
MAX_NUM_OF_ITEMS_BEFORE_PLOTTING_OCCCURS
(int
): This is used by the Graph
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 automatically 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 the 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 data provider will need to check 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 Server.
When the GraphTicker
control is loaded, it will allow the PanLeft/PanRight functions to be shown. In essence, if you have allowed the SUPPORTS_HISTORICAL_DATA
flag, the PanLeft/PanRight buttons will be shown; otherwise, they will not.
In order to understand 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.
DataProvider
The GraphTicker
is expecting to use a ThreadSafeObservableCollection<GraphDataItem>
which in itself deserves another discussion 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>();
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
public void Run()
{
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();
}
GraphDataItem dataItem = new GraphDataItem
{
DataValue = (double)rand.NextDouble() * 50,
TimeSampled = DateTime.Now,
SampleSequenceNumber=currentSampleCounter++
};
if (Graph.CanAcceptNewStreamingReadings)
dataValues.Add(dataItem);
if (SupportsHistoricalData)
{
dataContext = new WPFTickerDataContext();
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? Isn't that 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:
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
protected override void ClearItems()
{
_dispatcher.InvokeIfRequired(() =>
{
_lock.EnterWriteLock();
try
{
base.ClearItems();
}
finally
{
_lock.ExitWriteLock();
}
}, DispatcherPriority.DataBind);
}
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);
}
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);
}
protected override void RemoveItem(int index)
{
_dispatcher.InvokeIfRequired(() =>
{
if (index >= this.Count)
return;
_lock.EnterWriteLock();
try
{
base.RemoveItem(index);
}
finally
{
_lock.ExitWriteLock();
}
}, DispatcherPriority.DataBind);
}
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
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:
public static class WPFControlThreadingExtensions
{
#region Public Methods
public static void InvokeIfRequired(this Dispatcher disp,
Action dotIt, DispatcherPriority priority)
{
if (disp.Thread != Thread.CurrentThread)
{
disp.Invoke(priority, dotIt);
}
else
dotIt();
}
#endregion
}
Using the GraphTicker in a Container
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:
public partial class Window1 : Window
{
public Window1()
{
InitializeComponent();
this.Loaded += (s, e) =>
{
DataValueSimulator simulator = new DataValueSimulator();
simulator.Run();
graphTicker.DataValues = simulator.DataValues;
graphTicker.GraphTitle = "Simulated Values";
};
}
}
Now we will go on to discuss the nitty gritty of the two controls where it all happens. So marching on..
The GraphTicker
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
control also deals with the panning of SQL stored data (if that is enabled, see the SUPPORTS_HISTORICAL_DATA
flag in The Settings section). 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.
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;
}
How about this panning we keep mentioning, how does that work? Well, basically, it's 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, it's the WPFTicker.dbml file that is the LINQ to SQL stuff).
If you are panning left, there must be at least a whole viewable window's 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:
private void BtnPanLeft_Click(object sender, RoutedEventArgs e)
{
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();
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
{
graph.IsPaused = false;
}
}
}
catch
{
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 window's worth of values from the database. If there are, they are fetched.
Here is the code that deals with the PanRight.
private void BtnPanRight_Click(object sender, RoutedEventArgs e)
{
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)
{
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
{
graph.IsPaused = false;
}
}
}
catch
{
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.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.
The Graph
Now 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 represents the strokes of the values, which are then used to create a collection of points that are used as Polyline.Points
for a Polyline
object that is used within the Graph
control's XAML. Here is the 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>
-->
<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>
-->
<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>
-->
<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>
-->
<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>
-->
<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 really important method) is the one that translates the actual ThreadSafeObservableCollection<GraphDataItem>
values into an actual collection of Point
s 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 first and last sample in the ThreadSafeObservableCollection<GraphDataItem>
values form the X-axis. That only really leaves the Polyline
and the last point Line
(white horizontal line).
Here is the code that works out what Point
s 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.
public void ObtainPointsForValues()
{
if (this.DataItemXYPoints != null)
this.DataItemXYPoints.Clear();
if (DataValues != null)
{
if (DataValues.Count < MaxNumberOfItemsBeforePlottingStarts)
{
overallContainer.Visibility = Visibility.Hidden;
return;
}
#region MinMax
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 (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;
}
this.LastPointValue = this.DataItemXYPoints.Last();
#endregion
#region Do Labels
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();
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
GraphLine.Points = DataItemXYPoints;
overallContainer.Visibility = Visibility.Visible;
}
else
{
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 Graph
's 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 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
which 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 math 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 (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 the ObtainPointsForValues()
method shown above is simply getting the correct values for the labels and the last point Line
(white horizontal 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.