Note: The code presented has a little flaw: The ellipse width is statically set in the code, since the bottom wouldn't sit correctly on the X-Axis line.
Introduction
Honor to them who deserve it - This article is based on the article "Drawing a Grid Covered Cylinder in WPF", written by CodeProject user StewBob. Well, I renamed his "DrawingClass
" to "CylinderPainter3D
" and modified it in order to be able to draw the cylinder with an X / Y offset, without the Grid Lines, with different Radius values, heights and Colors - Finished was the core part I needed for my 3D Cylinder chart.
The only thing I had to do from scratch was to add another class to draw the chart itself - This class, called ChartPainter
takes advantage of the previously mentioned CylinderPainter3D
class which is basically StewBob's expanded DrawingClass
.
In the end, the chart will look somewhat like this:
The resulting classes will enable to draw a chart based on a collection of data, maximum size, offset and caption in order to make your application look more professional.
Even the Color of the chart elements (Cylinders, in this case) is customizable and can be set to every System.Windows.Media.Color
available in the System.Windows.Media.Colors
collection: System.Windows.Media.Color
available in the System.Windows.Media.Colors
collection:
Background
About the WPF Canvas - What is it?
Defines an area within which you can explicitly position child elements by using coordinates that are relative to the Canvas area.
This sentence, stolen from the MSDN, says basically that you can forms onto a two dimensional coordinate systems. The special thing you really really need to keep in mind about the Canvas is that the Y Axis is mirrored:
------------------------------------ X [10/0]
|[0/0]
|
|
|
|
|
|
|
|
| Y [0/8]
A small graphic of the coordinate system in a System.Windows.Controls.Canvas
. The Y Axis appears mirrored.
This chapter is going to show you how the Chart is drawn, and which offsets and constant values were applied. We first did a sketch drawing in Microsoft Visio in order to get an idea of how the chart might be drawn the best way. I'd highly recommend this to everyone to everyone doing drawing of custom elements in any programming language: You get a straightforward idea of how something is done the best way without loosing too much time if the first attempt fails.
An important Use Case was that the Chart can be drawn anywhere on the Canvas
element. I did this by specifying an Offset Point - X Offset
on the vertical X Axis and Y Offset
on the vertical Y Axis (# [1] (X Offset) and [2] (Y Offset). Changing either the value of the X
or Y
Offset results in the chart being drawn with a bigger or smaller offset.
The Y Axis Line
[4] starts at X Offset
/ Y Offset
- It's end is at X Offset
/ Y Offset
+ Y Axis Line
- The Y Axis Line
is equal to the maximum height of the chart in total.
Drawing the X Axis Line
of the chart was nearly the same as drawing the Y Axis Line
- The start point is at the end point of the Y Axis Line
( X Offset
/ Y Offset
+ Y Axis Line
), and the end point is simply the same as the start point of the X Axis Line, except that I had to add the length of the X Axis Line
to the X
coordinate of the end point [5].
Positioning the Cylinders (shown as yellow boxes in the picture above) was pretty easy: I know the starting point of the X Axis Line, and I know the offset each Cylinder needs from there. The said offset is calculated by the formula:
25 + (3r * index)
'r'
is the radius of a single Cylinder - It is calculated by dividing the length of the X Axis through the amount of Cylinders the chart needs to hold. This ensures a variable use of the charts width, and takes the burden of adjusting the width of the chart according to the amount of Cylinders it is going to hold from the Developer's back.
The index
is a zero-based number indicating how far a Cylinder is away from the Chart's 0/0 coordinate point (X Offset
/ Y Offset
+ Y Axis Line
). The index
is calculated 'on the fly' while drawing the Cylinders.
Now what is the number 25
used for? Simples: It is a default initial offset on the X Axis because the Cylinder would else stick to the Y Axis Line, destroying the whole decent appearance.
Now, drawing the Cylinder that it will have the correct height is a bit more difficult - Since the Canvas
' Y-Axis is mirrored, I needed to use the following formula:
(Y Axis Maximum Height – Cylinder Height) + Y Offset
I guess it is quite clear what it means: The Y Axis Maximum Height
- Cylinder Height
gives you the gap between the start of the drawing point and the top border of the Chart - Adding the Y Offset
gives you the exact value to pass on to the Cylinder Drawing class.
To calculate the offset of the Cylinder caption [6] is an easy gain: Just add up the length of the Y Axis Line
, the Y Offset
and an extra Offset to prevent the caption from sticking to the X Axis line.
Now you know about the whole magic behind drawing the chart, at least in theory. But there was this other thing, right?
Exactly. We now have to add the caption for the X and Y Lines.
In the top left corner of the chart is the Caption of the Y Axis placed. It has a left (X) offset of X Offset
+ 2 - The 2 is a constant value which keeps the caption from sticking to the Y Axis Line.
The caption for the X Axis Line is placed at Y Offset
+ Length of the Y Axis Line [3] from top and X Offset
+ Length of the X Axis Line from left [4].
Using the Code
The chapter "Using the code" provides an overview over the classes used within the project. Every class method is documented with an explanation text and separately explained code samples. The code samples are left out in case of properties - I assume what a property is, what it does and what its code looks like - I just added a text explaining what specific need the property serves.
ChartDataContainer - The Data Container
As for every data handling thingie, we need something to contain the data first. I made a class called ChartDataContainer
for this purpose which offers the following data properties:
List<double> Data
List<string> DataCaptions
Point MaxSize
Point Offset
string XAxisText
string YAxisText
Color ChartElementColor
Properties
Alright - If you have skipped the "Background" chapter and / or ask yourself now "Wait. Why do I need all this data?" - Here comes the explanation, grouped by each property of the ChartDataContainer
.
List<double> Data
The Data
property provides getter and setter methods for a list of double
values which are the data points to display in the chart. Each double
value in the list represents a single Cylinder's height.
List<string> DataCaptions
The DataCaption
property is optional and has some restrictions on it: The amount of items in the list must either match the amount of items in the Data
list or be zero. Each string
in the list represents a caption of an X Axis value (Cylinder) to display in the chart.
Point MaxSize
The MaxSize
property is used to get or set an X-Y value pair which defines the maximum size of the chart control. The X-Y value pair is represented by a System.Drawing.Point.
Point Offset
The Offset
property is a getter and setter property to define the Offset from the left (X) and from the top of the canvas where the chart is drawn onto. As the MaxSize
property it is an X-Y value pair represented by a System.Drawing.Point.
string XAxisText
This property is used to get or set the caption of the chart's X-Axis.
string YAxisText
The YAxisText
property is used to get or set the caption of the chart's Y-Axis.
Color ChartElementColor
The ChartElementColor
property defines the filling color of a graph element, e.g. the Cylinders.
CylinderPainter3D - Draw a Cylinder
The CylinderPainter3D
class is used to draw the 3D Cylinders used in the chart. It is configurable by setting the following properties:
Color FillingColor
double CylinderHeight
double CylinderRadius
I haven't listed all of the properties here - There are actually a few more - but those who I left out are not configurable by the ChartDataContainerClass
and therefore useless for explaining the given example.
Properties
Color FillingColor
The FillingColor
property defines the filling color of the Cylinder.
double CylinderHeight
The CylinderHeight
property defines the height of the Cylinder.
double CylinderRadius
The CylinderRadius
property defines the Radius of the Cylinder (CylinderRadius
* 2 = Cylinder's diameter).
Methods
public void DrawCylinder(System.Windows.Controls.Canvas cnv, double xOffset, double yOffset)
Draws a 3D Cylinder with a specified X/Y offset onto a System.Windows.Controls.Canvas
. The rest of the data is provided by the class' Properties. A big part of this method was written by StewBob, as mentioned at the top of this article.
The whole drawing method is based on the following seven variables:
double ellipseHeight = 12;
Point ptUpperLeft;
Point ptUpperRight;
Point ptLowerLeft;
Point ptLowerRight;
Point ptC;
Path pth = new Path();
ptUpperLeft = new Point(xOffset, ellipseHeight * 2);
ptUpperRight = new Point(xOffset + (cylinderRadius * 2), ptUpperLeft.Y);
ptLowerLeft = new Point(xOffset, ptUpperLeft.Y + cylinderHeight);
ptLowerRight = new Point(ptUpperLeft.X + (cylinderRadius * 2), ptUpperLeft.Y + cylinderHeight);
ptC = new Point(xOffset + cylinderRadius, ptUpperLeft.Y);
Each of those variables serves a very specific need:
ellipseHeight
- The ellipse on the top of the cylinder has a height which is statically set to 12.
This gives the impression that you look at the Cylinder from a position which is above the Cylinder - The original version by StewBob had a dynamically calculated height of the ellipse, but I had to leave it out because it would've broken the offset calculation.
ptUpperLeft
ptUpperRight
ptLowerLeft
ptLowerRight
- These four variables define the corners of the Cylinder as X/Y coordinates
ptC
- The
ptC
variable defines the coordinates of the center of the Cylinder's top ellipse.
pth
- This one will contain the
PathFigure
and Geometry
Segments.
Now that we know about which variables we are going to use drawing the Cylinder is quite easy:
LineSegment ln = new LineSegment(ptLowerLeft, true);
ArcSegment arc = new ArcSegment(ptLowerRight,
new Size(cylinderRadius, ellipseHeight), 0, false,
System.Windows.Media.SweepDirection.Counterclockwise, true);
PathFigure pf = new PathFigure();
pf.StartPoint = ptUpperLeft;
pf.Segments.Add(ln);
pf.Segments.Add(arc);
ln = new LineSegment(ptUpperRight, true);
pf.Segments.Add(ln);
PathGeometry pg = new PathGeometry();
pg.Figures.Add(pf);
pth.Stroke = new SolidColorBrush
(gridLineColor);pth.StrokeThickness = 2;
pth.Fill = new SolidColorBrush(fillingColor);
pth.Data = pg;
cnv.Children.Add(pth);
pth = new Path();
pth.Stroke = new SolidColorBrush
(gridLineColor);pth.StrokeThickness = 1;pth.Fill = new SolidColorBrush(fillingColor);
pg = new PathGeometry();
pg.AddGeometry(new EllipseGeometry(ptC, cylinderRadius, ellipseHeight));
pth.Data = pg;
cnv.Children.Add(pth);
The above snippet does nothing else than adding Geometry
and PathFigure
elements to the path in order to draw the path (pth
) on the canvas after everything is colored. Now that we went through the code to draw the Cylinder, there is just one thing left: Drawing the chart & adding several Cylinders in order to make a shiny chart, self-drawn onto a canvas.
ChartPainter - Draw a Chart
The CylinderChartPainter
class can be seen as API which is encapsulating the whole functionality of drawing the Chart and Chart Elements (Cylinders) - It is static
, and provides only a single static
method.
Methods
void DrawChart( System.Windows.Controls.Canvas cnv, ChartDataContainer container)
This method draws a Cylinder Chart onto the System.Windows.Controls.Canvas
"cnv" - The data defining the chart's size, offset, content, content captions and colorization options is stored in the ChartDataContainer
"container" .
The first thing is that the X-Axis line of the chart is drawn on to the canvas (I use the class Line
from the System.Windows.Shapes
namespace to draw Lines):
Line xLine = new Line();
xLine.Stroke = Brushes.Black;
xLine.X1 = container.Offset.X;
xLine.X2 = container.Offset.X + container.MaxSize.X;
xLine.Y1 = container.Offset.Y + container.MaxSize.Y;
xLine.Y2 = container.Offset.Y + container.MaxSize.Y;
xLine.StrokeThickness = 1; xLine.SnapsToDevicePixels = true;
cnv.Children.Add(xLine);
The next thing drawn is the Y-Axis Line (Same technique as for the X-Axis line, but vertically aligned):
Line yLine = new Line();
yLine.Stroke = Brushes.Black;
yLine.X1 = container.Offset.X;
yLine.Y1 = container.Offset.Y;
yLine.X2 = container.Offset.X;
yLine.Y2 = xLine.Y2;
yLine.StrokeThickness = 1;<
span style="font-size: 9pt;"><span style="font-size: 9pt;">yLine.SnapsToDevicePixels = true;
</span><span style="font-size: 9pt;">
cnv.Children.Add(yLine); </span>
<span style="font-size: 9pt;">//Add Line to canvas </span><
//span style="font-size: 9pt;"> </span>
After the X- and Y-Axis Lines are drawn, I need to draw the caption for them, according to the caption supplied in the ChartDataContainer
. In order to give you a better idea of what is placed where, I present you the same graphic as I did a bit above again:
The following code does nothing else than inserting two text blocks ("Y Axis Caption" and "X Axis Caption" in the picture above) according to the four offsets [1], [2], [3] and [4]. The text value is provided by the container
:
TextBlock yAxisDescriptionBlock = new TextBlock();
yAxisDescriptionBlock.Text = container.YAxisText;
Canvas.SetLeft(yAxisDescriptionBlock, yLine.X1 + 5);
Canvas.SetRight(yAxisDescriptionBlock, xLine.X2);
Canvas.SetTop(yAxisDescriptionBlock, container.Offset.Y);
cnv.Children.Add(yAxisDescriptionBlock);
TextBlock xAxisDescriptionBlock = new TextBlock();
xAxisDescriptionBlock.Text = container.XAxisText;
Canvas.SetTop(xAxisDescriptionBlock, yLine.Y2 - 12);
Canvas.SetLeft(xAxisDescriptionBlock, xLine.X2 + 3);
Canvas.SetRight(xAxisDescriptionBlock, xLine.X2 + container.YAxisText.Length);
cnv.Children.Add(xAxisDescriptionBlock);
Afterwards we are ready to use the CylinderPainter3D
class to draw the Cylinder elements of the chart.
Each chart is drawn according to the sketches shown in the chapter Drawing Sketches, and the offset (the gap) between the single Cylinders is calculated as:
CylinderRadius * 2.5
Another thing which I haven't shown in the sketch above is the fact that I included a scaling mechanism, allowing the chart to automatically resize the height of the cylinders in order to use as much space as possible (and therefore, display the data as detailed as possible). For example, if the biggest data element is 45 and the chart has a maximum height of 100, the method automatically resizes every Cylinder to be twice as high as it would be without the scale. This feature enables the chart to be as readable as possible. The charts are drawn for each data element in the container
.
double offsetX = container.Offset.X + 25;double scale = container.MaxSize.Y /
container.Data.Max(); double radius = (container.MaxSize.X /
(container.Data.Count + 2.5)) / 2; CylinderPainter3D cylinderPainter = new CylinderPainter3D();
cylinderPainter.CylinderRadius =
radius; cylinderPainter.FillingColor =
container.ChartElementColor; foreach(double dataelement in container.Data)
{
double topY = (container.Offset.Y +
(container.MaxSize.Y - (dataelement*scale))) - 26; cylinderPainter.CylinderHeight = dataelement * scale; cylinderPainter.DrawCylinder(cnv, offsetX, topY); offsetX += cylinderPainter.CylinderRadius * 2.5; }
After the chart's Cylinders are added to the canvas, I only need to add the caption elements for each Cylinder. Since it is entirely voluntary to even add captions to the Cylinders, I check whether there are captions available in the container
:
if (container.DataCaptions.Count > 0){
If there are caption elements, I proceed to add them to the canvas (I add them using a text block, and a horizontal line to connect the caption to a Cylinder. In addition, I defined a zipper
variable, which has a value of 10 and is multiplicated with -1
each iteration of the foreach
loop, causing the captions to have a slight, zipper-like offset which makes them better readable (the cyptions are not looking crowded anymore).
offsetX = container.Offset.X + 25; short zipperOffset = 10;
foreach (string caption in container.DataCaptions)
{
zipperOffset *= (-1);
Line captionLine = new Line();
captionLine.Stroke = Brushes.Black;
captionLine.X1 = offsetX;
captionLine.X2 = offsetX;
captionLine.Y1 = yLine.Y2;
captionLine.Y2 = yLine.Y2 + 40 + zipperOffset;
cnv.Children.Add(captionLine);
TextBlock chartElementDescriptionTextBlock = new TextBlock();
chartElementDescriptionTextBlock.Text = caption;
Canvas.SetTop(chartElementDescriptionTextBlock, yLine.Y2 + 47 + zipperOffset);
Canvas.SetLeft(chartElementDescriptionTextBlock, offsetX);
offsetX += radius * 2.5;
Canvas.SetRight(chartElementDescriptionTextBlock, offsetX);
cnv.Children.Add(chartElementDescriptionTextBlock);
}
The above code gives a caption, connected to a Cylinder
element of the chart looking like this:
Using the ChartPainter class to draw a Cylinder Chart
I made up a simple example code which shall show you how the ChartPainter
class can be used to draw a chart:
ChartDataContainer container = new ChartDataContainer();
container.Data = new List<double>() {20, 33, 115 , 85, 65 };container.DataCaptions = new List<string>()
{"1","2", "3", "4", "5"};container.MaxSize = new Point(700, 300);container.Offset = new Point(10, 150);
container.ChartElementColor = Colors.SlateBlue;
CylinderChartPainter.DrawChart(container, this.Canvas1);
Executing the above code gives you a chart looking like this:
Points of Interest
I learned how to draw a 3D Cylinder, use the drawn Cylinder to make a 3D Cylinder chart control and learned how to draw Text, Lines and other fancy stuff onto a Canvas - What really grinds my gears so far is the mirrored Y Axis of the WPF Canvas... If anyone has an idea how that comes - feel free to contact me and brighten my mind.
Apart from that, I am happy to announce that the charting routine I presented in this article was already used in production, in another CodeProject article Sandro Lorek and I did together. If you are interested in seeing what the code presented in this article does, download it and try to include it in a real life project. If you have any suggestions, bug findings / fixings or just want to say how happy you are with the code we presented, feel free to drop a comment below.
History