Title: Make a sunburst chart in C#, Part 1
A sunburst chart displays hierarchical data in a circular diagram. The hierarchy's root is drawn in the center. Its children sit in a ring around the root. After that, each node's wedge in a ring is divided among its children in the next ring.
In the picture above, you can compare the data in the TreeView control on the left with the sunburst chart on the right.
This is a fairly complicated program so I'm going to start with a simple version and then add more features in the following posts. In this post I'll explain how the program stores its data, how it loads the TreeView control, and how it draws the basic sunburst chart.
Storing Data
There are many ways you can store hierarchical data. For this example I decided to store the data in an XML file. The example uses elements for the values displayed on the chart. The elements can have two attributes, BgColor and FgColor, which give en element's background and foreground colors.
The following code shows the example's XML data.
<Items>
<Food>
<Vegetables BgColor="Green">
<Beans BgColor="SandyBrown" />
<Peas BgColor="LightGreen" />
<Lettuce BgColor="Lime" />
</Vegetables>
<Desserts>
<Tart FgColor="Blue" />
<Cookie FgColor="Brown" />
<Cake FgColor="Green" />
</Desserts>
<Fruit BgColor="Orange">
<Banana />
<Peach />
</Fruit>
</Food>
<Sports BgColor="LightBlue" FgColor="Blue">
<Volleyball />
<Baseball />
</Sports>
</Items>
Loading the TreeView
When the program starts, it executes the following code.
private XmlDocument XmlDoc = new XmlDocument();
private void Form1_Load(object sender, EventArgs e)
{
// Load the XML document.
XmlDoc.Load("test.xml");
// Load the TreeView.
LoadTreeViewFromXmlDoc(XmlDoc, trvItems);
trvItems.ExpandAll();
// Make the sun burst chart.
MakeSunburst();
}
This code declares the variable XmlDoc to hold the XML document. It saves the data at the class level so the program can reread the data as many times as necessary.
The form's Load event handler loads the XML document. It then calls the LoadTreeViewFromXmlDoc method to load the TreeView control. The event handler finishes by expanding all of the TreeView control's nodes and then calling MakeSunburst, which is described later.
// Load a TreeView control from an XML file.
private void LoadTreeViewFromXmlDoc(XmlDocument xml_doc, TreeView trv)
{
// Add the root node's children to the TreeView.
trv.Nodes.Clear();
AddTreeViewNode(trv.Nodes, xml_doc.DocumentElement);
}
// Add the children of this XML node
// to this child nodes collection.
private void AddTreeViewNode(
TreeNodeCollection parent_nodes, XmlNode xml_node)
{
// Make the new TreeView node.
TreeNode new_node = parent_nodes.Add(xml_node.Name);
// Add child nodes.
foreach (XmlNode child_node in xml_node.ChildNodes)
{
// Recursively make this node's descendants.
AddTreeViewNode(new_node.Nodes, child_node);
}
}
The LoadTreeViewFromXmlDoc method clears the TreeView control and then calls AddTreeViewNode to add the root node to the trv.Nodes collection of nodes.
The AddTreeViewNode method does all of the interesting work. It adds a node to the parent_nodes collection that it receives as a parameter. It then loops through the XML node's children and recursively calls AddTreeViewNode to add them to the newly created TreeView node's child collection.
Note that the program also calls MakeSunburst when it resizes so you can enlarge the form to make things fit better. It doesn't resize the font, however. You'll have to do that yourself in the code.
Drawing the Sunburst Chart
The following MakeSunburst method starts drawing the sunburst chart.
// Make a sunburst chart from the XML data.
private Bitmap MakeSunburst(int wid, int hgt, int margin,
XmlDocument xml_doc, Color bm_color, Pen arc_pen, Font font)
{
Bitmap bm = new Bitmap(wid, hgt);
using (Graphics gr = Graphics.FromImage(bm))
{
gr.Clear(bm_color);
gr.SmoothingMode = SmoothingMode.AntiAlias;
// See how deep we must go.
int depth = FindDepth(xml_doc.DocumentElement);
// Calculate geometry.
float cx = wid / 2f;
float cy = hgt / 2f;
wid -= 2 * margin;
hgt -= 2 * margin;
float dr = (Math.Min(wid, hgt) / 2f / depth);
// Draw the root.
RectangleF rect = new RectangleF(
cx - dr, cy - dr, 2 * dr, 2 * dr);
Color bg_color = GetNodeColor(XmlDoc.DocumentElement,
"BgColor", Color.Transparent);
using (Brush brush = new SolidBrush(bg_color))
{
gr.FillEllipse(brush, rect);
}
gr.DrawEllipse(arc_pen, rect);
Color fg_color = GetNodeColor(XmlDoc.DocumentElement,
"FgColor", Color.Black);
DrawCenteredText(gr, font, fg_color,
XmlDoc.DocumentElement.Name,
new PointF(cx, cy));
// Draw the other nodes.
DrawSunburstChildren(gr, cx, cy, dr, 1,
XmlDoc.DocumentElement.ChildNodes,
0, 360, font, bg_color, fg_color);
}
return bm;
}
The method first creates a bitmap of the desired size, creates an associated Graphics object, and clears it. It then calls the following FindDepth method to see how deep the hierarchical data is.
// Return the depth of the XML sub-document rooted at this node.
private int FindDepth(XmlNode node)
{
int depth = 1;
foreach (XmlNode child in node.ChildNodes)
{
int child_depth = FindDepth(child);
if (depth < 1 + child_depth) depth = 1 + child_depth;
}
return depth;
}
This method finds the depth of the sub-tree rooted at the indicated node. It sets variable depth equal to 1. It then loops over the node's children, recursively calling itself to find the depth of the child sub-trees. If a child sub-tree's depth plus 1 is greater than the current value of depth, the method updates depth.
After it finishes visiting the children, the method returns the final value of depth.
After it knows the depth of the hierarchical data, the MakeSunburst method calculates the X and Y coordinates of the center of the sunburst chart. It also calculates the chart's width and height, and dr, the width of the rings.
Next the method draws the root node. This node is special because it is drawn in a circle but the other nodes are drawn in wedges within the rings.
To draw the root, the method makes a rectangle to define the inner circle. It calls GetNodeColor (described shortly) to get the root element's background color, makes a brush of that color, and fills the center circle. It then uses GetNodeColor again to get the node's foreground color and calls DrawCenteredText to draw the root's text in the center of the sunburst chart.
The following code shows the GetNodeColor method.
// Return a node's Color attribute or
// the default value if there is no color.
private Color GetNodeColor(XmlNode node, string color_name,
Color default_color)
{
if (node.Attributes[color_name] == null) return default_color;
try
{
return Color.FromName(node.Attributes[color_name].Value);
}
catch
{
return default_color;
}
}
This method uses node.Attributes to see if the XML node has a particular attribute. The attribute will be either BgColor or FgColor in this example. If the attribute is not present, then the method returns the default color.
If the attribute is present, the method uses Color.FromName to see if this is a known color name. If the value is a known color, such as Red or LightBlue, then the method returns the resulting color. If the values isn't a known color, such as Plaid or Coquelicot, the method returns the default color.
The following code shows the DrawCenteredText method.
// Draw text centered at the position.
private void DrawCenteredText(Graphics gr, Font font, Color color,
string text, PointF center)
{
using (StringFormat sf = new StringFormat())
{
sf.Alignment = StringAlignment.Center;
sf.LineAlignment = StringAlignment.Center;
using (Brush brush = new SolidBrush(color))
{
gr.DrawString(text, font, brush, center, sf);
}
}
}
This method creates a StringFormat object, sets its Alignment and LineAlignment properties to center the text vertically and horizontally, and then draws the text at the indicated location.
Now, back to the MakeSunburst method. The method ends by calling the following DrawSunburstChildren method to recursively draw the root node's children.
// Draw the children of this node.
private void DrawSunburstChildren(Graphics gr,
float cx, float cy, float dr, int level,
XmlNodeList children, float min_angle, float max_angle,
Font font, Color parent_bg_color, Color parent_fg_color)
{
// Draw child arcs.
int num_children = children.Count;
float angle = min_angle;
float dangle = (max_angle - min_angle) / num_children;
foreach (XmlNode child in children)
{
// Draw this child.
Color child_bg_color, child_fg_color;
DrawSunburstChild(gr,
cx, cy, dr, level,
child, angle, angle + dangle, font,
parent_bg_color, parent_fg_color,
out child_bg_color, out child_fg_color);
// Draw this child's children.
DrawSunburstChildren(gr, cx, cy, dr, level + 1,
child.ChildNodes, angle, angle + dangle, font,
child_bg_color, child_fg_color);
// Move to the next child's section.
angle = angle + dangle;
}
}
This method gets the number of children and uses that to calculate the angle subtended by each child. For example, if the parent's arc goes from 90 to 120 degrees and it has 3 children, then each child gets (120 - 90) / 3 = 30 / 3 = 10 degrees of arc.
The method then loops through the children. For each child, the method calls DrawSunburstChild (described next) to draw the child. It then calls itself recursively to draw the child's children.
After processing each child, the method increases the angle variable to position the next child.
The following code shows the DrawSunburstChild method.
// Draw a single node.
private void DrawSunburstChild(Graphics gr,
float cx, float cy, float dr, int level,
XmlNode node, float min_angle, float max_angle, Font font,
Color default_bg_color, Color default_fg_color,
out Color bg_color, out Color fg_color)
{
// Draw the outline.
double min_theta = min_angle / 180f * Math.PI;
double max_theta = max_angle / 180f * Math.PI;
float inner_r = level * dr;
float outer_r = inner_r + dr;
RectangleF outer_rect = new RectangleF(
cx - outer_r, cy - outer_r,
2 * outer_r, 2 * outer_r);
RectangleF inner_rect = new RectangleF(
cx - inner_r, cy - inner_r,
2 * inner_r, 2 * inner_r);
float inner_min_x = (float)(cx + inner_r * Math.Cos(min_theta));
float inner_min_y = (float)(cy + inner_r * Math.Sin(min_theta));
float outer_min_x = (float)(cx + outer_r * Math.Cos(min_theta));
float outer_min_y = (float)(cy + outer_r * Math.Sin(min_theta));
float inner_max_x = (float)(cx + inner_r * Math.Cos(max_theta));
float inner_max_y = (float)(cy + inner_r * Math.Sin(max_theta));
float outer_max_x = (float)(cx + outer_r * Math.Cos(max_theta));
float outer_max_y = (float)(cy + outer_r * Math.Sin(max_theta));
GraphicsPath path = new GraphicsPath();
path.AddArc(outer_rect, min_angle, max_angle - min_angle);
path.AddLine(outer_max_x, outer_max_y, inner_max_x, inner_max_y);
path.AddArc(inner_rect, max_angle, min_angle - max_angle);
path.AddLine(inner_min_x, inner_min_y, outer_min_x, outer_min_y);
path.CloseFigure();
bg_color = GetNodeColor(node, "BgColor", default_bg_color);
using (Brush brush = new SolidBrush(bg_color))
{
gr.FillPath(brush, path);
}
gr.DrawPath(Pens.Black, path);
// Draw the text.
double mid_theta = (min_theta + max_theta) / 2;
float mid_r = (inner_r + outer_r) / 2;
float text_x = (float)(cx + mid_r * Math.Cos(mid_theta));
float text_y = (float)(cy + mid_r * Math.Sin(mid_theta));
string text = node.Name;
fg_color = GetNodeColor(node, "FgColor", default_fg_color);
DrawCenteredText(gr, font, fg_color, text,
new PointF(text_x, text_y));
}
This method draws a single node's data in a sunburst chart ring. Basically it uses some mathematics to calculate the four corners of the wedge and then makes a GraphicsPath to outline the wedge. In the picture on the right, the code adds the arc AB, the segment BC, the arc CD, and finally the segment DA to the GraphicsPath.
The method then fills and outlines the GraphicsPath.
Next the method finds the point E at the center of the wedge. It is at the angle halfway between the wedge's minimum and maximum angles. Its distance from the center of the sunburst chart is half of the distances between the wedge's inner and outer arcs.
After it finds the center point E, the method uses DrawCenteredText to draw the node's text centered at that point.
Next Time...
That's enough for now. This is a pretty long post because it needs to cover a certain minimum amount to draw a basic sunburst chart. Despite its length, this post still glosses over a number of points. Download the example to see additional details.
In my next post, I'll explain how to draw rotated text so the node labels fit inside the wedges better.
Download the example to experiment with it and to see additional details.
|