Introduction
This article presents step-by-step instructions on how to create a simple bar chart with WPF, using C#. You could substitute C# with any other .NET language of your choice. This article is only intended as a learning exercise. The chart is not of professional quality. There may be bugs here and there, but there are also a few good points. I'll keep improving this code over a period as I incrementally build my WPF skills, and hope to post more articles.
The current code is written to work with XML data. You could replace this with minimum effort. In a later article, I will try to improve upon this and make this more generic as the intent of this article was just to get hooked on to the charting capabilities of WPF using basic elements.
I am thankful to a few of my colleagues in my present company [not naming them as I haven't taken their permission], who in some form or the other enriched my understanding (whatever little I have) of WPF.
Background
This article assumes prior knowledge of C# and WPF. In case you are interested in learning about WPF, there are some excellent resources available on The Code Project. A few of them are listed below:
The skeleton UML class diagram to represent the BarChart
control is shown below:
To run the attached demo, please make sure the following config entries are valid for your environment:
<appSettings>
<add key="SALES_XML"
value="C:\SimpleChart\SimpleChart\Data\SalesSummary.xml"/>
<add key="SALES1_XML"
value="C:\SimpleChart\SimpleChart\Data\SalesSummary1.xml"/>
</appSettings>
Using the Code
Let's have a look at how this BarChart
could be used in your application to create a single valued chart.
chart1
is an instance of BarChart
:
chart1.Reset();
chart1.SmartAxisLabel = true;
chart1.Title = "Sales by Region";
chart1.ValueField.Add("Amount");
chart1.ToolTipText = "Sales($): {field}";
chart1.XAxisText = "Sales";
chart1.XAxisField = "Region";
chart1.ShowValueOnBar = true;
DataSet dsetAll = QueryHelper.GetDataSet(
ConfigurationManager.AppSettings["SALES_XML"]);
chart1.DataSource = dsetAll;
chart1.Generate();
To create a multi-valued bar chart, use the snippet below:
chart2.Reset();
chart2.SmartAxisLabel = true;
chart2.Title = "Sales Summary by Region";
chart2.ValueField.Add("Min");
chart2.ValueField.Add("Max");
chart2.ValueField.Add("Avg");
chart2.ToolTipText = "Sales($): {field}";
chart2.XAxisText = "Sales";
chart2.XAxisField = "Region";
chart2.ShowValueOnBar = true;
dsetAll = QueryHelper.GetDataSet(
ConfigurationManager.AppSettings["SALES1_XML"]);
chart2.DataSource = dsetAll;
chart2.Generate();
BarChart Dissected
The XAML representation of BarChart.xaml is as follows:
<UserControl x:Class="SimpleChart.Charts.BarChart"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Height="640" Width="480">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="0.05*"></RowDefinition>
<RowDefinition></RowDefinition>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"></ColumnDefinition>
</Grid.ColumnDefinitions>
<Slider Name="zoomSlider" Grid.Row="0" Grid.Column="1" Height="20"
DockPanel.Dock="Top" Minimum="1" Maximum="5" Value="1"/>
<ScrollViewer Grid.Row="1" Grid.Column="1" CanContentScroll="True"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto">
<Canvas x:Name="chartArea" ClipToBounds="False" Background="Black">
<Canvas.LayoutTransform>
<ScaleTransform
ScaleX="{Binding ElementName=zoomSlider, Path=Value}"
ScaleY="{Binding ElementName=zoomSlider, Path=Value}"/>
</Canvas.LayoutTransform>
<TextBlock x:Name="txtNoData" FontSize="12"
Text="No data found." Visibility="Hidden"
Opacity="100" Foreground="Red">
</TextBlock>
</Canvas>
</ScrollViewer>
</Grid>
</UserControl>
Setting up the Chart
We set up the basic chart layout by adding an X-axis, a top title, and a bubble text:
private void AddChartControlsToChart()
{
chartArea.Children.Add(txtXAxis);
chartArea.Children.Add(txtTopTitle);
chartArea.Children.Add(bubbleText);
}
Drawing the Chart
The main drawing activity happens in the Generate
method. This mind-map shows the important activities for drawing a chart:
- Add chart controls to setup the basic layout.
- Set up chart area.
- Get the data source.
- Check if data is present. If not, draw empty chart.
- Calculate the max Y-axis value. This is required for proper scaling.
- Draw the X-axis.
- Draw the Y-axis.
- Draw the X-axis label.
- Draw bar for each data value.
- Draw the legend.
public void Generate()
{
legends.Clear();
chartArea.Children.Clear();
AddChartControlsToChart();
SetUpChartArea();
DataTable dt = (DataSource as DataSet).Tables[0];
if (null != dt)
{
if (dt.Rows.Count == 0)
{
DrawEmptyChart();
return;
}
txtNoData.Visibility = Visibility.Hidden;
maxData = GetMax(dt);
PrepareChartForRendering();
int barCount = dt.Rows.Count;
bool isSeries = ValueField.Count > 1;
bool legendAdded = false;
foreach (DataRow row in dt.Rows)
{
DrawXAxisLabel(row);
SetBarWidth(barCount);
int currentSeries = 0;
foreach (string valField in ValueField)
{
if (null == valField)
continue;
if (!row.Table.Columns.Contains(valField))
continue;
DrawBar(isSeries, legendAdded, row,
ref currentSeries, valField);
}
legendAdded = true;
if (isSeries)
left = left + spaceBetweenBars;
}
if ((left + BarWidth) > chartArea.Width)
chartArea.Width = left + BarWidth;
DrawXAxis();
DrawYAxis();
DrawLegend();
}
}
Drawing the Bar
The DrawBar()
function handles the rendering of each bar. The bar, in our case, is represented by a rectangle object.
private void DrawBar(bool isSeries, bool legendAdded, DataRow row,
ref int currentSeries, string valField)
{
double val = 0.0;
if (row[valField] == DBNull.Value)
val = 0;
else
val = Convert.ToDouble(row[valField]);
double? calValue = (((float)val * 100 / maxData)) *
(chartArea.Height - bottomMargin - topMargin) / 100;
Rectangle rect = new Rectangle();
SetBarAttributes(calValue, rect);
Color stroke = Helper.GetDarkColorByIndex(currentSeries);
rect.Fill = new SolidColorBrush(stroke);
SetBarEvents(rect);
if (isSeries && !legendAdded)
{
legends.Add(new Legend(stroke, ValueField[currentSeries]));
}
top = (chartArea.Height - bottomMargin) - rect.Height;
Canvas.SetTop(rect, top);
Canvas.SetLeft(rect, left + leftMargin);
chartArea.Children.Add(rect);
if (ShowValueOnBar)
{
DisplayYValueOnBar(val, rect);
}
rect.Tag = new Bar(val, row, valField);
if (isSeries)
left = left + rect.Width;
else
left = left + BarWidth + spaceBetweenBars;
currentSeries++;
}
Drawing X-Axis
Setting up the X-axis is quite simple. Just add the line control at the appropriate location:
private void DrawXAxis()
{
Line xaxis = new Line();
xaxis.X1 = leftMargin;
xaxis.Y1 = this.Height - bottomMargin;
xaxis.X2 = this.chartArea.Width ;
xaxis.Y2 = this.Height - bottomMargin;
xaxis.Stroke = new SolidColorBrush(Colors.Silver);
chartArea.Children.Add(xaxis);
}
Drawing Y-Axis
Setting up the Y-axis is quite simple. The following activities have to be done in order to set up the Y-axis:
- Draw the Y-axis.
- Calculate the Y-axis marker value.
- Calculate the Y-axis scale.
- Add the marker line and marker text for each scale factor.
private void DrawYAxis()
{
Line yaxis = new Line();
yaxis.X1 = leftMargin;
yaxis.Y1 = 0;
yaxis.X2 = leftMargin;
yaxis.Y2 = this.Height - bottomMargin;
yaxis.Stroke = new SolidColorBrush(Colors.Silver);
chartArea.Children.Add(yaxis);
double scaleFactor = 10;
double yMarkerValue = Math.Ceiling(maxData.Value / scaleFactor);
double scale = 5;
scale = (((float)yMarkerValue * 100 / maxData.Value)) *
(chartArea.Height - bottomMargin - topMargin) / 100;
double y1 = this.Height - bottomMargin;
double yAxisValue = 0;
for (int i = 0; i <= scaleFactor; i++)
{
Line marker = AddMarkerLineToChart(y1);
DrawHorizontalGrid(marker.X1, y1);
AddMarkerTextToChart(y1, yAxisValue);
y1 -= scale;
yAxisValue += yMarkerValue;
}
}
Drawing the Legend
Drawing the legend is pretty straightforward. Loop through the Legends
collection and draw a line and text at the appropriate location with the specified colors.
private void DrawLegend()
{
if (legends == null || legends.Count == 0)
return;
double legendX1 = leftMargin + txtXAxis.Text.Length + 100;
double legendWidth = 20;
foreach (Legend legend in legends)
{
Line legendShape = new Line();
legendShape.Stroke = new SolidColorBrush(legend.LegendColor);
legendShape.StrokeDashCap = PenLineCap.Round;
legendShape.StrokeThickness = 8;
legendShape.StrokeStartLineCap = PenLineCap.Round;
legendShape.StrokeEndLineCap = PenLineCap.Triangle;
legendShape.X1 = legendX1;
legendShape.Y1 = this.Height - 10;
legendShape.X2 = legendX1 + legendWidth;
legendShape.Y2 = this.Height - 10;
chartArea.Children.Add(legendShape);
TextBlock txtLegend = new TextBlock();
txtLegend.Text = legend.LegendText;
txtLegend.Foreground = legendTextColor;
chartArea.Children.Add(txtLegend);
Canvas.SetTop(txtLegend, this.Height - 20);
Canvas.SetLeft(txtLegend, legendShape.X2 + 2);
legendX1 += legendWidth + 30 + txtLegend.Text.Length;
}
}
Feedback
The above code is a result of my learning WPF. In no means is this code optimized or perfect in every sense. The idea was to just post what I learned so that others may benefit from it. Please provide constructive criticism as this will help me improve my WPF skills and help me write better articles.
Points of Interest
Some of the things which I learned while developing this hobby code are outlined below:
- Understand basic chart drawing
- Understand basic WPF layout
- How to draw a smart axis label
- Tooltip and templating (under development)
- Adding legends
- Zoom-in/Zoom-out features
Development Ideas
Highlighted below are some of the objectives which I would like to tackle in my next article:
- Make the chart component reusable
- Support any datasource
- Enhanced tooltip templating
- Drilldown features
- In-built chart filtering capabilities
I may not be able to meet all the above intent in one go, but would take it up incrementally over a period of time.
Credits
All credits to CodeProject WPF article writers as I am learning mostly from their articles.
History
- 28th September, 2008 - First release (unorganized and probably buggy, but a learning exercise)
- 1st October, 2008 - Minor bug fixes