Colin Eberhardt's Adventures in WPF

Adding a Location Crosshair to Silverlight charts (again!)

March 23rd, 2009

Silverlight is moving fast. Really fast. The recent MIX09 conference saw the release of Silverlight 3 (Beta) and also a new release of the Silverlight Toolkit. All this change is making it hard for us bloggers to keep up!

Just over a month ago I posted an article on this blog about how to add a location crosshair to the Silverlight Toolkit Charts. This blog post briefly discusses how the charts have changed with the release last week, and updates the code accordingly.

If you want to know how the general approach works, I would recommend reading the previous blog post, this post serves as an update and discussion on how the chart template has changed.

There have been a number of minor changes to the charts that affect my previous implementation, including a change of namespace and orientation type on the chart axes. However, the biggest change is in the template of the chart control itself. Previously the chart template was structured via a Grid as shown below:

<ControlTemplate TargetType="charting:Chart">
    <Grid Name="ChartArea" Style="{TemplateBinding ChartAreaStyle}">
 
        <!-- ##### chart control adds column / row definitions and axes here #### -->
 
        <Grid Height="250" x:Name="PlotArea" Style="{TemplateBinding PlotAreaStyle}">
 
            <!-- the standard chart template components -->
            <Grid x:Name="GridLinesContainer" />
            <Grid x:Name="SeriesContainer"/>
            <Border BorderBrush="#FF919191" BorderThickness="1" />
 
            <!-- ##### I added cross hair and legend here #### -->
        </Grid>
    </Grid>
</ControlTemplate>

After loading the template the chart adds the axes and the appropriate column / row definitions. This leaves us free to add new visual content withing the ‘PlotArea’ grid. Inspecting the visual tree, with Silverlight Spy, it look like the following:

visualtree-before

The content I added in order to construct a crosshair and legend are highlighted in red.

The March release of the Silverlight toolkit modifies the way in which axes are added to the chart by the introduction of a new layout panel, the EdgePanel (think DockPanel!). This panel allows you to locate (or dock) elements at one of the four edges of a panel or the panel centre. The modified control template is show below:

<ControlTemplate TargetType="charting:Chart" x:Key="ChartTemplate">
    <Grid x:Name="ChartRoot" Style="{TemplateBinding PlotAreaStyle}">
 
        <chartingprimitives:EdgePanel x:Name="ChartArea" Style="{TemplateBinding ChartAreaStyle}">                                   
 
            <Grid Canvas.ZIndex="1" Style="{TemplateBinding PlotAreaStyle}" />
            <Border Canvas.ZIndex="1" BorderBrush="#FF919191" BorderThickness="1" />
 
            <!-- ##### I added cross hair and legend here #### -->
 
            <!-- ##### chart control adds column / row definitions and axes here #### -->
 
        </chartingprimitives:EdgePanel>
    </Grid>
</ControlTemplate>

I have indicated the location where I add my own visual content and also the location where the chart control adds the axes. The resulting visual tree is shown below:

visualtree-after

The significant difference here is that all the components of our chart are contained within the same EdgePanel, with their location dictated by their EdgePanel.Edge property. One problem this does introduce for us is that of Z-ordering, previously our additional content was top of the stack, now it sits somewhere in between. Fortunately EdgePanel honours the Canvas.ZIndex attached property allowing us to push our content to the top. The modified template is given below in full:

<ControlTemplate TargetType="charting:Chart" x:Key="ChartTemplate">
    <Grid x:Name="ChartRoot" Style="{TemplateBinding PlotAreaStyle}">
 
        <chartingprimitives:EdgePanel x:Name="ChartArea" Style="{TemplateBinding ChartAreaStyle}">                                   
 
            <Grid Canvas.ZIndex="-1" Style="{TemplateBinding PlotAreaStyle}" />
            <Border Canvas.ZIndex="3" BorderBrush="#FF919191" BorderThickness="1" />
 
            <!-- a location crosshair -->
            <Grid Name="CrosshairContainer" Canvas.ZIndex="1" Background="Transparent"
                 MouseMove="CrosshairContainer_MouseMove" MouseEnter="CrosshairContainer_MouseEnter"
                 MouseLeave="CrosshairContainer_MouseLeave" >
                <Grid Name="Crosshair">
                    <Line Name="Vertical" X1="{Binding Path=X}" Y1="0" X2="{Binding Path=X}" Y2="400" Stroke="Black"/>
                    <Line Name="Horizontal" X1="0" Y1="{Binding Path=Y}" X2="400" Y2="{Binding Path=Y}" Stroke="Black"/>
                </Grid>
            </Grid>
 
            <!-- a location 'legend' -->
            <Border Canvas.ZIndex="2" Name="LocationIndicator" Visibility="Collapsed" Style="{StaticResource LocationLegendStyle}">
                <StackPanel Orientation="Horizontal" Margin="5">
                    <TextBlock Text="Location: "/>
                    <TextBlock Text="{Binding Path=Key,
                                Converter={StaticResource FormattingConverter}, ConverterParameter=hh:mm:ss}"/>
                    <TextBlock Text=", "/>
                    <TextBlock Text="{Binding Path=Value,
                                Converter={StaticResource FormattingConverter}, ConverterParameter=0.00}"/>
                </StackPanel>
            </Border>
 
        </chartingprimitives:EdgePanel>
    </Grid>
</ControlTemplate>

One more subtle point, the event handlers for MouseMove, MouseLeave etc.. when associated with a Grid only work if the background is not null, hence the need for a transparent background. I have no idea why, believe me … it just works!

One final important change is the method used to translate points from screen coordinates into a position within the chart coordinate system. Previously I used a method called GetPlotAreaCoordinateValueRange on the ‘hidden IRangeAxis interface. This has now been renamed to GetValueAtPosition and returns and takes a UnitValue type as its input (presumably for API consistency with the pie charts). Here is the code:

/// <summary>
/// Transforms the supplied position on the plot area grid into a point within
/// the plot area coordinate system
/// </summary>
private KeyValuePair<DateTime, double> GetPlotAreaCoordinates(Point position)
{
    IComparable yAxisHit = ((IRangeAxis)YAxis).GetValueAtPosition(
        new UnitValue(PlotArea.ActualHeight - position.Y, Unit.Pixels));
 
    IComparable xAxisHit = ((IRangeAxis)XAxis).GetValueAtPosition(
        new UnitValue(position.X, Unit.Pixels));
 
    return new KeyValuePair<DateTime, double>((DateTime)xAxisHit, (double)yAxisHit);
}

With these changes in place, our crosshair is fully functional once more:

… until the next release of the toolkit ;-)

You can download the project sourcecode: sllinechartcrosshairupdated.zip.

Regards, Colin E.

Adding a Location Crosshair to Silverlight Charts

February 4th, 2009
UPDATE – The March09 update of the Silverlight toolkit is incompatible with the code detailed below. For an up-to-date version see the following blog post.

This blog post describes how to add a location crosshair to your Silverlight charts as shown below:

The chart itself is rendered using the charting component of the recently release Silverlight Toolkit. Creating and displaying a simple line chart is as simple as referencing the correct silverlight toolkit namespaces and placing a chart with an associated lineseries into the XAML for your page:

<charting:Chart Name="Chart" PlotAreaStyle="{StaticResource PlotAreaStyle}">
    <charting:LineSeries
		IndependentValueBinding="{Binding Path=Key}"
		DependentValueBinding="{Binding Path=Value}"/>
</charting:Chart>

In this example, the data is provided in the form of an XML file as shown in the snippet below:

<?xml version="1.0" encoding="UTF-8"?>
<history generated="2009/02/03 10:51:02 GMT+0000">
  <dataPoints>
    <dataPoint date="2009/02/03 08:01:00 GMT+0000" change="21.61" changePercent="0.36" value="6073.99"/>
    <dataPoint date="2009/02/03 08:02:45 GMT+0000" change="16.11" changePercent="0.27" value="6068.49"/>
    ...
    <dataPoint date="2009/02/03 10:50:45 GMT+0000" change="0.4" changePercent="0.01" value="6052.78"/>
  </dataPoints>
</history>

This data shows the performance of the FTSE 100 index on a relatively uneventful morning. The XML file is read using LINQ to XML into a collection of KeyValuePairs. The LineSeries’ ItemsSource is set to the result of this query, with the bindings in the above XAML binding the Key and Value properties of each KeyValuePair instance.

protected LineSeries LineSeries
{
    get
    {
        return ((LineSeries)Chart.Series[0]);
    }
}
 
public Page()
{
    InitializeComponent();
 
    XDocument doc = XDocument.Load("chartData.xml");
    var elements = from dataPoint in doc.Descendants("dataPoint")                           
                   select new KeyValuePair<DateTime, double>
                   (
                       DateTime.Parse(dataPoint.Attribute("date").Value.Substring(0, 19)),
                       Double.Parse(dataPoint.Attribute("value").Value)
                   );
 
    LineSeries.ItemsSource = elements;
}

This results in the following (rather ugly!) chart:

ftse100

Whilst the background fade effect and line series datapoints can be modified by styling the chart, the removal of unwanted elements such as the legend require us to delve into the the Chart’s control template. The modified control template is given below:

<charting:Chart.Template>
    <ControlTemplate TargetType="charting:Chart">
        <Grid Name="ChartArea" Style="{TemplateBinding ChartAreaStyle}">
 
            <!-- NOTE: the chart legend and title have been removed -->
            <Grid Height="250" x:Name="PlotArea" Style="{TemplateBinding PlotAreaStyle}"
                  MouseMove="PlotArea_MouseMove" MouseEnter="PlotArea_MouseEnter" MouseLeave="PlotArea_MouseLeave">
 
                <!-- the standard chart template components -->
                <Grid x:Name="GridLinesContainer" />
                <Grid x:Name="SeriesContainer"/>
                <Border BorderBrush="#FF919191" BorderThickness="1" />
 
                <!-- a location crosshair -->
                <Grid Name="Crosshair" Visibility="Collapsed">
                    <Line Name="Vertical" X1="{Binding Path=X}" Y1="0" X2="{Binding Path=X}" Y2="250" Stroke="Black"/>
                    <Line Name="Horizontal" X1="0" Y1="{Binding Path=Y}" X2="400" Y2="{Binding Path=Y}" Stroke="Black"/>
                </Grid>
 
                <!-- a location 'legend' -->
                <Border Name="LocationIndicator" Visibility="Collapsed" Style="{StaticResource LocationLegendStyle}">
                    <StackPanel Orientation="Horizontal" Margin="5">
                        <TextBlock Text="Location: "/>
                        <TextBlock Text="{Binding Path=Key}"/>
                        <TextBlock Text=", "/>
                        <TextBlock Text="{Binding Path=Value}"/>
                    </StackPanel>
                </Border>
            </Grid>
        </Grid>
    </ControlTemplate>
</charting:Chart.Template>

In the above template you can see a couple of Grids, one named GridLinesContainer, and the other names SeriesContainer. When constructing the chart, the chart control will navigate the visual tree constructed from its control template to find elements of these names. It will then add the grid lines and series to these containers accordingly. This gives us the freedom to modify the chart’s control template whilst still allowing it to function normally. In the above template I have added two new elements, a cross hair and a location indicator. I have also added handlers for a few of the mouse events on the plot area.

The mouse move event handler in the code behind finds the current mouse position within the PlotArea grid. Firstly, the coordinates of this point are found within the chart coordinate system (more on this later), following this, the DataContexts for the Crosshair and PlotArea are set to the mouse position and location within the chart coordinate system respectively. Looking in the above XAML we can see that the Crosshair contains a pair of lines which are bound to the X and Y properties of their DataContext, ensuring that the two lines intersect at the current mouse location. The LocationIndicator will inherit the PlotArea’s DataContext, allowing it to output the current location in the chart coordinate system.

private void PlotArea_MouseMove(object sender, MouseEventArgs e)
{
    if (LineSeries.ItemsSource == null)
        return;
 
    Point mousePos = e.GetPosition(PlotArea);
    KeyValuePair<DateTime, double> crosshairLocation = GetPlotAreaCoordinates(mousePos);
 
    PlotArea.DataContext = crosshairLocation;
    Crosshair.DataContext = mousePos;
    PlotArea.Cursor = Cursors.None;
}
 
protected Grid PlotArea
{
    get { return ChartArea.FindName("PlotArea") as Grid; }
}
 
protected Grid Crosshair
{
    get { return ChartArea.FindName("Crosshair") as Grid; }
}
 
protected Grid ChartArea
{
    get
    {
        // chart area is within a different namescope to this page, therefore
        // we must navigate the visual tree to locate it
        return VisualTreeHelper.GetChild(Chart, 0) as Grid;
    }
}

One thing worth noting in the above code is that way in which the PlotArea and Crosshair elements are located. Usually it is possible to simply name elements within your XAML using their Name attribute then refer to them directly in the code-behind or locate them via the DependencyObject.FindName method. However, FindName relies on the named element being in the same namescope. Control template are in a different namescope to the XAML page which they are defined within, therefore we have to navigate the visual tree to find the root element of the chart control, then invoke FindName from there. See the MSDN page on XAML namescopes for more details.

The GetPlotAreaCoordinates method is given below:

private KeyValuePair<DateTime, double> GetPlotAreaCoordinates(Point position)
{
    Range<IComparable> yAxisHit = ((IRangeAxis)YAxis).GetPlotAreaCoordinateValueRange(PlotArea.Height - position.Y);
    Range<IComparable> xAxisHit = ((IRangeAxis)XAxis).GetPlotAreaCoordinateValueRange(position.X);
 
    return new KeyValuePair<DateTime, double>((DateTime)xAxisHit.Minimum, (double)yAxisHit.Minimum);
}

The silverlight chart axes actually provides methods which can be used to translate from screen coordinates to chart coordinates via the IRangeAxis interface, however they are hidden by explicit interface implementation of these interface methods by the concrete axis classes. It is a shame that such useful methods are hidden! I have never really seen the value of explicit interface implementation.

In conclusion, this blog post has shown how simple it is to add a crosshair to your silverlight linecharts. What I personally find interesting is the way in which you can not only customise the appearance of Silverlight (and WPF) controls, but also add behaviour without the need to subclass the controls themselves. I think that this makes life easier for the users of Silverlight/WPF controls, in that they can use the same techniques to extend any control rather than relying on the control having built in extension points (it is often a source of frustration when developing with Windows Forms (or similar technologies) when you wish to customise some aspect of an existing control, however the control author has not provided events, or virtual methods for this purpose). It also of course makes life easier for the control developer in that they do not have to spend so much time thinking about how their control might be extended and adapted by its users.

You can download a visual studio project with the code from this blog post, sllinechartcrosshair.zip.

Regards, Colin E.