Click here to Skip to main content
15,860,859 members
Articles / Desktop Programming / WPF

WPF: A graph control

,
Rate me:
Please Sign up or sign in to vote.
4.90/5 (118 votes)
1 Feb 2009CPOL11 min read 290.9K   6K   212   92
A WPF graph control with autoscaling and historical data support.

Image 1

Contents

Introduction

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.

Before You Try and Run it

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.

The Structure

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.

Image 2

The Settings

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).

Image 3

Image 4

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.

XML
<?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 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.

Historical Functions

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.

Image 5

The Code

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:

C#
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? 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:

C#
/// <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:

C#
/// <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
}

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:

XML
<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:

C#
/// <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";
        };
    }
}

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.

C#
/// <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;
}

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:

C#
/// <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 window's worth of values from the database. If there are, they are fetched.

Here is the code that deals with the PanRight.

C#
/// <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.

C#
//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.

Image 6

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:

XML
<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.

Image 7

The rather medical looking background is achieved by the use of a Tiled ImageBrush; here is the XAML that does that:

XML
<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 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 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 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.

C#
/// <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 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.

C#
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 the ObtainPointsForValues() method shown above is simply getting the correct values for the labels and the last point Line (white horizontal line).

So What Do You Think ?

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.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Software Developer (Senior)
United Kingdom United Kingdom
I currently hold the following qualifications (amongst others, I also studied Music Technology and Electronics, for my sins)

- MSc (Passed with distinctions), in Information Technology for E-Commerce
- BSc Hons (1st class) in Computer Science & Artificial Intelligence

Both of these at Sussex University UK.

Award(s)

I am lucky enough to have won a few awards for Zany Crazy code articles over the years

  • Microsoft C# MVP 2016
  • Codeproject MVP 2016
  • Microsoft C# MVP 2015
  • Codeproject MVP 2015
  • Microsoft C# MVP 2014
  • Codeproject MVP 2014
  • Microsoft C# MVP 2013
  • Codeproject MVP 2013
  • Microsoft C# MVP 2012
  • Codeproject MVP 2012
  • Microsoft C# MVP 2011
  • Codeproject MVP 2011
  • Microsoft C# MVP 2010
  • Codeproject MVP 2010
  • Microsoft C# MVP 2009
  • Codeproject MVP 2009
  • Microsoft C# MVP 2008
  • Codeproject MVP 2008
  • And numerous codeproject awards which you can see over at my blog

Written By
Software Developer (Senior)
United Kingdom United Kingdom
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.

Comments and Discussions

 
Bugit is crashing... Pin
patodi27-Mar-17 19:34
patodi27-Mar-17 19:34 
QuestionVery Helpful Pin
a01101101110-Dec-14 2:52
a01101101110-Dec-14 2:52 
AnswerRe: Very Helpful Pin
Sacha Barber10-Dec-14 2:57
Sacha Barber10-Dec-14 2:57 
Questionhttp://www.codeproject.com/Questions/673690/how-to-add-a-novable-vertical-line-in-a-graph-of-x Pin
kumar9avinash29-Oct-13 23:44
kumar9avinash29-Oct-13 23:44 
AnswerRe: http://www.codeproject.com/Questions/673690/how-to-add-a-novable-vertical-line-in-a-graph-of-x Pin
Sacha Barber30-Oct-13 1:01
Sacha Barber30-Oct-13 1:01 
GeneralMy vote of 5 Pin
Sandyworm13-Jul-12 8:41
Sandyworm13-Jul-12 8:41 
GeneralRe: My vote of 5 Pin
Sacha Barber13-Jul-12 11:03
Sacha Barber13-Jul-12 11:03 
GeneralThis curve looks very cool Pin
Sandyworm13-Jul-12 8:39
Sandyworm13-Jul-12 8:39 
GeneralMy vote of 5 Pin
modzelm130-Mar-11 2:03
modzelm130-Mar-11 2:03 
GeneralJust the Ticket Pin
njdnjdnjdnjdnjd21-Feb-11 10:40
njdnjdnjdnjdnjd21-Feb-11 10:40 
GeneralMy vote of 5 Pin
njdnjdnjdnjdnjd21-Feb-11 9:11
njdnjdnjdnjdnjd21-Feb-11 9:11 
GeneralRe: My vote of 5 Pin
Sacha Barber21-Feb-11 19:14
Sacha Barber21-Feb-11 19:14 
GeneralSimple but excellent Pin
David Pérez Marinas21-Jan-11 22:55
David Pérez Marinas21-Jan-11 22:55 
GeneralRe: Simple but excellent Pin
Sacha Barber22-Jan-11 4:40
Sacha Barber22-Jan-11 4:40 
GeneralMy vote of 5 Pin
Espen Harlinn30-Nov-10 23:29
professionalEspen Harlinn30-Nov-10 23:29 
GeneralFour Pin
Luciha24-Nov-09 23:27
Luciha24-Nov-09 23:27 
GeneralMy vote of 1 Pin
captainplanet012315-Sep-09 6:03
captainplanet012315-Sep-09 6:03 
GeneralRe: My vote of 1 Pin
Sacha Barber15-Sep-09 21:47
Sacha Barber15-Sep-09 21:47 
RantRe: My vote of 1 Pin
PatLeCat5-Jul-10 22:11
PatLeCat5-Jul-10 22:11 
GeneralRe: My vote of 1 Pin
Sacha Barber6-Jul-10 22:28
Sacha Barber6-Jul-10 22:28 
GeneralRe: My vote of 1 Pin
PatLeCat7-Jul-10 9:41
PatLeCat7-Jul-10 9:41 
QuestionMultiple lines Pin
VolcanicZone14-Sep-09 18:10
VolcanicZone14-Sep-09 18:10 
AnswerRe: Multiple lines Pin
Sacha Barber14-Sep-09 19:40
Sacha Barber14-Sep-09 19:40 
GeneralRe: Multiple lines Pin
VolcanicZone15-Sep-09 14:19
VolcanicZone15-Sep-09 14:19 
GeneralRe: Multiple lines Pin
Sacha Barber15-Sep-09 21:47
Sacha Barber15-Sep-09 21:47 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.