Title: Draw a horizontal compass in C#
This example shows how to draw the horizontal compass shown at the bottom of the picture on the right. On the surface this seems like a simple drawing exercise. It mostly is a drawing exercise, although it turned out to not be quite as simple as I had thought it would be. The drawing code itself isn't too complicated, but figuring out exactly how to come up with that code took a little while.
I also added the circular compass heading display in the middle of the form to make it easier to visualize the horizontal compass's results. For example, when the horizontal compass 135 degrees, the circular heading display points southeast.
The following sections explain how the program draws the horizontal compass. The later sections describes the code that draws the circular heading display.
DrawCompass
The horizontal compass is just a PictureBox. The following code shows the control's Paint event handler, which draws the compass.
private void DrawCompass(Graphics gr, int value)
{
// Draw the background.
DrawBackground(gr);
// Draw tick marks.
using (Font nsew_font = new Font("Arial", 14,
FontStyle.Italic | FontStyle.Bold))
{
using (Font degrees_font = new Font("Arial", 12, FontStyle.Italic))
{
DrawTickMarks(gr, CurrentValue, nsew_font, degrees_font);
}
}
// Draw the pointer.
DrawPointer(gr);
}
This code mostly just calls other methods. First it calls DrawBackground to draw the dark gradient background. The code then creates two fonts, one to draw degree numbers and one to draw N, S, E, and W. It then calls the DrawTickMarks method to draw the compass's tick marks and text.
The Paint event handler finishes by calling the DrawPointer method to draw the little home plate on the control's middle top.
DrawBackground
The following code shows the DrawBackground method.
private void DrawBackground(Graphics gr)
{
int wid = picCompass.ClientSize.Width;
int hgt = picCompass.ClientSize.Height;
using (LinearGradientBrush brush =
new LinearGradientBrush(
picCompass.ClientRectangle,
Color.White, Color.Gray, 90))
{
ColorBlend color_blend = new ColorBlend();
color_blend.Colors = new Color[]
{
Color.White, Color.Black, Color.Black, Color.White,
};
color_blend.Positions = new float[]
{
0.0f, 0.3f, 0.8f, 1.0f,
};
brush.InterpolationColors = color_blend;
gr.FillRectangle(brush, picCompass.ClientRectangle);
}
}
The most interesting part of this method is the code that creates the brush. The method creates a LinearGradientBrush that shades from white to gray across the body of the control. The final parameter to the brush's constructor, which has value 90, indicates that the brush should shade vertically (in the direction of 90 degrees) from top to bottom.
After it creates the brush, the code modifies it so it shades from white to black and then back to white. To do that it creates a ColorBlend object and sets that object's Colors and Positions values. The Colors values indicate colors that the gradient should display. The Positions values indicate the fraction of the distance through the brush where each color appears. For example, the first black color appears 0.3 of the distance through the brush. The values used here make the brush hold the black color between fractions 0.3 and 0.8.
After it has created the brush, the code simply fills the compass's area with it.
DrawTickMarks
After the call to DrawBackground returns, the DrawCompass method creates two fonts and passes them to the following DrawTickMarks method.
private void DrawTickMarks(Graphics gr, float center_value,
Font nsew_font, Font degrees_font)
{
// Set the number of degrees that are visible.
const int width_in_degrees = 180;
// Calculate tick geometry.
const int letter_freq = 90; // Draw a letter every 90 degrees.
const int large_tick_freq = 30; // Draw a large tick mark every 30 degrees.
const int small_tick_freq = 15; // Draw a small tick mark every 15 degrees.
float large_tick_hgt = picCompass.ClientSize.Height / 5f;
float small_tick_hgt = large_tick_hgt / 2f;
float large_tick_y0 = picCompass.ClientSize.Height / 10f;
float large_tick_y1 = large_tick_y0 + large_tick_hgt;
float small_tick_y0 = large_tick_y0;
float small_tick_y1 = small_tick_y0 + small_tick_hgt;
// Find the center.
int wid = picCompass.ClientSize.Width;
float x_mid = wid / 2f;
float letter_y = large_tick_y1 * 1.2f;
// Find the width of one degree on the control.
float pix_per_degree = (float)picCompass.ClientSize.Width / width_in_degrees;
// Find the value at the left edge of the control.
float left_value = center_value - (wid / 2f) / pix_per_degree;
// Find the next smaller multiple of small_tick_freq.
int degrees = small_tick_freq * (int)(left_value / small_tick_freq);
degrees -= small_tick_freq;
// Find the corresponding X position.
float x = x_mid - (center_value - degrees) * pix_per_degree;
// Adjust degrees so it is between 1 and 360.
if (degrees <= 0) degrees += 360;
// Draw tick marks.
using (StringFormat sf = new StringFormat())
{
sf.Alignment = StringAlignment.Center;
for (int i = 0; i <= width_in_degrees; i += small_tick_freq)
{
// See what we should draw.
string letter = "";
if (degrees % letter_freq == 0)
{
switch (degrees / letter_freq)
{
case 1:
letter = "E";
break;
case 2:
letter = "S";
break;
case 3:
letter = "W";
break;
case 4:
letter = "N";
break;
}
gr.DrawLine(Pens.White, x, large_tick_y0, x, large_tick_y1);
gr.DrawString(letter, nsew_font,
Brushes.White, new PointF(x, letter_y), sf);
}
else if (degrees % large_tick_freq == 0)
{
gr.DrawLine(Pens.White, x, large_tick_y0, x, large_tick_y1);
gr.DrawString(degrees.ToString(), degrees_font,
Brushes.White, new PointF(x, letter_y), sf);
}
else
{
gr.DrawLine(Pens.White, x, small_tick_y0, x, small_tick_y1);
}
degrees += small_tick_freq;
if (degrees > 360) degrees -= 360;
x += small_tick_freq * pix_per_degree;
}
}
}
This method first defines some constants to determine things like tick mark sizes and frequencies. This example draws a small tick mark every 15 degrees, a large tick mark every 30 degrees, and the letters N, S, E, and W every 90 degrees.
The code then finds the center of the control and calculates the size of one degree in pixels.
Next the method needs to draw the tick marks. You could loop from 0 to 360 degrees and draw the appropriate tick marks. You would still need to do some calculations to figure out where 0 degrees belongs on the control.
Depending on the current central value, you might also need to draw another set of tick marks after the first one. For example, if center_value is 340, then the first pass from 0 to 360 degrees will end just past the center of the control and you'll need to draw another set of tick marks.
Similarly if the central value is small, you'll need to draw another set of tick marks to the left. For example, if center_value is 10, then the first instance of zero degrees will be just to the left of the center and all of the control to the left of that point will be empty.
This kind of looping would work, but it seems rather cumbersome. This example takes a different approach. It calculates the degree value of the leftmost edge of the control. To do that, it divides the control's half-width (in pixels) by the number of pixels per degree. That gives the half-width in degrees. It then subtracts that value from center_value to get the degree value at the control's left edge. Note that this value might be less than zero.
The code then find the next smaller multiple of small_tick_freq. To do that, it divides the left edge degree value left_value by small_tick_freq, truncates the result, and multiples by small_tick_freq. One oddity here is that the (int) operator truncates toward zero. That means if the left_value is less than zero, then the resulting multiple of small_tick_freq is larger than left_value.
We could start drawing there, but I want to start a bit earlier so letters and numbers that are drawn just off the left edge of the control are partly visible. To accomplish that, the method subtracts small_tick_freq from the result.
Now that we know the leftmost degree value that we want to draw, the code calculates the X coordinate of that position. It then adjusts the degrees value if necessary so it lies between 1 and 360 degrees. That is the value that we will draw.
At this point the method is just about ready to draw the tick marks. It creates a StringFormat object to align the text and enters a loop. The loop runs from the 0 to the control's width in degrees. It increments by small_tick_freq each time it passes through the loop.
If the degree number is a multiple of the letter frequency (90 degrees), then the method draws a large tick mark and the appropriate letter.
If the degree number is not a multiple of the letter frequency and it is a multiple of the large tick mark frequency, then the method draws a large tick mark and the degree number as a string.
If the degree number is not one of those multiples, then the method draws a small tick mark.
At the end of the loop, the method adds small_tick_freq to variable degrees so it knows what degree number to draw next time. It also adds small_tick_freq times the number of pixels per degree to x so it knows where to draw the next tick mark.
DrawPointer
After the DrawBackground and DrawTickMarks methods have finished, the DrawCompass method calls the following DrawPointer method.
private void DrawPointer(Graphics gr)
{
float y0 = 0;
float y2 = picCompass.ClientSize.Height / 10f;
float y1 = y2 / 2f;
float half_wid = y2;
// Find the center.
int wid = picCompass.ClientSize.Width;
float x_mid = wid / 2f;
// Define the points.
PointF[] points =
{
new PointF(x_mid - half_wid, y0),
new PointF(x_mid + half_wid, y0),
new PointF(x_mid + half_wid, y1),
new PointF(x_mid, y2),
new PointF(x_mid - half_wid, y1),
};
gr.FillPolygon(Brushes.LightBlue, points);
gr.DrawPolygon(Pens.Black, points);
}
This method simply creates some points to define the pointer's shape and then fills and outlines it.
That's all there is to the horizontal compass. The trickiest part is the DrawTickMarks method.
The next section describes the DrawHeading method that draws the circular heading display.
DrawHeading
The following DrawHeading method draws the circular heading display.
private void DrawHeading(Graphics gr, int value, Font font)
{
float cx = picHeading.ClientSize.Width / 2f;
float cy = picHeading.ClientSize.Height / 2f;
// Draw NSEW.
float letter_r = Math.Min(cx, cy) * 0.85f;
string[] letters = { "N", "E", "S", "W" };
int[] degrees = { 270, 0, 90, 180 };
for (int i = 0; i < 4; i++)
{
float letter_x = letter_r * (float)Math.Cos(DegreesToRadians(degrees[i]));
float letter_y = letter_r * (float)Math.Sin(DegreesToRadians(degrees[i]));
PointF point = new PointF(cx + letter_x, cy + letter_y);
DrawRotatedText(gr, font, Brushes.Black,
letters[i], point, degrees[i] + 90);
}
// Draw tick marks.
const int large_tick_freq = 30; // Draw a large tick mark every 30 degrees.
const int small_tick_freq = 15; // Draw a small tick mark every 15 degrees.
const int tiny_tick_freq = 3; // Draw a tiny tick mark every 3 degrees.
float outer_r = letter_r * 0.9f;
float large_r = outer_r * 0.8f;
float small_r = outer_r * 0.9f;
float tiny_r = outer_r * 0.95f;
using (Pen pen = new Pen(Color.Blue, 3))
{
for (int i = tiny_tick_freq; i <= 360; i += tiny_tick_freq)
{
float cos = (float)Math.Cos(DegreesToRadians(i));
float sin = (float)Math.Sin(DegreesToRadians(i));
float x0 = cx + outer_r * cos;
float y0 = cy + outer_r * sin;
float x1, y1;
if (i % large_tick_freq == 0)
{
pen.Width = 3;
x1 = cx + large_r * cos;
y1 = cy + large_r * sin;
}
else if (i % small_tick_freq == 0)
{
pen.Width = 2;
x1 = cx + small_r * cos;
y1 = cy + small_r * sin;
}
else
{
pen.Width = 1;
x1 = cx + tiny_r * cos;
y1 = cy + tiny_r * sin;
}
gr.DrawLine(pen, x0, y0, x1, y1);
}
}
// Draw the pointer.
// Rotate 90 degrees so North is at 0.
double radians = DegreesToRadians(value - 90);
const int tip_r = 4;
float pointer_r = large_r * 1.0f;
float tip_x = cx + pointer_r * (float)Math.Cos(radians);
float tip_y = cx + pointer_r * (float)Math.Sin(radians);
float tip_x1 = cx + tip_r * (float)Math.Cos(radians + Math.PI / 2.0);
float tip_y1 = cy + tip_r * (float)Math.Sin(radians + Math.PI / 2.0);
float tip_x2 = cx + tip_r * (float)Math.Cos(radians - Math.PI / 2.0);
float tip_y2 = cy + tip_r * (float)Math.Sin(radians - Math.PI / 2.0);
PointF[] points =
{
new PointF(tip_x, tip_y),
new PointF(tip_x1, tip_y1),
new PointF(tip_x2, tip_y2),
};
gr.FillPolygon(Brushes.Black, points);
// Draw the center.
const int center_r = 6;
RectangleF rect = new RectangleF(
cx - center_r, cy - center_r,
2 * center_r, 2 * center_r);
gr.FillEllipse(Brushes.LightBlue, rect);
gr.DrawEllipse(Pens.Black, rect);
}
This method first finds the center of the drawing area. It then loops through the letters N, S, E, and W and calls the DrawRotatedText method to draw those letters at their correct compass points. I'll describe that method shortly.
After drawing the letters, the method loops around the circle drawing tick marks. It uses a technique similar to the one used earlier to draw tick marks of different sizes at various multiples of certain frequencies. This time it draws large, small, and tiny tick marks.
After drawing the tick marks, the method draws the pointer. Both that code and the code that draws the tick marks use sine and cosines to figure out where each line segment should end. The code is somewhat long, but it's relatively straightforward. It's mostly long because it uses a lot of constants to define things like the size of the tick marks.
The following code shows the DrawRotatedText method.
private void DrawRotatedText(Graphics gr, Font font, Brush brush,
string text, PointF location, float degrees)
{
GraphicsState state = gr.Save();
gr.ResetTransform();
gr.RotateTransform(degrees);
gr.TranslateTransform(location.X, location.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);
}
This method saves the Graphics object's graphical state and then resets its transform.
It then make the Graphics rotate the desired number of degrees. It follows that with a second transformation that moves the resulting graphic so the origin is positioned at the point (x, y).
Next the code creates a StringFormat object that centers text vertically and horizontally. The method then draws the desired text at the origin. The StringFormat object centers the text and then the Graphics object's transformations rotate the text and translate it to the desired position.
The method finishes by restoring the Graphics object's saved state.
Conclusion
You may never need this kind of horizontal compass or circular heading display, but you could use something similar to provide some interesting gauges. In any case this example provides some interesting exercises in figuring out how to draw different kinds of graphics.
You may want to add the DrawRotatedText method to your toolkit.
Download the example to experiment with it and to see additional details.
|