Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

Custom Gauge Controls for Windows Phone 7: Part II

0.00/5 (No votes)
25 Feb 2013 2  
This article is the second in the series that describes a set of gauge controls for WP7.

Introduction

This article follows the first one in this series. The first article described the design considerations and the way to use the code for a set of custom gauge controls for the Windows Phone 7 platform. This article will dig deeper into the problem and will describe the implementation of the scale classes. If you haven't read the first article, please start with that one first as it will give you a better understanding of what this article is trying to present.

The articles in this series

Below, you can find a list of all the articles in this series:

The Scale base class

As you saw in the previous article, the Scale hierarchy has three classes: a base class (Scale) that is used to hold the logic and properties that are common for every gauge, and two derived classes that implement specific functionality for linear and radial gauges.

The image below presents a diagram of all the common properties a gauge should have regardless of its shape.

These are all dependency properties. You can see that the Scale has properties to hold the value interval (Minimum and Maximum), the indicators, the ranges, and some range properties (thickness, whether or not to use a default range, and whether or not to use the range’s colors to paint the ticks and labels that fall within that range in that particular color). The base class also has properties that are used to configure the label and tick templates.

There are a few things to discuss regarding some of these properties. The first property I’m going to talk about is the Indicators property. This is used to expose the collection of indicators that the scale will have. The indicators will not be held directly inside the scale panel. Instead, I have defined a private Canvas that holds the indicators. This canvas instance is then added to the Scale’s Children collection. The Indicators property exposes the Canvas' Children property. This can be seen in the code below:

Canvas indicatorContainer;
public Scale()
{
    indicatorContainer = new Canvas();            
    Children.Add(indicatorContainer);
    //...
}
public UIElementCollection Indicators
{
    get { return indicatorContainer.Children; }
}

The next property is the Ranges property. This property is used to hold all the optimal ranges the user decides to use. This property has the type ObservableCollection<GaugeRange>. Each range has a maximum value (the offset) and a color. The type definition can be seen in the code below:

public class GaugeRange
{
    public double Offset { get; set; }
    public Color Color { get; set; }
}

A particular range will be drawn from where the previous one ends until the Offset. The first range starts at 0. Another thing to mention here is that this collection only describes the ranges. In order to add ranges to our scale, we need to define the shapes and set the brushes according to the colors and add them to the Scale’s Children collection. There are two methods that are used to add and remove ranges from the scale. These are CreateRanges() and ClearRanges(). Since the range creation depends on the type of range, these two methods are abstract and need to be overridden in the derived classes. The declarations can be seen below:

protected abstract void CreateRanges();
protected abstract void ClearRanges();

The next set of properties I’m going to talk about are the tick and label customization properties. These are the properties that set the templates for the ticks and labels. These are plain DataTemplate properties, but the nice code to show here is the code that handles the lack of templates. The class has a couple of methods that are used to apply the templates. If the user specified the templates, the methods will apply those; if the user didn’t specify any templates, the methods apply implicit templates. These are obtained by loading a predefined XAML string. At the moment, this is hard coded. The two methods can be seen in the listing below:

protected virtual DataTemplate CreateDataTemplate(TickType tType)
{
    string template = "";
    if (tType == TickType.Label)
        template = @"<TextBlock Text=""{Binding}"" FontSize=""13"" FontWeight=""Bold"" />";
    else if (tType == TickType.Minor)
        template = @"<Rectangle Fill=""Snow"" Width=""2"" Height=""5"" />";
    else
        template = @"<Rectangle Fill=""Snow"" Width=""2"" Height=""10"" />";

    string xaml =
        @"<DataTemplate
           xmlns=""http://schemas.microsoft.com/winfx/2006/xaml/presentation""
           xmlns:x=""http://schemas.microsoft.com/winfx/2006/xaml"">"+
           template+@"</DataTemplate>";

    DataTemplate dt = (DataTemplate)XamlReader.Load(xaml);
    return dt;
}

private DataTemplate GetTickTemplate(TickType tType)
{
    //after adding template properties also check that those arent null
    if (tType == TickType.Label)
    {
        if (LabelTemplate != null)
            return LabelTemplate;
        return CreateDataTemplate(tType);
    }
    else if (tType == TickType.Minor)
    {
        if (MinorTickTemplate != null)
            return MinorTickTemplate;
        return CreateDataTemplate(tType);
    }
    else
    {
        if (MajorTickTemplate != null)
            return MajorTickTemplate;
        return CreateDataTemplate(tType);
    }
}

The CreateDataTemplate method is marked as virtual so that it can be overridden in the derived classes to provide different templates for linear and radial scales.

The last thing to talk about in this base class is the tick and label creation. The ticks and labels are held in two private variables. The definitions can be seen below:

List<Tick> labels=new List<Tick>();
List<Tick> ticks=new List<Tick>();

As you can see, the ticks and labels are represented by the Tick class. This class is used to represent labels, minor ticks, and major ticks. The definition of this class can be seen below:

public enum TickType { Minor, Major, Label }
public class Tick:ContentPresenter
{
    public double Value
    {
        get { return (double)GetValue(ValueProperty); }
        set { SetValue(ValueProperty, value); }
    }
    public static readonly DependencyProperty ValueProperty =
        DependencyProperty.Register("ValueProperty", typeof(double), 
                                    typeof(Tick), new PropertyMetadata(0.0));

    public TickType TickType
    {
        get { return (TickType)GetValue(TickTypeProperty); }
        set { SetValue(TickTypeProperty, value); }
    }
    public static readonly DependencyProperty TickTypeProperty =
        DependencyProperty.Register("TickType", typeof(TickType), 
        typeof(Tick), new PropertyMetadata(TickType.Minor));

}

These labels and ticks are created the first time in the constructor of the class and then destroyed and recreated every time tick related properties or the gauge size changes. The label creation method can be seen in the code below:

private void CreateLabels()
{
    double max = Maximum;
    double min = Minimum;
    for (double v = min; v <= max; v += MajorTickStep)
    {
        Tick tick = new Tick() { Value = v, TickType = TickType.Label };
        labels.Add(tick);
        //also set the content and template for the label
        tick.ContentTemplate = GetTickTemplate(TickType.Label);
        tick.Content = v;
        Children.Insert(0, tick);
    }
}

The labels will be created only for the major ticks. The function starts from the minimum value, and for every major tick, creates a label. It creates a tick instance, and sets the value, the type, and the template. As you can see, the template is assigned using the functions described above. At the end, the code sets the content and adds the label to the Scale’s Children collection. The function uses Insert instead of Add so that we can be sure the indicators are always on top of the other scale graphics.

The ticks are created in a similar fashion. The only difference is that an additional check is done to determine the minor ticks. The code for the tick creation method can be seen below:

private void CreateTicks()
{
    double max = Maximum;
    double min = Minimum;
    int num = 0;//the tick index
    double val = min;//the value of the tick
    while (val <= max)
    {
        DataTemplate template = null;
        Tick tick = new Tick();
        tick.Value = val;
        if (num % MinorTickStep == 0)
        {
            tick.TickType = TickType.Minor;
            template = GetTickTemplate(TickType.Minor);
        }
        if (num % MajorTickStep == 0)
        {
            tick.TickType = TickType.Major;
            template = GetTickTemplate(TickType.Major);
        }
        tick.ContentTemplate = template;
        tick.Content = val;
        ticks.Add(tick);
        Children.Insert(0, tick);

        val += MinorTickStep;
        num += MinorTickStep;
    }
}

You can see from the above method that, for a value that is a candidate for both a minor and a major tick, the major tick is chosen. There are also methods for removing the labels and ticks. These methods clear the private lists and also remove the ticks from the Scale’s Children collection. The definitions can be seen below:

private void ClearLabels()
{
    for (int i = 0; i < labels.Count; i++)
    {
        Children.Remove(labels[i]);
    }
    labels.Clear();
}
private void ClearTicks()
{
    for (int i = 0; i < ticks.Count; i++)
    {
        Children.Remove(ticks[i]);
    }
    ticks.Clear();
}

The last piece of code in this class that I haven’t talked about is the code that is used to arrange the labels, ticks, ranges, and indicators. Based on the scale type, all these elements will be arranged in a different way. Because of this, the specific arrange code will be implemented in the derived classes. Also, since this is a container, I should also override the ArrangeOverride and MeasureOverride methods. Since the measuring code depends on the scale type, I have overridden only the ArrangeOverride method. The code for this method can be seen below:

protected override Size ArrangeOverride(Size finalSize)
{
    ArrangeLabels(finalSize);
    ArrangeTicks(finalSize);
    ArrangeRanges(finalSize);
    indicatorContainer.Arrange(new Rect(new Point(), finalSize));
    
    ArrangeIndicators(finalSize);
    //at the end just return the final size
    return finalSize;
}

The three Arrange methods are abstract and should be overridden in the derived scale classes. The declarations can be seen below:

protected abstract void ArrangeTicks(Size finalSize);
protected abstract void ArrangeLabels(Size finalSize);
protected abstract void ArrangeRanges(Size finalSize);

The linear scale

The properties specific to this type of scale can be seen in the image below:

These are dependency properties and are used to specify the orientation of the scale (horizontal or vertical) and the scale’s elements position (TopLeft and BottomRight).

The first methods I would like to talk about are the range creation and removal methods. The code for the range creation is presented below:

protected override void CreateRanges()
{
    //insert the default range
    if (UseDefaultRange)
    {
        def.Fill = DefaultRangeBrush;
        Children.Add(def);
    }
    if (Ranges == null)
        return;
    //it is presumed that the ranges are ordered
    foreach (GaugeRange r in Ranges)
    {
        Rectangle rect = new Rectangle();
        rect.Fill = new SolidColorBrush(r.Color);
        ranges.Add(rect);
        Children.Add(rect);
    }
}

The first thing the method does is check if the UseDefaultRange property is true. If it is, it creates the default range, sets its brush, and adds it to the scale’s Children collection. Then it iterates over the Ranges collection, and for every range description, it creates a rectangle, sets the brush, and adds it to the scale’s Children collection. Since this is the linear scale, the ranges are represented by using rectangles. The rectangles are also held in a private list. The method that removes the ranges is shown in the code below:

protected override void ClearRanges()
{
    //remove the default range
    if (UseDefaultRange)
    {
        Children.Remove(def);
    }
    if (Ranges == null)
        return;
    for (int i = 0; i < ranges.Count; i++)
    {
        Children.Remove(ranges[i]);
    }
    ranges.Clear();
}

The method first removes the default range, removes the range rectangles from the scale’s Children collection, and at the end, it clears the private list.

Another important method in this class is the MeasureOverride method. This will be used to tell the framework how much space the linear scale will occupy. The space depends on the ticks, labels, ranges, and indicator sizes as well as on the scale’s orientation. The method can be seen in the listing below:

protected override Size MeasureOverride(Size availableSize)
{
    foreach (Tick label in GetLabels())
        label.Measure(availableSize);
    foreach (Tick tick in GetTicks())
        tick.Measure(availableSize);
    foreach (Rectangle rect in ranges)
        rect.Measure(availableSize);
    
    if(Indicators!=null)
        foreach (UIElement ind in Indicators)
            ind.Measure(availableSize);

    double width = 0;
    double height = 0;
    double lblMax=0, tickMax=0, indMax=0;
    if (Orientation == Orientation.Horizontal)
    {
        lblMax = GetLabels().Max(p => p.DesiredSize.Height);
        tickMax = GetTicks().Max(p => p.DesiredSize.Height);
        if (Indicators != null && Indicators.Count > 0)
            indMax = Indicators.Max(p => p.DesiredSize.Height);
        height = 3 + lblMax + tickMax + indMax;
        if(!double.IsInfinity(availableSize.Width))
            width = availableSize.Width;
    }
    else
    {
        lblMax = GetLabels().Max(p => p.DesiredSize.Width);
        tickMax = GetTicks().Max(p => p.DesiredSize.Width);
        if (Indicators != null && Indicators.Count > 0)
            indMax = Indicators.Max(p => p.DesiredSize.Width);
        width = 3 + lblMax + tickMax + indMax;
        if (!double.IsInfinity(availableSize.Height))
            height = availableSize.Height;
    }

    return new Size(width, height);
}

The method first measures all of the panel’s children and then returns the desired size. As you can see, the size of the linear scale mostly depends on its orientation.

The last methods to talk about in the linear scale case are the three abstract methods that are used to arrange the tick's labels and ranges. The method used to arrange the labels can be seen in the listing below:

protected override void ArrangeLabels(Size finalSize)
{
    var labels = GetLabels();
    double maxLength = labels.Max(p => p.DesiredSize.Width)+1;
    foreach (Tick tick in labels)
    {
        if (Orientation == Orientation.Horizontal)
        {
            double offset = GetSegmentOffset(finalSize.Width, tick.Value);
            if (TickPlacement == LinearTickPlacement.TopLeft)
            {                        
                tick.Arrange(new Rect(new Point(offset-tick.DesiredSize.Width/2, 1), 
                                                tick.DesiredSize));
            }
            else
            {
                tick.Arrange(new Rect(new Point(offset - tick.DesiredSize.Width / 2, 
                   finalSize.Height - tick.DesiredSize.Height - 1), tick.DesiredSize));
            }
        }
        else
        {
            double offset = GetSegmentOffset(finalSize.Height, tick.Value);
            if (TickPlacement == LinearTickPlacement.TopLeft)
            {
                tick.Arrange(new Rect(new Point(maxLength - 
                  tick.DesiredSize.Width,finalSize.Height - offset - 
                  tick.DesiredSize.Height / 2), tick.DesiredSize));
            }
            else
            {
                tick.Arrange(new Rect(new Point(finalSize.Width - 
                  maxLength,finalSize.Height - offset - 
                  tick.DesiredSize.Height / 2), tick.DesiredSize));
            }
        }
    }
}

As you can see, the method first calculates the label offset and then it calls the Arrange method on each label using the calculated offset and the label’s desired size. The offset is calculated by using the helper function below:

private double GetSegmentOffset(double length, double value)
{
    double offset = length * (value - Minimum) / (Maximum - Minimum);
    return offset;
}

The method used to arrange the ticks is similar. The only difference is the added offset to compensate for the label’s size. The code for this method can be seen below:

protected override void ArrangeTicks(Size finalSize)
{
    var ticks = GetTicks();
    
    foreach (Tick tick in ticks)
    {
        if (Orientation == Orientation.Horizontal)
        {
            double maxLength = ticks.Max(p => p.DesiredSize.Height);
            double offset = GetSegmentOffset(finalSize.Width, tick.Value);
            if (TickPlacement == LinearTickPlacement.TopLeft)
            {
                double yOff=GetLabels().Max(p=>p.DesiredSize.Height)+1;
                tick.Arrange(new Rect(new Point(offset - tick.DesiredSize.Width / 2, 
                  yOff + maxLength - tick.DesiredSize.Height), tick.DesiredSize));
            }
            else
            {
                double yOff = finalSize.Height - maxLength - 
                  GetLabels().Max(p => p.DesiredSize.Height) - 2;
                tick.Arrange(new Rect(new Point(offset - 
                  tick.DesiredSize.Width / 2, yOff), tick.DesiredSize));
            }
        }
        else
        {
            double maxLength = ticks.Max(p => p.DesiredSize.Width) + 
                   GetLabels().Max(p => p.DesiredSize.Width) + 2;
            double offset = GetSegmentOffset(finalSize.Height, tick.Value);
            if (TickPlacement == LinearTickPlacement.TopLeft)
            {
                tick.Arrange(new Rect(new Point(maxLength - tick.DesiredSize.Width,
                  finalSize.Height- offset - 
                  tick.DesiredSize.Height / 2), tick.DesiredSize));
            }
            else
            {
                tick.Arrange(new Rect(new Point(finalSize.Width - maxLength,
                  finalSize.Height- offset - tick.DesiredSize.Height / 2), 
                  tick.DesiredSize));
            }
        }
    }
}

The code used to arrange the ranges uses a similar technique to the previous two methods. This method first arranges the default range if it exists and then iterates over the rest of the ranges in order to arrange them as well. The interesting thing to notice about this function is that for the user defined ranges, the method calculates the optimal range dimension based on the previous range and does not simply start from 0. The code for this method can be seen below:

protected override void ArrangeRanges(Size finalSize)
{
    if (UseDefaultRange)
    {
        if (Orientation == Orientation.Horizontal)
        {
            double yOff;
            if (TickPlacement == LinearTickPlacement.TopLeft)
            {
                yOff = GetLabels().Max(p => p.DesiredSize.Height) + 
                       GetTicks().Max(p => p.DesiredSize.Height) + 1;
                def.Arrange(new Rect(new Point(0, yOff), 
                            new Size(finalSize.Width, RangeThickness)));
            }
            else
            {
                yOff = GetLabels().Max(p => p.DesiredSize.Height) + 
                       GetTicks().Max(p => p.DesiredSize.Height) + 3;
                def.Arrange(new Rect(new Point(0, finalSize.Height - yOff - 
                            RangeThickness), new Size(finalSize.Width, RangeThickness)));
            }

        }
        else
        {
            double off = GetLabels().Max(p => p.DesiredSize.Width) + 
                         GetTicks().Max(p => p.DesiredSize.Width) + 2;
            if (TickPlacement == LinearTickPlacement.TopLeft)
            {
                def.Arrange(new Rect(new Point(off, 0), 
                            new Size(RangeThickness, finalSize.Height)));
            }
            else
            {
                def.Arrange(new Rect(new Point(finalSize.Width - off - RangeThickness, 0), 
                                     new Size(RangeThickness, finalSize.Height)));
            }
        }
    }
    double posOffset = 0;
    for (int i = 0; i < ranges.Count; i++)
    {
        Rectangle rect = ranges[i];
        rect.Fill = new SolidColorBrush(Ranges[i].Color);

        if (Orientation == Orientation.Horizontal)
        {
            double yOff;
            if (TickPlacement == LinearTickPlacement.TopLeft)
            {
                yOff = GetLabels().Max(p => p.DesiredSize.Height) + 
                  GetTicks().Max(p => p.DesiredSize.Height) + 1;
                rect.Arrange(new Rect(new Point(posOffset, yOff), 
                  new Size(GetSegmentOffset(finalSize.Width, 
                  Ranges[i].Offset) - posOffset, RangeThickness)));
            }
            else
            {
                yOff = GetLabels().Max(p => p.DesiredSize.Height) + 
                       GetTicks().Max(p => p.DesiredSize.Height) + 3;
                rect.Arrange(new Rect(new Point(posOffset, finalSize.Height - 
                     yOff - RangeThickness), new Size(GetSegmentOffset(finalSize.Width, 
                     Ranges[i].Offset) - posOffset, RangeThickness)));
            }
            posOffset = GetSegmentOffset(finalSize.Width, Ranges[i].Offset);
        }
        else
        {
            double off = GetLabels().Max(p => p.DesiredSize.Width) + 
                         GetTicks().Max(p => p.DesiredSize.Width) + 2;
            double segLength = GetSegmentOffset(finalSize.Height, Ranges[i].Offset);
            if (TickPlacement == LinearTickPlacement.TopLeft)
            {
                rect.Arrange(new Rect(new Point(off, finalSize.Height - segLength), 
                             new Size(RangeThickness, segLength - posOffset)));
            }
            else
            {
                rect.Arrange(new Rect(new Point(finalSize.Width - off - RangeThickness, 
                     finalSize.Height - segLength), 
                     new Size(RangeThickness, segLength - posOffset)));
            }
            posOffset = segLength;
        }
    }
}

As you can see, before iterating over the range collection, the method initializes an offset variable. This variable will be used to calculate the correct start position of each range. This offset is updated after each iteration so that the next range starts where the previous one ended. After the horizontal and vertical offsets are calculated, the Arrange method is called. The length of the current range will be calculated by subtracting the current offset from the offset of the current range. This effectively determines the optimal length and does not start every range from 0.

The images below present some screenshots with the LinearScale. Each image presents different customizations.

The image above presents four LinearScale instances. The first one uses the default settings. The second linear scale has a RangeThickness of 5 and has templates for the labels and both tick types. The third scale has the TickPlacement property set to LinearTickPlacement.BottomRight, has two ranges, and has the RangeThickness property set to 5. The last scale also has the TickPlacement property set to LinearTickPlacement.BottomRight. It also has templates for the minor and major ticks.

The image above presents the same four LinearScale instances. The only difference is that this time the Orientation property is set to Orientation.Vectical.

The radial scale

The properties specific to radial scales can be seen in the image below:

These are all dependency properties. MinAngle and MaxAngle determine the start angle and the sweep of the scale. TickPlacement determines whether the ticks should be drawn inward or outward. The RadialType property refers to the circle subtypes we want for the scale. We can have a full circle scale, a semicircle scale, or a quadrant scale. This option can be mainly used to save space on the screen. For example, if the user chooses a 90 degree range, maybe he doesn’t want to waste the other three quadrants of the circle. To save space, he can specify that he wants a quadrant. This property will be used in conjunction with the min, max angle, and sweep direction properties. This property will have an effect on the position of the center of rotation inside the area of the scale and on the range. If the angles and sweep direction don’t match, the user can choose to change them to get what he wants. This is easier than implementing complicated logic to determine the quadrant in code and coercing the angle values.

The range creation and removal methods are similar to the ones implemented for the linear scale. The only difference is that instead of rectangles, the ranges for radial scales will be instances of the Path class.

The MeasureOverride method calls Measure on all the container children and then it just returns the desired size. We will use all the available sizes and center the scale in that space. The definition can be seen below:

protected override Size MeasureOverride(Size availableSize)
{
    double width = 0.0;
    double height = 0.0;
    if (!double.IsInfinity(availableSize.Width))
        width = availableSize.Width;
    if (!double.IsInfinity(availableSize.Height))
        height = availableSize.Height;
    Size size = new Size(width, height);
    //measure all the children
    foreach (Tick label in GetLabels())
        label.Measure(availableSize);
    foreach (Tick tick in GetTicks())
        tick.Measure(availableSize);
    foreach (Path path in ranges)
        path.Measure(availableSize);
    if (Indicators != null)
        foreach (UIElement ind in Indicators)
            ind.Measure(availableSize);
    //return the available size as everything else will be 
    //arranged to fit inside.
    return size;
}

The last methods to talk about are the three abstract methods used to arrange the labels, ticks, and ranges. These three methods make use of a helper class. I will first present these methods and then describe the helper class.

The code that arranges the labels in the radial scale can be seen below:

protected override void ArrangeLabels(Size finalSize)
{
    double maxRad = RadialScaleHelper.GetRadius(RadialType, finalSize, 
                    MinAngle, MaxAngle, SweepDirection);
    Point center = RadialScaleHelper.GetCenterPosition(RadialType, 
                   finalSize, MinAngle, MaxAngle, SweepDirection);
    double x = center.X;
    double y = center.Y;
    double rad = maxRad;
    if (TickPlacement == RadialTickPlacement.Inward)
    {
        rad = maxRad - RangeThickness - GetTicks().Max(p => p.DesiredSize.Height);
    }
    var labels = GetLabels();

    for (int i = 0; i < labels.Count; i++)
    {
        PositionTick(labels[i], x, y, rad - labels[i].DesiredSize.Height / 2);
    }
}

The code first retrieves the center of the scale and the maximum radius by using the helper class methods. After this, the actual radius is calculated based on the tick orientation. At the end, the code iterates over every label and positions it. This is done using the PositionTick method. The definition of this method can be seen below:

private void PositionTick(Tick tick, double x, double y, double rad)
{
    // Tick tick = ticks[i];
    double tickW = tick.DesiredSize.Width;
    double tickH = tick.DesiredSize.Height;

    double angle = GetAngleFromValue(tick.Value);
    if (SweepDirection == SweepDirection.Counterclockwise)
        angle = -angle;
    //position the tick
    double px = x + (rad) * Math.Sin(angle * Math.PI / 180);
    double py = y - (rad) * Math.Cos(angle * Math.PI / 180);
    px -= tickW / 2;
    py -= tickH / 2;
    tick.Arrange(new Rect(new Point(px, py), tick.DesiredSize));

    //rotate the tick (if not label or
    //if it is label and has rotation enabled)
    if ((EnableLabelRotation && tick.TickType==TickType.Label) || 
         tick.TickType!=TickType.Label)
    {
        RotateTransform tr = new RotateTransform();
        tr.Angle = angle;
        tick.RenderTransformOrigin = new Point(0.5, 0.5);
        tick.RenderTransform = tr;
    }
}

The method calculates the tick (label or tick) position based on the center and radius by using the polar coordinate system formulas. The method then calls Arrange() on the tick and then it sets the RenderTransform in order to rotate the tick. You can see from the code above that the tick is only rotated if it is not a label or if it is a label and if the EnableLabelRotation property is set to true.

The method that arranges the ticks can be seen in the listing below:

protected override void ArrangeTicks(Size finalSize)
{
    double maxRad = RadialScaleHelper.GetRadius(RadialType, finalSize, 
                    MinAngle, MaxAngle, SweepDirection);
    Point center = RadialScaleHelper.GetCenterPosition(RadialType, 
                   finalSize, MinAngle, MaxAngle, SweepDirection);
    double x = center.X;
    double y = center.Y;

    var ticks = GetTicks();
    var labels = GetLabels();

    double rad = maxRad - labels.Max(p => p.DesiredSize.Height) - 
                 ticks.Max(p => p.DesiredSize.Height) - 1;
    if (TickPlacement == RadialTickPlacement.Inward)
    {
        rad = maxRad - RangeThickness;
    }

    for (int i = 0; i < ticks.Count; i++)
    {
        if (TickPlacement == RadialTickPlacement.Outward)
            PositionTick(ticks[i], x, y, rad + ticks[i].DesiredSize.Height / 2);
        else
            PositionTick(ticks[i], x, y, rad - ticks[i].DesiredSize.Height / 2);
    }
}

As you can see, the method is very similar and the tick placement method is called with a different radius based on the tick orientation property. The last method is the one that arranges the ranges. The first part of the ArrangeRanges() method will be used to arrange the default range in case the UseDefaultRange property is set to true. This can be seen below:

double maxRad = RadialScaleHelper.GetRadius(RadialType, finalSize, 
                MinAngle, MaxAngle, SweepDirection);
Point center = RadialScaleHelper.GetCenterPosition(RadialType, 
               finalSize, MinAngle, MaxAngle, SweepDirection);
double x = center.X;
double y = center.Y;
//calculate the ranges' radius
double rad = maxRad;
if (TickPlacement == RadialTickPlacement.Outward)
{
    rad = maxRad - GetLabels().Max(p => p.DesiredSize.Height) - 
                   GetTicks().Max(p => p.DesiredSize.Height) - 1;
}
//draw the default range
if (UseDefaultRange)
{
    double min = MinAngle, max = MaxAngle;
    if (SweepDirection == SweepDirection.Counterclockwise)
    {
        min = -min;
        max = -max;
    }                    
    //the null check needs to be done because
    //otherwise the arrange pass will be called 
    //recursevely as i set new content for the path in every call
    Geometry geom= RadialScaleHelper.CreateArcGeometry(min, max, rad, 
                   RangeThickness, SweepDirection);
    if (def.Data == null || def.Data.Bounds!=geom.Bounds)
        def.Data = geom;
    //arrange the default range. move the start point of the 
    //figure (0, 0) in the center point (center)
    def.Arrange(new Rect(center, finalSize));
}

The code first gets the center and the maximum possible radius, then it sets the range shape by using the CreateArcGeometry() method. In the end, the code calls Arrange() on the default range by using the center point.

The code that arranges the rest of the ranges is similar. You can see this below:

double prevAngle = MinAngle;
if (SweepDirection == SweepDirection.Counterclockwise)
    prevAngle = -prevAngle; 
for (int i = 0; i < ranges.Count; i++)
{
    Path range = ranges[i];
    GaugeRange rng = Ranges[i];
    double nextAngle = GetAngleFromValue(rng.Offset);
    if (SweepDirection == SweepDirection.Counterclockwise)
        nextAngle = -nextAngle;

    range.Fill = new SolidColorBrush(rng.Color);

    if (range.Data == null)
        range.Data = RadialScaleHelper.CreateArcGeometry(prevAngle, 
                     nextAngle, rad, RangeThickness, SweepDirection);

    range.Arrange(new Rect(center, finalSize));
    prevAngle = nextAngle;
}

Besides arranging the ranges, this code also sets the color of each range. In order to arrange these ranges, the code needs to get the end angles. The code starts at the minimum angle and then increments that after each range has been arranged.

The images below present some screenshots with the RadialScale. Each image presents different customizations.

In the above image, the section on the left presents a radial scale with default settings. The section on the right has a single range set. The range thickness is 5, the range offset is 60.

In the above image, the section on the left presents a radial scale with the TickPlacement property set to LinearTickPlacement.Inward, the RangeThickness property is 5, and the scale has two ranges. The section on the right shows a radial scale in which the label, minor tick, and major tick templates were changed.

The above image presents two gauges that have the SweepDirection property set to SweepDirection.Counterclockwise. In the section on the left, the MinAngle is 0 and the MaxAngle is 270. In the section on the right, the MinAngle is 90, the MaxAngle is 360, the UseDefaultRange is false, and we have a single range defined.

In this last image, we have two radial scales with more tick and label customizations.

The RadialScaleHelper class

This is a helper class that is used by the RadialScale type to calculate the radial scale center position, the radial scale radius, and the range geometries. The class has three public methods. The first method returns the logical center of the scale. This is the point that will be used as the center of rotation. The second method returns the desired radius of the scale. This radius will depend on the minimum and maximum angles and also on the RadialType setting. The last method will be used to obtain the range shapes.

The radial gauge can be a circle, a semi circle, or a quarter of a circle. In each situation, the center of rotation will differ. The GetCenterPosition() method will be used to determine this center based on the radial type, the final size of the scale, the min and max angles, and the sweep direction. The center position cases can be seen better in the image below:

The definition of this method can be seen below:

public static Point GetCenterPosition(RadialType type, Size finalSize, double minAngle, 
              double maxAngle, SweepDirection sweepDir)
{
    //get the quadrants of the limits
    int q1 = GetQuadrant(minAngle);
    int q2 = GetQuadrant(maxAngle);
    if (sweepDir == SweepDirection.Counterclockwise)
    {
        q1++; q2++;
        if (q1 > 4) q1 = 1;
        if (q2 > 4) q2 = 1;
    }
    else
    {
        //q2->q4 and q4->q2
        if (q1 % 2 == 0) q1 = 6 - q1;
        if (q2 % 2 == 0) q2 = 6 - q2;
    }
    //calculate the difference
    int diff = q2 - q1;
    if (Math.Abs(diff) == 0)
    {
        //quarter possibility
        if (type == RadialType.Quadrant)
        {
            return GetCenterForQuadrant(q2, finalSize);
        }
        else if (type == RadialType.Semicircle)
        {
            if (q1 == 1 || q1 == 2)
                return new Point(finalSize.Width / 2, finalSize.Height);
            else
                return new Point(finalSize.Width / 2, 0);
        }
        else
        {
            //full circle
            return new Point(finalSize.Width / 2, finalSize.Height / 2);
        }
    }
    else if (Math.Abs(diff) == 1 || (Math.Abs(diff)==3 && (maxAngle-minAngle)<=180))
    {
        //semicircle possibility
        if (type == RadialType.Quadrant || type == RadialType.Semicircle)
        {
            return GetCenterForSemicircle(q1, q2, finalSize);
        }
        else
        {
            //full circle
            return new Point(finalSize.Width / 2, finalSize.Height / 2);
        }
    }
    else
    {
        //full circle
        return new Point(finalSize.Width / 2, finalSize.Height / 2);
    }
}

Since the geometric quadrants don’t match up with the ones on the screen, the method first converts the screen quadrants to the geometric ones. After this, based on the difference, a different point is returned. The same checks are done to get the maximum possible radius in the GetRadius method.

The last method is the method that builds the range geometries. Every range will have four segments. These can be seen in the image below:

The code for the method can be seen below:

public static Geometry CreateArcGeometry(double minAngle, double maxAngle, 
       double radius, int thickness, SweepDirection sweepDirection)
{
    //the range will have 4 segments (arc, line, arc, line)
    //if the sweep angle is bigger than 180 use the large arc
    //first use the same sweep direction as the control. invert for the second arc.
    PathFigure figure = new PathFigure();
    figure.IsClosed = true;
    figure.StartPoint = new Point((radius - thickness) * 
           Math.Sin(minAngle * Math.PI / 180),
           -(radius - thickness) * Math.Cos(minAngle * Math.PI / 180));

    //first arc segment
    ArcSegment arc = new ArcSegment();
    arc.Point = new Point((radius - thickness) * Math.Sin(maxAngle * Math.PI / 180),
        -(radius - thickness) * Math.Cos(maxAngle * Math.PI / 180));
    arc.Size = new Size(radius - thickness, radius - thickness);
    arc.SweepDirection = sweepDirection;
    if (Math.Abs(maxAngle - minAngle) > 180) arc.IsLargeArc = true;
    figure.Segments.Add(arc);
    //first line segment
    LineSegment line = new LineSegment();
    line.Point = new Point(radius * Math.Sin(maxAngle * Math.PI / 180),
        -radius * Math.Cos(maxAngle * Math.PI / 180));
    figure.Segments.Add(line);
    //second arc segment
    arc = new ArcSegment();
    arc.Point = new Point(radius * Math.Sin(minAngle * Math.PI / 180),
        -radius * Math.Cos(minAngle * Math.PI / 180));
    arc.Size = new Size(radius, radius);
    arc.SweepDirection = SweepDirection.Counterclockwise;
    if (sweepDirection == SweepDirection.Counterclockwise)
        arc.SweepDirection = SweepDirection.Clockwise;
    if (Math.Abs(maxAngle - minAngle) > 180) arc.IsLargeArc = true;
    figure.Segments.Add(arc);

    PathGeometry path = new PathGeometry();
    path.Figures.Add(figure);
    return path;
}

Final thoughts

That is all there is to scales implementation. I hope this second article sheds some more light on the implementation of the scales in this control library. Please check out the last article in this series to see how the indicators are implemented.

If you liked the article and found the code useful, please take a minute to post a comment and vote for the first article in the series.

History

  • Created on March 19, 2011.
  • Updated on March 21, 2011.
  • Updated source code on March 23, 2011.
  • Updated source code on March 30, 2011.
  • Updated source code and sample on April 05, 2011.
  • Updated source code on April 10, 2011.
  • Updated source code on February 24, 2013.

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here