Introduction
This article gives an overview of the code and algorithms I created while writing a chart control for .NET 3.0/WPF. My chart control is useful in that it includes Pan/Zoom, has a clipboard helper that copies the chart to the clipboard in a resolution that the user desires, adds data to the image in the clipboard so that the user can paste a bitmap image or paste tab separated chart line values into a spreadsheet. The cursor, when moved over the chart, shows the value of the nearest point, or the value at the centre of the cursor. Polygons and lines can be added by coordinate to the chart, and the chart has a transparent background allowing it to be overlaid over any pretty background required. To zoom into the chart, hold down the right mouse button and move the mouse up or to the right, then to pan around just drag the chart with the left mouse button.
While this chart control is quite basic right now, I intend to add other features as they are required such as databinding to static and dynamic data sources with dynamically giving the chart an animated appearance, pipe shaded bars for bar charts, and other fancy chart primitives. Updates to this control can be found at the Swordfish Charts project page on Sourceforge where I've released it under the BSD license.
Background
Over the years I've moved from ASM/C/DOS to C/Win32 to C++/MFC To C++/GTK/Linux to C#/Windows Forms and now to C#/WPF with WPF being the new User Interface API introduced for Windows Vista. For each GUI toolkit I've generally written my own charting control for displaying data, and here I present to you how I wrote one for WPF that I use for displaying data inside WPF business applications that I am working on.
Using the code
Using the chart involves adding the chart control to your XAML file, and then in the code behind you add lines to the chart control. First of all you will need to add a reference in your project to the Swordfish.WPF.Charts
project. Then at the top of your XAML file for where you are placing the control, add the following namespace:
xmlns:SwordfishCharts="clr-namespace:Swordfish.WPF.Charts"
In the body of the XAML file add the chart control like so:
<SwordfishCharts:XYLineChart x:Name="xyLineChart"
RenderTransformOrigin="0.5,0.5"/>
If you want a nice rounded background for the chart control as seen in the screenshot above, wrap the control in a border like this:
<Border x:Name="plotToCopy" BorderBrush="Black" BorderThickness="1"
CornerRadius="8" Background="#FFFFF0D0" Margin="0">
<SwordfishCharts:XYLineChart x:Name="xyLineChart"
RenderTransformOrigin="0.5,0.5"/>
</Border>
The chart control has a property called Primitives
to which you add ChartPrimitive
objects. At the moment a ChartPrimitive
represents a line, or a filled polygon. ChartPrimitive
objects are rendered in the order that they appear in the Primitives
collection. Points are added to a ChartPrimitive
using either of these functions:
public void AddPoint(double x, double y);
public void AddPoint(Point point);
public void InsertPoint(Point point, int index);
public void AddSmoothHorizontalBar(double x, double y);
Once all of the required primitives have been added to the chart call XYLineChart.RedrawPlotLines();
to get the chart to recalculate it's display list. The chart control sets the axis limits automatically to show all of the data points.
In the source code download link at the top of this article, there is a class in the Swordfish.WPF.Charts
project called TestPage
that is implemented in the TestPage.xaml and TestPage.cs files. This test page overlays a clipboard interface over the chart control. The contents of TestPage.xaml can be seen below showing a CopyToClipboard
being placed in the same grid cell as the XYLineChart
control. Note that the border around the chart control is called "plotToCopy
". When copying the chart to the clipboard I want to copy the decorative border as well, so I have labelled it as such.
<Grid x:Class="Swordfish.WPF.Charts.TestPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:SwordfishCharts="clr-namespace:Swordfish.WPF.Charts">
<Border x:Name="plotToCopy" BorderBrush="Black" BorderThickness="1"
CornerRadius="8" Background="#FFFFF0D0" Margin="0">
<SwordfishCharts:XYLineChart x:Name="xyLineChart"
RenderTransformOrigin="0.5,0.5"/>
</Border>
<Border HorizontalAlignment="Left" VerticalAlignment="Top"
Padding="1,0,1,0" BorderBrush="Black"
Background="#A0005500" BorderThickness="0" CornerRadius="8">
<SwordfishCharts:CopyToClipboard x:Name="copyToClipboard"/>
</Border>
</Grid>
There is a static class called ChartUtilities
that contains a function called CopyChartToClipboard
that is used for copying the chart. Here's a list of the functions in ChartUtilities
which may be of use to you:
public static void CopyChartToClipboard(FrameworkElement plotToCopy,
XYLineChart chartControl, double width, double height);
public static double Closest_1_2_5_Pow10(double optimalValue);
public static double ClosestValueInListTimesBaseToInteger
(double optimalValue, double[] numbers, double baseValue);
public static Rect GetPlotRectangle(List<ChartPrimitive> primitiveList);
public static Rect GetPlotRectangle(List<ChartPrimitive>
primitiveList, double oversize);
public static ChartPrimitive ChartLineToBaseLinedPolygon
(ChartPrimitive chartLine);
public static ChartPrimitive LineDiffToPolygon
(ChartPrimitive baseLine, ChartPrimitive topLine);
public static void AddTestLines(XYLineChart xyLineChart);
public static DrawingBrush CreateHatch50(Color color, Size blockSize);
public static void CopyFrameworkElementToClipboard
(FrameworkElement copyTarget, double width, double height);
public static System.Drawing.Bitmap CopyFrameworkElementToBitmap
(FrameworkElement copyTarget, double width, double height);
Under the Hood
Rendering the Chart
When the chart is rendered, the gridlines are drawn first, and then the chart lines and polygons are drawn on top. The gridlines are selected by picking the granularity required to get close to 100 pixels in the horizontal, and 75 pixels in the vertical (the values of 100
and 75
are set in the XYLineChart
constructor). A function called Closest_1_2_5_Pow10
from ChartUtilities
picks gridline granularity so that the gridline values are multiples of 1, 2 or 5 times the power of 10 that yields the nearest match. This function is implemented as follows:
double[] numbers = { 1.0, 2.0, 5.0 };
double baseValue = 10.0;
double multiplier = Math.Pow(baseValue, Math.Floor(Math.Log(optimalValue) /
Math.Log(baseValue)));
double minimumDifference = baseValue * baseValue * multiplier;
double closestValue = 0.0;
double minimumNumber = baseValue * baseValue;
foreach (double number in numbers)
{
double difference = Math.Abs(optimalValue - number * multiplier);
if (difference < minimumDifference)
{
minimumDifference = difference;
closestValue = number * multiplier;
}
if (number < minimumNumber)
{
minimumNumber = number;
}
}
if (Math.Abs(optimalValue - minimumNumber * baseValue * multiplier) <
Math.Abs(optimalValue - closestValue))
closestValue = minimumNumber * baseValue * multiplier;
return closestValue;
In actual fact the current implementation of the chart control picks gridlines for the X axis that are multiples of powers of 12 as it has only been used for showing months on the X axis. So the entire implementation for working out the spacing looks like this:
if (maxXY.X != minXY.X)
scaleX = (double)size.Width / (double)(maxXY.X - minXY.X);
if (maxXY.Y != minXY.Y)
scaleY = (double)size.Height / (double)(maxXY.Y - minXY.Y);
double optimalSpacingX = optimalGridLineSpacing.X / scaleX;
double spacingX = ChartUtilities.ClosestValueInListTimesBaseToInteger(
optimalSpacingX, new double[] { 1, 3, 6 }, 12.0);
if (spacingX < 2.0)
spacingX = ChartUtilities.Closest_1_2_5_Pow10(optimalSpacingX);
double optimalSpacingY = optimalGridLineSpacing.Y / scaleY;
double spacingY = ChartUtilities.Closest_1_2_5_Pow10(optimalSpacingY);
where maxXY
and minXY
are the minimum and maximum values of all the primitives added to the chart. The gridlines are added to a PathFigure
object as line segments as seen below. The boolean parameter in LineSegment
just says whether a line should be drawn to that point or not, so the code is moving to the start point, and then drawing to the endpoint.
Point startPoint = new Point(xPos, size.Height);
Point endPoint = new Point(xPos, 0);
pathFigure.Segments.Add(new LineSegment(startPoint, false));
pathFigure.Segments.Add(new LineSegment(endPoint, true));
A PathFigure
can be converted to PathGeometry
which, as seen below, has a transform attached to it to allow it to be zoomed, panned, or even rotated.
Chart lines and polygons are added to the chart canvas in the XYLineChart.RenderPlotLines
method seen below:
protected void RenderPlotLines(Canvas canvas)
{
canvas.Children.Clear();
foreach (ChartPrimitive primitive in primitiveList)
{
if (primitive.Points.Count > 0)
{
Path path = new Path();
PathGeometry pathGeometry = new PathGeometry();
pathGeometry.Transform = shapeTransform;
if (primitive.Filled)
{
pathGeometry.AddGeometry(primitive.PathGeometry);
path.Stroke = null;
if (primitive.Dashed)
{
path.Fill = ChartUtilities.CreateHatch50(primitive.Color,
new Size(2, 2));
}
else
path.Fill = new SolidColorBrush(primitive.Color);
}
else
{
pathGeometry.AddGeometry(primitive.PathGeometry);
path.Stroke = new SolidColorBrush(primitive.Color);
path.StrokeThickness = primitive.LineThickness;
path.Fill = null;
if (primitive.Dashed)
path.StrokeDashArray = new DoubleCollection(new double[] { 2, 2 });
}
path.Data = pathGeometry;
path.Clip = chartClip;
canvas.Children.Add(path);
}
}
}
Inspection of the above code shows that for each primitive, a Path
object is added to the canvas. A Path
has geometry in it, and to that geometry I attach a transform that I use to implement the pan and zoom. The path is clipped to the chart region so that the geometry isn't drawn outside the chart area when it is zoomed.
Zooming and Panning
The calculations for zooming and panning are done in the PanZoomCalculator
class and are normalized to the dimensions of the chart, so a pan distance of 1 will pan the chart 1x the width of the chart which allows the chart to easily retain it's pan and zoom settings when it is resized.
Most of the work is done in the MouseMoved
method. Panning is calculated like this:
Pan += PixelsMoved/Zoom/ChartSize
which in the code looks like this:
if (isPanning)
{
currentPan.X += (newPosition.X - lastPosition.X) /
currentZoom.X / window.Width;
currentPan.Y += (newPosition.Y - lastPosition.Y) /
currentZoom.Y / window.Height;
}
Zooming uses a power function so that the zooming retains a linear feel to it as you zoom in and out of the chart. I've found that 1.002 to the power of the number of pixels moved seems to work nicely. So the algorithm looks like this:
Zoom *= Power(1.002, PixelsMoved)
Zooming is done on the spot that the user right clicked the mouse. Normally the chart would zoom in on it's centre, so to zoom in on where the user clicked the chart needs to be panned as it is zoomed. The amount to pan is calculated like this:
Pan += (ChartSize/2 - ZoomStartPos) * (1/previousZoom - 1/Zoom)/ChartSize
Which in code looks like this:
if (isZooming)
{
Point oldZoom = currentZoom;
currentZoom.X *= Math.Pow(1.002, newPosition.X - lastPosition.X);
currentZoom.Y *= Math.Pow(1.002, -newPosition.Y + lastPosition.Y);
currentZoom.X = Math.Max(1, currentZoom.X);
currentZoom.Y = Math.Max(1, currentZoom.Y);
currentPan.X +=
(window.Width * .5 - zoomCentre.X) * (1/oldZoom.X - 1/currentZoom.X) /
window.Width;
currentPan.Y +=
(-window.Height * .5 - zoomCentre.Y) * (1/oldZoom.Y - 1/currentZoom.Y) /
window.Height;
}
Finally I limit the Pan so that the chart isn't panned out of site. I take the amount that the chart is bigger than if it wasn't zoomed, halve it as it can only be panned either way half of the size that it exceeds the normal size, and then I scale it by the zoom factor as the zoom factor is applied to the pan when the transform is calculated. So the algorithm looks like this:
Pan = Min(Pan, (Zoom-1)/2/Zoom)
Which in code looks like this:
lastPosition = newPosition;
if (isPanning || isZooming)
{
Point maxPan = new Point();
maxPan.X = .5*(currentZoom.X - 1) / (currentZoom.X);
maxPan.Y = .5*(currentZoom.Y - 1) / (currentZoom.Y);
currentPan.X = Math.Min(maxPan.X, currentPan.X);
currentPan.X = Math.Max(-maxPan.X, currentPan.X);
currentPan.Y = Math.Min(maxPan.Y, currentPan.Y);
currentPan.Y = Math.Max(-maxPan.Y, currentPan.Y);
if (Double.IsNaN(currentPan.X) || Double.IsNaN(currentPan.Y))
currentPan = new Point(0f, 0f);
if (Double.IsNaN(currentZoom.X) || Double.IsNaN(currentZoom.Y))
currentZoom = new Point(1f, 1f);
this.OnPanZoomChanged();
}
The Zoom and Pan are then applied to the chart geometry as a transform, which is calculated in the XYLineChart.SetChartTransform
method which looks like this:
protected void SetChartTransform(double width, double height)
{
Rect plotArea = ChartUtilities.GetPlotRectangle(primitiveList, 0.01f);
minPoint = plotArea.Location;
minPoint.Offset(-plotArea.Width * panZoomCalculator.Pan.X,
plotArea.Height * panZoomCalculator.Pan.Y);
minPoint.Offset(0.5 * plotArea.Width * (1 - 1 / panZoomCalculator.Zoom.X),
0.5 * plotArea.Height * (1 - 1 / panZoomCalculator.Zoom.Y));
maxPoint = minPoint;
maxPoint.Offset(plotArea.Width / panZoomCalculator.Zoom.X,
plotArea.Height / panZoomCalculator.Zoom.Y);
Point plotScale = new Point();
plotScale.X = (width / plotArea.Width) * panZoomCalculator.Zoom.X;
plotScale.Y = (height / plotArea.Height) * panZoomCalculator.Zoom.Y;
Matrix shapeMatrix = Matrix.Identity;
shapeMatrix.Translate(-minPoint.X, -minPoint.Y);
shapeMatrix.Scale(plotScale.X, plotScale.Y);
shapeTransform.Matrix = shapeMatrix;
}
Putting the chart coordinates on the cursor
The chart coordinates on the cursor work in 2 modes. When the cursor is near a plot point it displays the coordinates of the point and draws a yellow circle around the point, otherwise the cursor just shows the coordinates of the crosshairs. Finding the closest point is handled by the ClosestPointPicker
class which performs an optimized equivalent of a distance test on each transformed point and picks the closest.
Originally the crosshairs and coordinates were rendered to a bitmap that was converted to an icon and then set as a mouse cursor. This worked fine except that it caused a Security Exception if the chart control was used in an online XBAP application. To overcome this, I now turn off the cursor, and then render a software cursor in the Adorner Layer of the Chart Control. The code for displaying the coordinates on the actual mouse cursor can still be found in the MouseCursorCoordinateDrawer
class which uses a WPFCursorFromBitmap
class that handles removing the old Icon handle from system space everytime it is updated, otherwise you will find the system suddenly running out of space to put GDI objects and you will have to reboot your machine everytime you use the chart control. The Adorner
version being used has a little bit of lag, but it's not too bad, and it's what the Adorner layer is for.
The AdornerCursorCoordinateDrawer
inherits from Adorner
. The adorner layer seems to exist on the chart control only when it's visible, so the AdornerCursorCoordinateDrawer
is added to the chart control when it is made visible, and removed when the chart control is made not visible as such:
public XYLineChart()
{
...
adorner = new AdornerCursorCoordinateDrawer
(clippedPlotCanvas, shapeTransform);
...
}
void clippedPlotCanvas_IsVisibleChanged
(object sender, DependencyPropertyChangedEventArgs e)
{
if (this.IsVisible && adornerLayer == null)
{
adornerLayer = AdornerLayer.GetAdornerLayer(clippedPlotCanvas);
adornerLayer.Add(adorner);
}
else if (adornerLayer != null)
{
adornerLayer.Remove(adorner);
adornerLayer = null;
}
}
The adorner draws the cursor in the OnRender
method. When the cursor is locked onto the nearest point it draws the cursor like this:
Point point = elementTransform.Transform(lockPoint);
drawingContext.DrawEllipse(null, new Pen(blackBrush, 3), point, 2.5, 2.5);
drawingContext.DrawEllipse
(null, new Pen(new SolidColorBrush(Colors.White), 2), point, 2.5, 2.5);
Pen yellowPen = new Pen(new SolidColorBrush(Colors.Yellow), 2);
Pen blackPen = new Pen(blackBrush, 3);
drawingContext.DrawEllipse(null, blackPen, mousePoint, radius, radius);
drawingContext.DrawEllipse(null, yellowPen, mousePoint, radius, radius);
Otherwise it draws the cursor like this:
Pen blackPen = new Pen(blackBrush, .7);
drawingContext.DrawEllipse(null, blackPen, mousePoint, radius, radius);
drawingContext.DrawLine(blackPen, new Point
(mousePoint.X - radius * 1.6, mousePoint.Y),
new Point(mousePoint.X - 2, mousePoint.Y));
drawingContext.DrawLine(blackPen, new Point
(mousePoint.X + radius * 1.6, mousePoint.Y),
new Point(mousePoint.X + 2, mousePoint.Y));
drawingContext.DrawLine(blackPen, new Point
(mousePoint.X, mousePoint.Y - radius * 1.6),
new Point(mousePoint.X, mousePoint.Y - 2));
drawingContext.DrawLine(blackPen, new Point
(mousePoint.X, mousePoint.Y + radius * 1.6),
new Point(mousePoint.X, mousePoint.Y + 2));
When adding text to the cursor, I first need to work out how many decimal places to use to display a value accurate down to one pixel. This is done by looking at the Log10 of the distance between 2 adjacent pixels after they have been transformed to the chart coordinates. The code for this looks like this:
Rect rect = inverse.TransformBounds(new Rect(0, 0, 1, 1));
int xFigures = Math.Max(1,(int)(Math.Ceiling(-Math.Log10(rect.Width)) + .1));
int yFigures = Math.Max(1,(int)(Math.Ceiling(-Math.Log10(rect.Height)) + .1));
string xFormat = "#0." + new string('#', xFigures);
string yFormat = "#0." + new string('#', yFigures);
The coordinate text is then drawn in a box like this:
string coordinateText = coordinate.X.ToString(xFormat) + "," +
coordinate.Y.ToString(yFormat);
drawingContext.PushTransform(new ScaleTransform(1, -1));
FormattedText formattedText = new FormattedText
(coordinateText, CultureInfo.CurrentCulture,
FlowDirection.LeftToRight, new Typeface("Arial"), 10, blackBrush);
Pen textBoxPen = new Pen(new SolidColorBrush
(Color.FromArgb(127, 255, 255, 255)), 1);
Rect textBoxRect = new Rect
(new Point(mousePoint.X + radius * .7, -mousePoint.Y + radius * .7),
new Size(formattedText.Width, formattedText.Height));
double diff = textBoxRect.Right + 3 -
((FrameworkElement)AdornedElement).ActualWidth;
if (diff > 0)
textBoxRect.Location = new Point(textBoxRect.Left - diff, textBoxRect.Top);
drawingContext.DrawRectangle(textBoxPen.Brush, textBoxPen, textBoxRect);
drawingContext.DrawText(formattedText, textBoxRect.Location);
drawingContext.Pop();
Copying to the clipboard
Currently the chart needs to be copied to the clipboard as a bitmap if it is to be pasted into any other app. I would anticipate that sometime in the future the chart could be copied as a XAML package, but at the moment nothing supports this clipboard format, which is a shame, because I would really like to be able to paste vector graphics objects into Microsoft Word 2007 documents. I tried going down the convert to VML route but I haven't finished that yet as there are some issues. So currently the chart is converted to a bitmap using the ChartUtilities.CopyFrameworkElementToBitmap
method which looks like this:
public static System.Drawing.Bitmap CopyFrameworkElementToBitmap
(FrameworkElement copyTarget, double width, double height)
{
if (copyTarget == null)
return new System.Drawing.Bitmap((int)width, (int)height);
System.Drawing.Bitmap bitmap;
using (MemoryStream outStream =
CopyFrameworkElementToMemoryStream(copyTarget, width, height,
new BmpBitmapEncoder()))
{
bitmap = new System.Drawing.Bitmap(outStream);
}
return bitmap;
}
That's not very exciting, is it. What it is doing is it is using ChartUtilities.CopyFrameworkElementToMemoryStream
to save the chart control to a bitmap in a memory stream, and then it is loading that memory stream back into a System.Drawing.Bitmap
. The CopyFrameworkElementToMemoryStream
method is below. Note that it changes the DPI of the render target as a way of scaling the chart to the bitmap.
public static MemoryStream CopyFrameworkElementToMemoryStream
(FrameworkElement copyTarget, double width, double height,
BitmapEncoder enc)
{
Transform storedTransform = copyTarget.LayoutTransform;
copyTarget.LayoutTransform = new ScaleTransform(1, 1);
copyTarget.UpdateLayout();
double baseHeight = copyTarget.ActualHeight;
double baseWidth = copyTarget.ActualWidth;
copyTarget.LayoutTransform =
new ScaleTransform(baseWidth / width, baseHeight / height);
copyTarget.UpdateLayout();
RenderTargetBitmap rtb = new RenderTargetBitmap(
(int)width,
(int)height,
96d * width / baseWidth,
96d * height / baseHeight,
PixelFormats.Default);
rtb.Render(copyTarget);
MemoryStream outStream = new MemoryStream();
enc.Frames.Add(BitmapFrame.Create(rtb));
enc.Save(outStream);
copyTarget.LayoutTransform = storedTransform;
copyTarget.UpdateLayout();
return outStream;
}
Points of Interest
I had a problem with the grid lines flickering when they were recalculated after the pan or zoom changed. This was fixed by adding them to the chart inside the MeasureOverride
method.
I've found that it looks nice to add a filled background to lines added to the chart. So that lines aren't covered up by the polygons I add all the filled polygons first, and then add the lines on top.
History
- 10th January 2007 - Initial article
- 11th January 2007 - Fixed a bug found by Josh Smith where the adorner cursor wasn't being removed when the mouse went off the control during a pan
- 10th December 2012 - Major changes so called it version 2. Added bar charts, axis overrides, zoom with mouse wheel, and an override library that uses Direct2D to render the chart.