Adding a Location Crosshair to Silverlight Charts

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.

MORE BY COLIN

blog comments powered by Disqus