Scrolling in WPF Toolkit’s Column Chart

In WPF Toolkit charts, when points are plotted on the chart’s plot area, they take up all the available space. However, when points get too many, the plot area becomes congested and labels overlap. A workaround is to have a scrollable axis and plot only the points with visible coordinates.

Introduction

I needed to use charts for the reports in an application I was working on. Having used the WPF Toolkit controls like DataGrid and DatePicker for some time now, I decided to use the included chart controls instead of using other third-party controls. Currently, it is in preview quality as of the February 2010 release. One problem that I encountered is that there is no built-in scrolling support. Look at the following figure below. You can see that the axis labels overlap and all the columns are fitted to the available area.



Figure 1. Overlapping Labels and Congested Points

I believe that there will be support for chart scrolling in the future. But for now we have to devise a workaround. I have searched over the Internet for articles that may have solved this problem but haven’t found any. In this example, I will be working on a column chart.

Getting Started

Before we start coding, it is better if you have the WPF toolkit source code with you as reference. This can be downloaded from this link. At the time of writing, the latest release was on February 2010.

Custom Axis

My first idea is just to add a Scrollbar control to the default template of the independent axis and handle scroll events so I can show the coordinates and data points that should be visible and hide those that are not. However, I decided to use a ScrollViewer control because it seems to be easier to use. We only need to wrap the panel containing the axis and update each data point’s position each time the scrollbar is moved by the user. The following XAML shows the template of the axis, taken from the default style of the DisplayAxis control.

<ControlTemplate TargetType="charting:DisplayAxis">
<Grid x:Name="AxisGrid" Background="{TemplateBinding Background}">
<datavis:Title x:Name="AxisTitle" Style="{TemplateBinding TitleStyle}" />
</Grid>
</ControlTemplate>

Listing 1. Default DisplayAxis Template

The Grid named AxisGrid will contain a Panel that will then be populated with coordinate labels. In our example, the Panel will be filled up with categories, (Part0, Part1, Part2, and on) since the horizontal axis is of type CategoryAxis. You can see the different Axis classes in the next figure. If the Panel is accessible in our code, we can wrap it inside a ScrollViewer. Another option is to put a ScrollViewer around the parent Grid but that would mean the axis’ title will be scrollable as well. In any case, we have to derive a class from the DisplayAxis class or one of its descendants to implement scrolling.



Figure 2. Axis Classes

In our example, it might be natural to think that we need to subclass CategoryAxis. However, I found little benefit from doing this. That is, we will only inherit the SortOrder dependency property. Other fields are marked as private, even the list of categories. As for the methods, we need to override their implementation anyway. Thus, I decided to create a ScrollableCategoryAxis class which is basically a copy of the CategoryAxis class, except the part where the Panel is populated with the category labels. Here is the CategoryAxis’ implementation of adding category labels to the Panel.

/// <summary>
/// Renders as an oriented axis.
/// </summary>
/// <param name="availableSize">The available size.</param>
private void RenderOriented(Size availableSize)
{
_labelPool.Reset();
_majorTickMarkPool.Reset();

try
{
OrientedPanel.Children.Clear();
this.GridLineCoordinatesToDisplay.Clear();

if (this.Categories.Count > 0)
{
double maximumLength = Math.Max(GetLength(availableSize) - 1, 0);

Action<double> placeTickMarkAt =
(pos) =>
{
Line tickMark = _majorTickMarkPool.Next();
OrientedPanel.SetCenterCoordinate(tickMark, pos);
OrientedPanel.SetPriority(tickMark, 0);
this.GridLineCoordinatesToDisplay.Add(new UnitValue(pos, Unit.Pixels));
OrientedPanel.Children.Add(tickMark);
};

int index = 0;
int priority = 0;

foreach (object category in Categories)
{
Control axisLabel = CreateAndPrepareAxisLabel(category);
double lower = ((index * maximumLength) / Categories.Count) + 0.5;
double upper = (((index + 1) * maximumLength) / Categories.Count) + 0.5;
placeTickMarkAt(lower);
OrientedPanel.SetCenterCoordinate(axisLabel, (lower + upper) / 2);
OrientedPanel.SetPriority(axisLabel, priority + 1);
OrientedPanel.Children.Add(axisLabel);
index++;
priority = (priority + 1) % 2;
}
placeTickMarkAt(maximumLength + 0.5);
}
}
finally
{
_labelPool.Done();
_majorTickMarkPool.Done();
}
}

Listing 2. Original Code for Adding Category Labels

The Panel that we have been discussing about is actually an OrientedPanel class. This is an internal class found in the same assembly as the chart controls. This Panel is specialized in that it tries to plot elements in a one-dimensional plane but minimizes collision of these elements according to some priority. You can see this at work in Figure 1, where the category labels are plotted in 2 lines. Since the OrientedPanel is internal, we can’t use or inherit from it so we won’t be able to wrap it inside a ScrollViewer. The AxisGrid containing this OrientedPanel, although referenced in one of the ancestors of ScrollableCategoryAxis, is not accessible in our code by default. We have to override the OnApplyTemplate method to get a reference to the Grid.

/// <summary>
/// Retrieves template parts and configures layout.
/// </summary>
public override void OnApplyTemplate()
{
base.OnApplyTemplate();

axisGrid = GetTemplateChild(AxisGridName) as Grid;
if (axisGrid != null)
{
axisGrid.SetValue(Grid.IsSharedSizeScopeProperty, true);

categoryLabelsGrid = new Grid();

axisScrollViewer = new ScrollViewer();
axisScrollViewer.Content = categoryLabelsGrid;
axisScrollViewer.VerticalScrollBarVisibility = ScrollBarVisibility.Hidden;
axisScrollViewer.HorizontalScrollBarVisibility = ScrollBarVisibility.Auto;

axisGrid.Children.Add(axisScrollViewer);
}
}

Listing 3. Adding a ScrollViewer

I decided to use a normal Grid to display the category labels. This Grid will be the content of the ScrollViewer, which will then be added to the AxisGrid. We set the IsSharedSizeScope property of the AxisGrid to true because I intend to place category labels in different grid columns having the same width. Next, we have to override the Render method, which is responsible for adding the category labels. Note that in the CategoryAxis’ RenderOriented method that you saw in Listing 2 is actually called by the Render method.

/// <summary>
/// Renders the axis labels, tick marks, and other visual elements.
/// </summary>
/// <param name="availableSize">The available size.</param>
protected override void Render(Size availableSize)
{
if (categoryLabelsGrid != null)
{
categoryLabelsGrid.Children.Clear();
categoryLabelsGrid.ColumnDefinitions.Clear();

foreach (object category in Categories)
{
ColumnDefinition columnDefinition = new ColumnDefinition();
columnDefinition.SharedSizeGroup = "Group";
categoryLabelsGrid.ColumnDefinitions.Add(columnDefinition);

Label categoryLabel = new Label();
categoryLabel.Content = category;
categoryLabel.HorizontalAlignment = HorizontalAlignment.Center;
categoryLabel.SetValue(Grid.ColumnProperty, Categories.IndexOf(category));

categoryLabelsGrid.Children.Add(categoryLabel);
}
}
}

Listing 4. Adding Category Labels

I ignored putting tick marks so that the Render method code is easier to understand. You can see that there is a column for every category. Since all ColumnDefinitions has the same SharedSizeGroup value, all columns will have the same width. This also means that the longest category label will dictate the width of all columns. Notice that we won’t need to use the availableSize parameter with this kind of implementation.

Custom Series

To use the ScrollableCategoryAxis, we have to create a class that inherits from the ColumnSeries class. The ColumnSeries class has a GetAxes method that uses a CategoryAxis instance as its horizontal axis. So we have to override this method so that an instance of ScrollableCategoryAxis is returned instead.

public class ScrollableColumnSeries : ColumnSeries
{
/// <summary>
/// Acquire a horizontal category axis and a vertical linear axis.
/// </summary>
/// <param name="firstDataPoint">The first data point.</param>
protected override void GetAxes(DataPoint firstDataPoint)
{
GetAxes(
firstDataPoint,
(axis) => axis.Orientation == AxisOrientation.X,
() =>
{
ScrollableCategoryAxis categoryAxis = new ScrollableCategoryAxis { Orientation = AxisOrientation.X };
return categoryAxis;
},
(axis) =>
{
IRangeAxis rangeAxis = axis as IRangeAxis;
return rangeAxis != null && rangeAxis.Origin != null && axis.Orientation == AxisOrientation.Y;
},
() =>
{
IRangeAxis rangeAxis = CreateRangeAxisFromData(firstDataPoint.DependentValue);
rangeAxis.Orientation = AxisOrientation.Y;
if (rangeAxis == null || rangeAxis.Origin == null)
{
throw new InvalidOperationException("No suitable axis is available for plotting the dependent value.");
}
DisplayAxis axis = rangeAxis as DisplayAxis;
if (axis != null)
{
axis.ShowGridLines = true;
}
return rangeAxis;
});
}
}

Listing 5. Custom ColumnSeries

The following XAML shows how to use the series in an actual chart. This is the same XAML for the window in Figure 1, except that I used ScrollableColumnSeries instead of ColumnSeries.

<Window x:Class="WPFToolkitChartScrollDemo.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:WPFToolkitChartScrollControls;assembly=WPFToolkitChartScrollControls"
xmlns:charting="clr-namespace:System.Windows.Controls.DataVisualization.Charting;assembly=System.Windows.Controls.DataVisualization.Toolkit"
Title="MainWindow" Height="400" Width="500">
<Grid>
<charting:Chart>
<charting:Chart.Series>
<controls:ScrollableColumnSeries
IndependentValueBinding="{Binding Name}"
DependentValueBinding="{Binding Quantity}"
ItemsSource="{Binding RelativeSource={RelativeSource FindAncestor, AncestorType=Window}, Path=Parts}"/>
</charting:Chart.Series>
</charting:Chart>
</Grid>
</Window>

Listing 6. Using ScrollableColumnSeries in a Chart

When we run the application, the following figure is shown.



Figure 3. ScrollableCategoryAxis in Action

Now, we can scroll over the category labels. But now, the data points are missing. So what happened here? The DataPointSeries class is an ancestor of our ScrollableColumnSeries class. It has an abstract method called UpdateDataPoint. This method is called when plotting a data point. The ColumnSeries class provides an implementation of the UpdateDataPoint method. To get the range where the data point can be plotted, it calls the following method of the ColumnBarBaseSeries class.

/// <summary>
/// Gets a range in which to render a data point.
/// </summary>
/// <param name="category">The category to retrieve the range for.
/// </param>
/// <returns>The range in which to render a data point.</returns>
protected Range<UnitValue> GetCategoryRange(object category)
{
ICategoryAxis categoryAxis = ActualIndependentAxis as CategoryAxis;
if (categoryAxis != null)
{
return categoryAxis.GetPlotAreaCoordinateRange(category);
}
else
{
UnitValue unitValue = ActualIndependentAxis.GetPlotAreaCoordinate(category);
if (ValueHelper.CanGraph(unitValue.Value) && _dataPointlength.HasValue)
{
double halfLength = _dataPointlength.Value / 2.0;

return new Range<UnitValue>(
new UnitValue(unitValue.Value - halfLength, unitValue.Unit),
new UnitValue(unitValue.Value + halfLength, unitValue.Unit));
}

return new Range<UnitValue>();
}
}

Listing 7. GetCategoryRange Method of ColumnBarBaseSeries

Notice here that the ActualIndependentAxis property is converted to the type CategoryAxis, before it is assigned to a variable of type ICategoryAxis. Since our ScrollableCategoryAxis class does not inherit from CategoryAxis, its GetPlotAreaCoordinateRange method is not called even though it implements the ICategoryAxis interface. To work around this, we can override the UpdateDataPoint method in the ScrollableColumnSeries, and call the ScrollableCategoryAxis’ GetPlotAreaCoordinateRange method. The following listing shows the updated ScrollableColumnSeries code.

public class ScrollableColumnSeries : ColumnSeries
{
private Panel plotArea;

/// <summary>
/// Get the plot area.
/// </summary>
public override void OnApplyTemplate()
{
base.OnApplyTemplate();

plotArea = GetTemplateChild(DataPointSeries.PlotAreaName) as Panel;
}

/// <summary>
/// Acquire a horizontal category axis and a vertical linear axis.
/// </summary>
/// <param name="firstDataPoint">The first data point.</param>
protected override void GetAxes(DataPoint firstDataPoint)
{
// Omitted for readability
// ...
}

/// <summary>
/// Updates each point.
/// </summary>
/// <param name="dataPoint">The data point to update.</param>
protected override void UpdateDataPoint(DataPoint dataPoint)
{
if (SeriesHost == null || plotArea == null)
{
return;
}

object category = dataPoint.ActualIndependentValue ?? (this.ActiveDataPoints.IndexOf(dataPoint) + 1);
ICategoryAxis categoryAxis = ActualIndependentAxis as ScrollableCategoryAxis;
Range<UnitValue> coordinateRange = categoryAxis.GetPlotAreaCoordinateRange(category);

// Omitted for readability
// ...
}
}

Listing 8. Workaround for Incorrect Data Point Ranges

I copied the UpdateDataPoint implementation in the ColumnSeries class and made a couple of changes to make things work. You can see that instead of calling the GetCategoryRange shown in Listing 7, I directly converted the ActualIndependentAxis to ScrollableCategoryAxis. This time, it will not return null and the GetPlotAreaCoordinateRange will be called.



Figure 4. Data Points Get Plotted

Now, data points are plotted the same way as we see in Figure 1. The next thing to do is to plot each data point according to its category’s position in the axis.

Plotting Data Points

To plot each data point correctly, we have to modify the GetPlotAreaCoordinateRange method of the ScrollableCategoryAxis class. The following code listing shows the unmodified GetPlotAreaCoordinateRange method.

/// <summary>
/// Returns range of coordinates for a given category.
/// </summary>
/// <param name="category">The category to return the range for.</param>
/// <returns>The range of coordinates corresponding to the category.
/// </returns>
public Range<UnitValue> GetPlotAreaCoordinateRange(object category)
{
// Omitted for readability
// ...

if (Orientation == AxisOrientation.X || Orientation == AxisOrientation.Y)
{
double maximumLength = Math.Max(ActualLength - 1, 0);
double lower = (index * maximumLength) / Categories.Count;
double upper = ((index + 1) * maximumLength) / Categories.Count;

if (Orientation == AxisOrientation.X)
{
return new Range<UnitValue>(new UnitValue(lower, Unit.Pixels), new UnitValue(upper, Unit.Pixels));
}
else if (Orientation == AxisOrientation.Y)
{
return new Range<UnitValue>(new UnitValue(maximumLength - upper, Unit.Pixels), new UnitValue(maximumLength - lower, Unit.Pixels));
}
}
else
{
// Omitted for readability
// ...
}

return new Range<UnitValue>();
}

Listing 9. ScrollableCategoryAxis’ GetPlotAreaCoordinateRange Method

You can see in the preceding code listing a variable called maximumLength. This variable indicates the width or height of the AxisGrid. The length is then divided into the number of categories. In our scenario, this would be incorrect since the Grid containing the category labels is longer than the AxisGrid. Thus, we should use the ActualWidth or ActualHeight properties of the categoryLabelsGrid instead of the AxisGrid. We just need to add the following property.

/// <summary>
/// Gets the actual length.
/// </summary>
protected new double ActualLength
{
get
{
if (categoryLabelsGrid != null)
{
return GetLength(new Size(categoryLabelsGrid.ActualWidth, categoryLabelsGrid.ActualHeight));
}

return base.ActualLength;
}
}

Listing 10. Return the Correct Axis Length

Let’s run the application again and check if the data points are plotted correctly.



Figure 5. Correct Axis Length

It looks better now but resizing the window gives us the following result.

Figure 6. Positions and Widths Gone Wrong

This time, the positions of the data points are incorrect. Also, the widths of the data points seem slightly smaller when compared to those in Figure 5. After some debugging, I found out that the value of the ActualWidth property of the categoryLabelsGrid changes when the window is resized. I don’t know why exactly but I believe it has something to do with WPF’s layout system. Anyway, a simple fix is to store the actual size of the Grid when it is loaded and use that size in the ActualLength property we defined earlier.

/// <summary>
/// Stores the size of the Grid containing the category labels.
/// </summary>
/// <param name="sender">Grid.</param>
/// <param name="e">Ignored.</param>
private void CategoryLabelsGrid_Loaded(object sender, RoutedEventArgs e)
{
gridSize = new Size(categoryLabelsGrid.ActualWidth, categoryLabelsGrid.ActualHeight);

// Force redraw
Invalidate();
}

/// <summary>
/// Gets the actual length.
/// </summary>
protected new double ActualLength
{
get
{
if (gridSize != null)
{
return GetLength(gridSize);
}

return base.ActualLength;
}
}

Listing 11. Fix for Incorrect Positions and Widths

Last thing to do is to handle the ScrollChanged event of the ScrollViewer to display the data points that should be visible.

Handling Scrolling

Our problem with scrolling is that the ScrollViewer is defined in the ScrollableCategoryAxis class while the UpdateDataPoints method can be called in the ScrollableColumnSeries class. I thought of a simple solution: define an event in ScrollableCategoryAxis that will be raised when the ScrollViewer has been scrolled.

/// <summary>
/// Triggers a ScrollChanged event.
/// </summary>
/// <param name="sender">ScrollViewer.</param>
/// <param name="e">Ignored.</param>
private void AxisScrollViewer_ScrollChanged(object sender, ScrollChangedEventArgs e)
{
EventHandler eh = ScrollChanged;
if (eh != null)
{
eh(this, new EventArgs());
}
}

Listing 12. Notify ScrollableColumnSeries of Change in ScrollViewer

Then, in the GetAxes method of ScrollableColumnSeries, we can hook up the event handler to the ScrollChanged event of the ScrollableCategoryAxis instance. In the event handler, we can call the UpdateDataPoints method.

/// <summary>
/// Updates the data points when the scrollbar has been moved.
/// </summary>
/// <param name="sender">ScrollableCategoryAxis object.</param>
/// <param name="e">Ignored.</param>
private void ScrollableCategoryAxis_ScrollChanged(object sender, EventArgs e)
{
UpdateDataPoints(ActiveDataPoints);
}

Listing 13. ScrollChanged Event Handler

We then need to update the GetPlotAreaCoordinateRange method of the ScrollableCategoryAxis to take into consideration the ScrollViewer’s horizontal or vertical offset.

double offset = 0.0;
if (axisScrollViewer != null)
{
offset = Orientation == AxisOrientation.X ? axisScrollViewer.HorizontalOffset :
axisScrollViewer.VerticalOffset;
}

double maximumLength = Math.Max(ActualLength - 1, 0);
double lower = (index * maximumLength) / Categories.Count - offset;
double upper = ((index + 1) * maximumLength) / Categories.Count - offset;

Listing 14. ScrollChanged Event Handler

Let’s run again the application. You can see that it works as expected.



Figure 7. A Scrollable Column Chart

You can download the Visual Studio 2008 solution here.

By Michael Detras   Popularity  (9294 Views)