Title: Draw a radar chart in C#
A radar chart shows measurements along several axes that all radiate out from a common center. This lets you compare several properties for different objects at the same time. in a way, you can think of a radar chart as similar to a line graph that has been bent around so the X axis has been condensed to a point.
The Main Idea
The main idea is relatively straightforward. Find the radar chart's center point (cx, cy). Divide the circle into the right number of wedges for the desired number of properties that you want to graph. Then plot points with different radii from the center point in the directions of the wedges.
To be a bit more precise (but not too precise just yet), suppose you want to draw a radar chart with five properties or dimensions. Then we divide the circle into 360 / 5 = 72 degree wedges. You should draw the axes at 0, 72, 144, 216, and 288 degrees from the center of the radar chart. (This example actually rotates the angles back 90 degrees so the first axis is drawn vertically.)
Now suppose an object has the value R for a particular dimension and that dimension should be drawn at angle theta from the center of the radar chart. Then the corresponding point on the chart has coordinates (R * Cosine(theta), R * Sine(theta)).
You should pick the values R for the objects so they fill the drawing area nicely.
To draw the radar chart for a particular object, find the points for that object's values and connect them to form a polygon.
If you make positive outcomes farther from the center, then better objects will produce larger polygons. Note that this does not mean you can blindly use the polygons' areas to decide which object is better because the different dimensions may have different degrees of importance. For example, if you're trying to decide which car to buy, price may be more important than number of cupholders. Still, the radar chart lets you quickly compare the objects.
Storing Data
Probably the biggest issue when building this example is deciding how to store the data. This example uses two classes to store data about the objects and about the dimensions displayed on the chart.
The following CarData class holds data about cars.
public class CarData
{
public string Name;
public Color Color;
public float[] Values;
public PointF[] Points = null;
public CarData(string name, Color color,
params float[] values)
{
Name = name;
Color = color;
Values = (float[])values.Clone();
}
}
This class simply holds a car's name and values. The Values array holds the values. Notice that the class does not know what those values represent. That makes iterating over the values easier but would make using the class for other purposes harder. In this example the values are for luxury electric cars and store a car's low-end price, high-end price, overall rating by Edmunds, range per charge, and miles per kilowatt-hour of charging. (I got the data from the Edmunds Best Electric Cars page.)
The Points array holds the points that the program uses to draw the car's radar chart. The program stores the points instead of generating them as needed so it can easily use them to display tooltips when the mouse hovers over one of the points.
The class's constructor takes as parameters the car's name, the color that should be used to draw the car's radar chart, and a params array holding the values. The constructor simply saves the name and color. It then clones the values parameter and saves it in the object's Values array.
The following AxisInfo class is even simpler.
public class AxisInfo
{
public string Name, FormatString;
public float Min, Max;
public AxisInfo(string name, string format_string,
float min, float max)
{
Name = name;
FormatString = format_string;
Min = min;
Max = max;
}
}
This class holds the name of an axis (such as PriceLow or Rating) and a format string that should be used to format values along the axis. (For example, price values should be formatted as currency.)
The class also stores Min and Max values that indicate how values on the axis should be scaled along the axis. For example, suppose the min and max values for a price axis are $40,000 and $100,000. Then the value $40,000 is mapped to the center of the radar chart and the value $100,000 is mapped to the end of the axis farthest from the center. You should select min and max so the axes' values for different objects are spread out nicely. For example, if the actual car prices range from $60,000 to $70,000 and you set min = 0 and max = 100,000, then the cars' values will be too close together to be very useful.
The AxisInfo class's constructor simply saves the name, format string, min, and max values.
Initializing Data
When the program starts, the following code initializes the radar chart data.
// Initialize the car data.
// From https://www.edmunds.com/electric-car/.
private void Form1_Load(object sender, EventArgs e)
{
Cars = new List<CarData>();
Cars.Add(new CarData("Audi e-tron", Color.Red, 69850, 80900, 8.4f, 218, 100f / 44));
Cars.Add(new CarData("Jaguar I-PACE", Color.Green, 39090, 44590, 8.2f, 234, 100f / 30));
Cars.Add(new CarData("Polestar 2", Color.Blue, 59900, 59900, 8.2f, 275, 100f / 27));
AxisInfos = new List<AxisInfo>();
AxisInfos.Add(new AxisInfo("PriceLow", "c", 90000, 30000));
AxisInfos.Add(new AxisInfo("PriceHigh", "c", 90000, 30000));
AxisInfos.Add(new AxisInfo("Rating", "0.0", 0, 10));
AxisInfos.Add(new AxisInfo("Range", "0", 0, 300));
AxisInfos.Add(new AxisInfo("Miles/kWh", "0.00", 0, 5));
}
This code creates the Cars list and then adds three CarData objects to it to hold information about the three kinds of cars that the example uses. It then similarly creates a new AxisInfos list and adds AxisInfo objects to represent the five property axes that the program uses.
Notice that the min and max values for the PriceLow and PriceHigh axes are reversed. For example, the minimum PriceLow value is $90,000 and the maximum value is $30,000. Reversing those values makes the radar chart plot larger values close to the chart's center and smaller values farther out. That makes all of the "good" values farther away from the center so it's easier to tell which cars have better properties.
That's the only really interesting setup. The remaining interesting pieces draw the radar chart and display tooltips.
Drawing the Radar Chart
It takes a few steps to draw the radar chart. The process starts when the picPlot PictureBox control receives a Paint event and executes the following event handler.
private void picPlot_Paint(object sender, PaintEventArgs e)
{
e.Graphics.SmoothingMode = SmoothingMode.AntiAlias;
e.Graphics.TextRenderingHint = TextRenderingHint.AntiAlias;
e.Graphics.Clear(picPlot.BackColor);
// Draw the axes.
DrawCharts(e.Graphics, chkFillAreas.Checked);
}
This code sets some Graphics object properties to produce smooth lines and text, clears the drawing, and then calls the following DrawCharts method.
// Draw the radar charts.
private void DrawCharts(Graphics gr, bool fill_areas)
{
// Find the center and radii.
float cx = picPlot.ClientSize.Width / 2f;
float cy = picPlot.ClientSize.Height / 2f;
float rx = cx - 20;
float ry = cy - 20;
// Find the angular distance between wedges.
double dtheta = 2 * Math.PI / AxisInfos.Count;
// Draw.
DrawAxes(gr, cx, cy, rx, ry, dtheta);
DrawLevels(gr, cx, cy, rx, ry, dtheta);
DrawRadarCharts(gr, fill_areas, cx, cy, rx, ry, dtheta);
}
This code finds the center of the drawing area and calculates the chart's X and Y radii, minus a margin. (If you want the chart's drawing area to be square, make the two radii the same. For example, you can set them both equal to the smaller of the two, or ensure that the picPlot control is square.)
Next the code calculates the angular distance between the axes. This is simply 2π radians divided by the number of axes.
The method finishes by calling the DrawAxes, DrawLevels, and DrawRadarCharts methods described in the following sections to do the actual drawing.
DrawAxes
The following DrawAxes method draws the chart's axes.
// Draw the axes.
private void DrawAxes(Graphics gr, float cx, float cy,
float rx, float ry, double dtheta)
{
double theta = -Math.PI / 2;
using (Font font = new Font("Arial", 12))
{
for (int i = 0; i < AxisInfos.Count; i++)
{
double x = cx + rx * Math.Cos(theta);
double y = cy + ry * Math.Sin(theta);
gr.DrawLine(Pens.Black, cx, cy, (float)x, (float)y);
x = cx + (rx + 10) * Math.Cos(theta);
y = cy + (ry + 10) * Math.Sin(theta);
DrawRotatedText(gr, font, Brushes.Black,
AxisInfos[i].Name, x, y, theta + Math.PI / 2);
theta += dtheta;
}
}
}
This code makes variable theta start at -π/2 radians (-90 degrees). That makes the first axis vertical and above the chart's center. (Look at the picture at the top of the post.)
The code creates a font and then loops through the AxisInfos list.
For each axis, the method calculates the X and Y coordinates of the axis's second end point. (The first end point is at the center.) It then draws a line between the center and the end point.
Next the code finds a point that lies 10 pixels beyond the axis end point. It then calls the DrawRotatedText method described next to draw the axis name at that point. The code adds π/2 radians (90 degrees) to the angle passed into the call to DrawRotatedText method to give the text the correct orientation.
The following code shows the DrawRotatedText method.
// Draw text rotated at the indicated point.
private void DrawRotatedText(Graphics gr, Font font,
Brush brush, string text, double x, double y, double theta)
{
GraphicsState state = gr.Save();
gr.ResetTransform();
gr.RotateTransform((float)(theta * 180 / Math.PI));
gr.TranslateTransform((float)x, (float)y, MatrixOrder.Append);
using (StringFormat sf = new StringFormat())
{
sf.Alignment = StringAlignment.Center;
sf.LineAlignment = StringAlignment.Center;
gr.DrawString(text, font, brush, 0, 0, sf);
}
gr.Restore(state);
}
The method first saves the Graphics object's state and resets its transformation to remove any previous transformations. It then adds a rotation to give the text its desired orientation. Next it translates the origin to the text's desired final location (x, y).
The code then creates a StringFormat object to center text vertically and horizontally, and uses the DrawString method to draw the text at the origin. The StringFormat object centers the text over the origin. The Graphics object's transformations then rotate the text and translate it to its desired final destination (x, y).
DrawLevels
The following DrawLevels method draws the black pentagons that show points 20%, 40%, 60%, 80% and 100% of the distances along each axis.
// Draw level polygons.
private void DrawLevels(Graphics gr, float cx, float cy,
float rx, float ry, double dtheta)
{
// Draw the level polygons.
double theta = -Math.PI / 2;
int num_levels = 5;
double dfraction = 1.0 / num_levels;
double fraction = dfraction;
PointF[] points = new PointF[AxisInfos.Count];
for (int level = 0; level < num_levels; level++)
{
for (int i = 0; i < AxisInfos.Count; i++)
{
double x = cx + fraction * rx * Math.Cos(theta);
double y = cy + fraction * ry * Math.Sin(theta);
points[i] = new PointF((float)x, (float)y);
theta += dtheta;
}
gr.DrawPolygon(Pens.Black, points);
fraction += dfraction;
}
}
The method finds the number of levels that it should draw. It divides 1.0 by that number to get the amount by which each level should differ from the ones around it. This example shows five levels so they are separated by 0.2 times the length of the axes. initially the code sets the value fraction to the difference between the levels. (In this example, that's 0.2.)
The code then loops through the levels. For each level, the method loops through the number of axes. For each axis, the code calculates the point along that axis that is fraction distance from the chart's center to the end of the axis. It saves the point in the points array.
After it has found the points for all of the axes at this level, the program uses the points to draw a polygon. It then increases fraction so it is ready to draw the next polygon.
The method continues looping through the levels until it has drawn them all. If you like, you could skip the final level so the axes stick out past the outermost level polygon.
DrawRadarCharts
The following DrawRadarCharts method draws a radar chart for each car.
// Draw a radar chart for each car.
private void DrawRadarCharts(Graphics gr, bool fill_areas,
float cx, float cy, float rx, float ry, double dtheta)
{
// Plot the data.
foreach (CarData car_data in Cars)
{
DrawRadarChart(car_data, gr, fill_areas, cx, cy, rx, ry, dtheta);
}
}
This method simply loops through the car data and calls the following DrawRadarChart method for each car.
// Draw one car's radar chart.
private double DrawRadarChart(CarData car_data,
Graphics gr, bool fill_areas,
float cx, float cy, float rx, float ry, double dtheta)
{
// Get this car's polygon.
PointF[] points = new PointF[AxisInfos.Count];
double theta = -Math.PI / 2;
for (int i = 0; i < AxisInfos.Count; i++)
{
double frac =
(car_data.Values[i] - AxisInfos[i].Min) /
(AxisInfos[i].Max - AxisInfos[i].Min);
double x = cx + frac * rx * Math.Cos(theta);
double y = cy + frac * ry * Math.Sin(theta);
points[i] = new PointF((float)x, (float)y);
theta += dtheta;
}
// Save the points.
car_data.Points = points;
// Draw the polygon.
if (fill_areas)
{
Color color = Color.FromArgb(64, car_data.Color);
using (Brush brush = new SolidBrush(color))
{
gr.FillPolygon(brush, points);
}
}
using (Pen pen = new Pen(car_data.Color, 3))
{
gr.DrawPolygon(pen, points);
}
return theta;
}
This method loops through the number of axes. For each axis, it gets the car's corresponding Values entry and uses interpolation to see what fraction of the distances along the axis the value lies.
For example, if the value is close to AxisInfos[i].Min, then the fraction's numerator is close to zero so the fraction is close to zero. If the value is close to AxisInfos[i].Max, then the fraction's numerator is close to its denominator so the fraction is close to one.
Having found the fraction, the code uses it to find the corresponding point along the axis. (This is similar to the way the DrawLevels method worked except here the fraction varies with each of the car's values. In the DrawLevels method the fraction was the same for every point at a particular level.)
After it has found all of the car's points, the method saves them in the CarData object's Points array for later use.
If the Fill Areas checkbox is checked, then the fill_areas parameter is true and the method creates a color that uses the car's color but that has alpha (opacity) value value 64, making it largely transparent. The code uses the color to create a brush and then fills a polygon defined by the car's points. The picture on the right shows the program displaying filled polygons.
The method finishes by outlining the car's polygon with a thick pen.
Displaying Toltips
When you move the mouse over the picPlot control, the following event handler executes.
// Display a tooltip if appropriate.
private void picPlot_MouseMove(object sender, MouseEventArgs e)
{
string tip_text = "";
foreach (CarData car in Cars)
{
// Skip this car if it has no points yet.
if (car.Points == null) continue;
// Check the car's points.
for (int i = 0; i < car.Values.Length; i++)
{
if (PointIsClose(e.Location, car.Points[i], 8))
{
tip_text =
car.Name + " " +
AxisInfos[i].Name + ": " +
car.Values[i].ToString(AxisInfos[i].FormatString);
break;
}
if (tip_text != "") break;
}
}
if (tip_text != tipPoint.GetToolTip(picPlot))
tipPoint.SetToolTip(picPlot, tip_text);
}
This code loops through the car data. For each car, it loops through the car's points to see if the mouse's position is close the the mouse. It does that by calling the PointIsClose method described shortly.
If the PointIsClose method says that the mouse is close to one of the car's points, the code composes some tooltip text and breaks out of its loops.
After the loops end, the event handler compares the new tooltip text to the picPlot control's current tooltip. If the two are different, the code displays the new tooltip.
There are two interesting things to note here. First, the new tooltip text will be blank if the mouse is not near any of the cars' points. That lets the program remove old tooltips.
Second, the code only updates the tooltip if it has changed. Once a tooltip is displayed, it remains unchanged even if the mouse moves slightly. To see the difference, comment out the statement if (tip_text != tipPoint.GetToolTip(picPlot)) and see what happens. You may prefer that effect.
The following code shows the PointIsClose method.
private bool PointIsClose(PointF point1, PointF point2, float radius)
{
float dx = point1.X - point2.X;
float dy = point1.Y - point2.Y;
return (dx * dx + dy * dy) < (radius * radius);
}
This method simply calculates the distance squared between two points and returns true if the result is less than the given radius squared.
Conclusion
This example is somewhat complex, largely to support more advanced features like allowing you to easily change the names and number of axes, display more car objects, and show tooltips. Download the example to experiment with it and to see additional details.
Looking at the two pictures shown above, you can easily see that the Jaguar I-Pace covers the most area, so you may decide that it is the best choice. The Polestar 2 has slightly more area in the Range and Miles/kWh dimensions, so it may be better if you think those are much more important than price.
Strangely the Audi e-tron had worse scores on all dimensions except for the Edmunds overall rating. You'll probably need to do some more reading to find out why Edmunds thought that was the better car even though it did worse on price, range, and Miles/kWh.
Download the example to experiment with it and to see additional details.
|