Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / operating-systems / Windows

WPF Chart Control With Pan, Zoom and More

4.92/5 (42 votes)
10 Dec 2012Public Domain10 min read 9   10.9K  
Chart Control for Microsoft .NET 3.0/WPF with pan, zoom, and offline rendering to the clipboard for custom sizes.

Sample Image - swordfishcharts.png

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:

XML
xmlns:SwordfishCharts="clr-namespace:Swordfish.WPF.Charts"

In the body of the XAML file add the chart control like so:

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

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

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

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

C#
/// <summary>
/// Copies the plotToCopy as a bitmap to the clipboard, and copies the
/// chartControl to the clipboard as tab separated values.
/// </summary>
/// <param name="width">Width of the bitmap to be created</param>
/// <param name="height">Height of the bitmap to be created</param>
public static void CopyChartToClipboard(FrameworkElement plotToCopy, 
    XYLineChart chartControl, double width, double height);

/// <summary>
/// Calculates the value as near to the input as possible, 
/// a power of 10 times 1,2, or 5
/// </summary>
/// <param name="optimalValue"> The value to get closest to</param>
/// <returns>The nearest value to the input value</returns>
public static double Closest_1_2_5_Pow10(double optimalValue);

/// <summary>
/// Calculates the closest possible value to the optimalValue passed
/// in, that can be obtained by multiplying one of the numbers in the
/// list by the baseValue to the power of any integer.
/// </summary>
/// <param name="optimalValue">The number to get closest to</param>
/// <param name="numbers">List of numbers to multiply by</param>
/// <param name="baseValue">The base value</param>
public static double ClosestValueInListTimesBaseToInteger
    (double optimalValue, double[] numbers, double baseValue);

/// <summary>
/// Gets the plot rectangle that is required to hold all the
/// lines in the primitive list
/// </summary>
/// <param name="primitiveList"></param>
public static Rect GetPlotRectangle(List<ChartPrimitive> primitiveList);

/// <summary>
/// Gets a nominally oversize rectangle that the plot will be drawn into
/// </summary>
public static Rect GetPlotRectangle(List<ChartPrimitive> 
                    primitiveList, double oversize);

/// <summary>
/// Converts a ChartLine object to a ChartPolygon object that has
/// one edge along the bottom Horizontal base line in the plot.
/// </summary>
public static ChartPrimitive ChartLineToBaseLinedPolygon
                        (ChartPrimitive chartLine);

/// <summary>
/// Takes two lines and creates a polygon between them
/// </summary>
public static ChartPrimitive LineDiffToPolygon
            (ChartPrimitive baseLine, ChartPrimitive topLine);

/// <summary>
/// Adds a set of lines to the chart for test purposes
/// </summary>
public static void AddTestLines(XYLineChart xyLineChart);

/// <summary>
/// Creates a 50% hatch patter for filling a polygon
/// </summary>
public static DrawingBrush CreateHatch50(Color color, Size blockSize);

/// <summary>
/// Copies a Framework Element to the clipboard as a bitmap
/// </summary>
/// <param name="copyTarget">The Framework Element to be copied</param>
/// <param name="width">The width of the bitmap</param>
/// <param name="height">The height of the bitmap</param>
public static void CopyFrameworkElementToClipboard
    (FrameworkElement copyTarget, double width, double height);

/// <summary>
/// Copies a Framework Element to a bitmap
/// </summary>
/// <param name="copyTarget">The Framework Element to be copied</param>
/// <param name="width">The width of the bitmap</param>
/// <param name="height">The height of the bitmap</param>
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:

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

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

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

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

plain
Pan += PixelsMoved/Zoom/ChartSize 

which in the code looks like this:

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

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

plain
Pan += (ChartSize/2 - ZoomStartPos) * (1/previousZoom - 1/Zoom)/ChartSize 

Which in code looks like this:

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

plain
Pan = Min(Pan, (Zoom-1)/2/Zoom) 

Which in code looks like this:

C#
lastPosition = newPosition;

if (isPanning || isZooming)
{
  // Limit Pan
  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:

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

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

C#
// Draw the little circle around the lock point

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

// Draw the big yellow circle

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:

C#
// Draw the target symbol

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:

C#
// Works out the number of decimal places required to show the 
// difference between
// 2 pixels. E.g. if pixels are .1 apart then use 2 places etc
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));

// Number of significant figures for the x coordinate
string xFormat = "#0." + new string('#', xFigures);
/// Number of significant figures for the y coordinate
string yFormat = "#0." + new string('#', yFigures);

The coordinate text is then drawn in a box like this:

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

C#
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;
  // Convert from a WPF Bitmap Source to a Win32 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.

C#
public static MemoryStream CopyFrameworkElementToMemoryStream
    (FrameworkElement copyTarget, double width, double height, 
    BitmapEncoder enc)
{
  // Store the Frameworks current layout transform, as this will be 
  // restored later
  Transform storedTransform = copyTarget.LayoutTransform;

  // Set the layout transform to unity to get the nominal width and height
  copyTarget.LayoutTransform = new ScaleTransform(1, 1);
  copyTarget.UpdateLayout();

  double baseHeight = copyTarget.ActualHeight;
  double baseWidth = copyTarget.ActualWidth;

  // Now scale the layout to fit the bitmap
  copyTarget.LayoutTransform =
    new ScaleTransform(baseWidth / width, baseHeight / height);
  copyTarget.UpdateLayout();

  // Render to a Bitmap Source, note that the DPI is changed for the
  // render target as a way of scaling the FrameworkElement
  RenderTargetBitmap rtb = new RenderTargetBitmap(
    (int)width,
    (int)height,
    96d * width / baseWidth,
    96d * height / baseHeight,
    PixelFormats.Default);

  rtb.Render(copyTarget);

  // Convert from a WPF Bitmap Source to a Win32 Bitmap
  MemoryStream outStream = new MemoryStream();
  enc.Frames.Add(BitmapFrame.Create(rtb));
  enc.Save(outStream);
  // Restore the Framework Element to it's previous state
  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.

License

This article, along with any associated source code and files, is licensed under A Public Domain dedication