Friday, January 6, 2017

Simple Charting

This post refers to Framework 4.0.

I recently decided to write a WPF page that visualizes user logons as both a table and a chart. I could have used some third party charting controls but decided to write my own because I wanted to find out how to do it.

In this example I will generate a simple line chart based on a hard coded tuple of x,y coordinates. The top half of the page will display the chart and the lower half will display the data in a table. There will be a grid splitter that can adjust the height of the two halves of the page and the chart will resize itself when the splitter is adjusted or the page is resized.

The chart is generated by writing lines and text on a canvas. I have broken the rules of MVVM by referencing the canvas control explicitly in background code.

Start a new WPF application and call it LineChart.

Here is the initial XAML that needs to replace the default XAML in MainWindow. If you run it you will see the basic layout of the page.

<Window x:Class="LineChart.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:LineChart"
        mc:Ignorable="d"
        Title="Line Chart" Height="500" Width="500"
        DataContext="{Binding RelativeSource={RelativeSource self}}">
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="*"></RowDefinition>
            <RowDefinition Height="8"></RowDefinition>
            <RowDefinition Height="*"></RowDefinition>
        </Grid.RowDefinitions>
        <Canvas Name="ChartCanvas" Grid.Row="0" Background="AliceBlue" VerticalAlignment="Stretch" HorizontalAlignment="Stretch"></Canvas>
        <GridSplitter Grid.Row="1" Width="auto" HorizontalAlignment="Stretch" VerticalAlignment="Stretch"></GridSplitter>
        <DataGrid Grid.Row="2" AutoGenerateColumns="true" IsReadOnly="true"></DataGrid>
    </Grid>
</Window>

Let's start by making the gridsplitter look a little nicer. I added a small "gripper" image to the application and called it HorizontalGripper. It looks like this...


Add a colored rectangle to grid row 1 and change the GridSplitter to look like this...

        <Rectangle Grid.Row="1" Fill="SteelBlue"></Rectangle>
        <GridSplitter Grid.Row="1" Width="auto" HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
            <GridSplitter.Background>
                <ImageBrush Stretch="None" ImageSource="HorizontalGripper.png"/>
            </GridSplitter.Background>
        </GridSplitter>


Now display the page again and see how the grid splitter stands out.


Now it's time to define our data points and populate the table.

Replace the code behind so that MainWindow.xaml.cs looks like this...

using System.Collections.Generic;
using System.Windows;
using System.Windows.Shapes;
using System.Windows.Media;
using System.Windows.Controls;
using System.Linq;

namespace LineChart
{
    public partial class MainWindow : Window
    {
        public Point[] Points
        {
            get
            {
                return new Point[] { new Point(0,44), new Point (1,10), new Point(2,20), new Point(3,13), new Point(4,44), new Point(5,5), new Point(6,61), new Point(7,16), new Point(8,23), new Point(9,81), new Point(10,55) };
            }
        }

        public MainWindow()
        {
            InitializeComponent();
        }
    }
}

Now we can bind the datagrid to our data points by adding an ItemsSource like this...
<DataGrid Grid.Row="2" AutoGenerateColumns="true" IsReadOnly="true" ItemsSource="{Binding Points}"></DataGrid>

And now our table is populated. Yay table! Now for the real reason we came here -- the chart.


We need to scale the chart to fit in the canvas whenever the canvas size changes. There is a SizeChanged event on the Canvas control so we need to create and register a handler for it. Here is the code to register the handler.

public MainWindow()
{
    InitializeComponent();
    ChartCanvas.SizeChanged += ChartCanvas_SizeChanged;
}

The definition of the SizeChanged event handler starts like this.

void ChartCanvas_SizeChanged(object sender, RoutedEventArgs e) 
{
     double offsetLeft = 50;     // Distance from left of canvas to Y axis and right of canvas to right of X axis
     double offsetBottom = 50;   // Distance from bottom of canvas to X axis and top of canvas to top of Y axis
     SolidColorBrush AxisBrush = new SolidColorBrush(Colors.DodgerBlue);
     SolidColorBrush LineBrush = new SolidColorBrush(Colors.IndianRed);
     double canvasHeight = ChartCanvas.ActualHeight;
     double canvasWidth = ChartCanvas.ActualWidth;

     ChartCanvas.Children.Clear();

     Line XAxis = new Line();
     XAxis.X1 = offsetLeft;
     XAxis.Y1 = canvasHeight - offsetBottom;
     XAxis.X2 = canvasWidth - offsetLeft;
     XAxis.Y2 = canvasHeight - offsetBottom;
     XAxis.Stroke = AxisBrush;
     XAxis.StrokeThickness = 2;
     ChartCanvas.Children.Add(XAxis);

     Line YAxis = new Line();
     YAxis.X1 = offsetLeft;
     YAxis.Y1 = offsetBottom;
     YAxis.X2 = offsetLeft;
     YAxis.Y2 = canvasHeight - offsetBottom;
     YAxis.Stroke = AxisBrush;
     YAxis.StrokeThickness = 2;
     ChartCanvas.Children.Add(YAxis);

 }

At this point we have the X and Y axes displayed and the grid rescales whenever the canvas size changes. Our next task is to display the axis ticks and labels. I have decided to break each axis into ten equal parts so there will be 11 labels on each axis. Before we can add the ticks we need to decide what the range on each axis will be. For simplicity I have decided the range will be zero to the smallest power of ten that is equal to or larger than the largest value on the axis. 

For example, if an axis has a maximum value of 72, the axis range will be 0 to 100 with ticks every 10 units. Note I have not optimized the algebra so the algorithms are clearer.

Add the following code to the end of ChartCanvas_SizeChanged

            double MaxX = Points.Max(p => p.X), XScale = 1;
            double MaxY = Points.Max(p => p.Y), YScale = 1;

            while (MaxX > XScale) { XScale *= 10; }     // X axis will go from 0 to XScale
            while (MaxY > YScale) { YScale *= 10; }     // Y axis will go from 0 to YScale

            double tickLength = 10;

            for (int X = 0; X <= 10; X++)
            {
                Line XTick = new Line();
                XTick.X1 = offsetLeft + (canvasWidth - 2 * offsetLeft) / 10 * X;
                XTick.Y1 = canvasHeight - offsetBottom;
                XTick.X2 = XTick.X1;
                XTick.Y2 = canvasHeight - offsetBottom + tickLength;
                XTick.Stroke = AxisBrush;
                XTick.StrokeThickness = 1;
                ChartCanvas.Children.Add(XTick);
                TextBlock XLabel = new TextBlock();
                XLabel.Text = (X * XScale/ 10).ToString();
                Canvas.SetLeft(XLabel, XTick.X1 - 5);
                Canvas.SetTop(XLabel, XTick.Y2 + 10);
                ChartCanvas.Children.Add(XLabel);
            }

            for (int Y = 0; Y <= 10; Y++)
            {
                Line YTick = new Line();
                YTick.X1 = offsetLeft;
                YTick.Y1 = canvasHeight - offsetBottom - (canvasHeight - 2 * offsetBottom) / 10 * Y;
                YTick.X2 = offsetLeft - tickLength;
                YTick.Y2 = YTick.Y1;
                YTick.Stroke = AxisBrush;
                YTick.StrokeThickness = 1;
                ChartCanvas.Children.Add(YTick);
                TextBlock YLabel = new TextBlock();
                YLabel.Text = (Y * YScale/ 10).ToString();
                Canvas.SetLeft(YLabel, 5);
                Canvas.SetTop(YLabel, YTick.Y1 - 5);
                ChartCanvas.Children.Add(YLabel);
            }

We now have the ticks and labels on both axes. Time to actually plot some data.

            for (int i = 0; i < Points.Length - 1; i++)
            {
                Point point1 = Points[i];
                Point point2 = Points[i + 1];
                Line plotLine = new Line();
                plotLine.X1 = offsetLeft + (canvasWidth - 2 * offsetLeft) / XScale * point1.X;
                plotLine.Y1 = canvasHeight - offsetBottom - (canvasHeight - 2 * offsetBottom) / YScale * point1.Y;
                plotLine.X2 = offsetLeft + (canvasWidth - 2 * offsetLeft) / XScale * point2.X;
                plotLine.Y2 = canvasHeight - offsetBottom - (canvasHeight - 2 * offsetBottom) / YScale * point2.Y;
                plotLine.Stroke = LineBrush;
                plotLine.StrokeThickness = 2;
                ChartCanvas.Children.Add(plotLine);
            }
        


No comments:

Post a Comment