A Navigator Control For Visiblox Time Series Charts

In this blog post I will describe the creation of a simple range selector UserControl, which can be used alongside a Visiblox chart to create an interactive navigator for time series data.

Whether you are studying finance, politics, meteorology or sociology you are sure to encounter time series data. Time series are everywhere! And until the universe starts to collapse in on itself and the arrow of time reverses, these time series are going to keep on growing in size. When charting and exploring large time series datasets, it can help to have a navigator control - a small chart showing the entire dataset at a lower resolution, with controls that allow the user to select a range to view in detail.

This blog post describes how to create a simple navigator control which allows the user to select and drag a time range, as shown below:

Get Microsoft Silverlight

The data in the above chart comes from the Time Series Data Library collected and published online by Rob Hyndman.

The Range Control

The markup for the range selector user control is a Grid divided into three columns. The left and right hand column each contain a Border with a blue fill which shades the sections which are outside of the selected range. These cells also each contain a Thumb control, this is the element that the user interacts with. The central column contains a thumb that occupies the entire cell, however, its opacity is set to zero so that it is not visible:

<UserControl x:Class="VisibloxRangeControl.DateTimeRangeControl"
    ... >

  <Grid x:Name="LayoutRoot">
    <Grid.ColumnDefinitions>
      <ColumnDefinition Width="400"/>
      <ColumnDefinition />
      <ColumnDefinition Width="50"/>
    </Grid.ColumnDefinitions>

    <Thumb Grid.Column="1"
           Opacity="0"
           Cursor="Hand"
           DragDelta="CentreThumb_DragDelta"
           DragCompleted="Thumb_DragCompleted"/>

    <Border Background="Blue"
            BorderBrush="Black"
            BorderThickness="0,0,1,0"
            Opacity="0.3"/>
    <Thumb x:Name="LeftThumb"
           Width="10" Height="20"
           Margin="0,0,-5,0"
           VerticalAlignment="Center" HorizontalAlignment="Right"
           Cursor="SizeWE"
           DragDelta="LeftThumb_DragDelta"
           DragCompleted="Thumb_DragCompleted"/>

    <Border Background="Blue"
            BorderThickness="1,0,0,0"
            BorderBrush="Black"
            Opacity="0.3"
            Grid.Column="2"/>
    <Thumb x:Name="RightThumb"
           Grid.Column="2"
           Width="10" Height="20"
           Cursor="SizeWE"
           Margin="-5,0,0,0"
           VerticalAlignment="Center" HorizontalAlignment="Left"
           DragDelta="RightThumb_DragDelta"
           DragCompleted="Thumb_DragCompleted"/>
  </Grid>
</UserControl>

Event handlers are added to the DragDelta and DragCompleted event of each Thumb control. The Thumb control is a bit of an odd one, you might expect that it moves itself as the user clicks and drags it, however, this is not the case. When the user clicks and drags the Thumb, it fires DragDelta events as the mouse moves, however, it is your responsibility to move the Thumb to the updated location to reflect this drag operation.

This might sound odd at first, however, there are many in which a control can be moved, you can set its Canvas location, update its Margin, apply a RenderTransform, to name just a few methods. In the case of the range control described in the XAML above, the Thumb location is dictated by the width of the column that contains it. Therefore, when DragDelta events are fired, we need to update these widths on code-behind, as shown below:

/// <summary>
/// Handles dragging of the left hand thumb control
/// </summary>
private void LeftThumb_DragDelta(object sender, DragDeltaEventArgs e)
{
  // obtain the column width and apply an offset
  var columnDef = LayoutRoot.ColumnDefinitions[0];
  var width = columnDef.Width.Value;
  width += e.HorizontalChange;

  // prevent the user from dragging the thumb outside of the control
  if (width < 0)
  {
    width = 0;
  }

  // prevent the overlap of the left + right regions
  if (width + LayoutRoot.ColumnDefinitions[2].Width.Value + 20 > this.ActualWidth)
  {
    width = columnDef.Width.Value;
  }

  // update the column width
  columnDef.Width = new GridLength(width);
}

private void Thumb_DragCompleted(object sender, DragCompletedEventArgs e)
{
    UpdateExposedBounds();
}

The event handler for the right hand thumb is very similar to the above. The handler for the centre thumb which allows you to drag a region of fixed time is a little more complex, updating the widths of both left and right hand columns, however, the principle is very much the same.

In order to use this control as an interactive range selector, we need to be able to specify the DateTime range it represents and also the control needs to expose the DateTime range for the current selection. To achieve this purpose, the control exposes a Range dependency property of type DateTimeRange (from the Visiblox APIs), which is used to specify the overall date range. The DragCompleted event handler above calls UpdateExposedBounds() which updates a Bounds dependency property to reflect the selected range:

private void UpdateExposedBounds()
{
  if (Range == null)
    return;

  double deltaMinutes = (Range.Maximum - Range.Minimum).TotalMinutes;
  double width = this.ActualWidth;

  double leftThumbPos = LayoutRoot.ColumnDefinitions[0].Width.Value;
  double rightThumbPos = LayoutRoot.ColumnDefinitions[2].Width.Value;

  DateTime upper = Range.Maximum.AddMinutes(-deltaMinutes * (rightThumbPos / width));
  DateTime lower = Range.Minimum.AddMinutes(deltaMinutes * (leftThumbPos / width));
  Bounds = new DateTimeRange(lower, upper);
}

The above code uses a simple bit of algebra to compute the Bounds as a proportion of the overall exposed Range.

Using this range control to create a 'navigator' chart and update a 'detail' is as simple as binding the range control's Range property to the XAxis.ActualRange property of the navigator chart, and its Bounds property to the XAxis.Range property of the detail chart.

This can all be achieved via UI bindings as shown below:

<Grid>
  <Grid.RowDefinitions>
    <RowDefinition Height="2*" />
    <RowDefinition Height="*"/>
  </Grid.RowDefinitions>

  <!-- the 'detail' chart -->
  <vis:Chart LegendVisibility="Collapsed"
             x:Name="detailChart">
    <vis:Chart.YAxis>
      <vis:LinearAxis LabelsPosition="Inside"
                      AutoScaleToVisibleData="True"/>
    </vis:Chart.YAxis>
    <vis:Chart.XAxis>
      <vis:DateTimeAxis Range="{Binding ElementName=rangeControl, Path=Bounds}"/>
    </vis:Chart.XAxis>
  </vis:Chart>

  <!-- the navigator chart -->
  <vis:Chart Grid.Row="1"
             LegendVisibility="Collapsed"
             x:Name="chartNavigator">
    <vis:Chart.XAxis>
      <vis:DateTimeAxis />
    </vis:Chart.XAxis>
  </vis:Chart>

  <local:DateTimeRangeControl x:Name="rangeControl"
           Grid.Row="1"
           Range="{Binding ElementName=chartNavigator, Path=XAxis.ActualRange}"/>
</Grid>

You can see the above code in action:

Get Microsoft Silverlight

Performance Considerations

With the code above, the range control Bounds property is bound to the X axis range of the upper chart. Each time this range is changed the chart has to perform quite a bit of work, computing the new Y-axis range, re-drawing the series etc... For this reason, the implementation only updates the Bounds property when the user finishes adjusting the range. It would be better if the chart could update whilst the user drags the navigator range. In order to do this, we need a more lightweight method of updating the upper chart.

The Visiblox axes expose a Zoom property which can be used to supply a Scale / Offset which rapidly updates the chart. In order to use the Zoom property we need to convert the Bounds exposed by the range control into a suitable zoom. This is easiest done in code-behind.

The following code handles property changed events from the range control:

private void RangeControl_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
  if (rangeControl.Bounds == null)
    return;

  DateTimeAxis xAxis = detailChart.XAxis as DateTimeAxis;

  double lower = xAxis.GetDataValueAsRenderPositionWithZoom(rangeControl.Bounds.Minimum);
  double upper = xAxis.GetDataValueAsRenderPositionWithZoom(rangeControl.Bounds.Maximum);
  var zoom = xAxis.GetZoom(lower, upper);
  xAxis.Zoom = zoom;
}

The axis exposes a GetZoom method which can be used to create a zoom which will cause the axis to display the data within the given range (in pixels). However, the range control exposes a Bounds which is in the data coordinate system (i.e. dates) rather than the screen coordinate system. Therefore, we first need to apply the GetDataValueAsRenderPositionWithZoom coordinate system conversion method to the upper and lower bound.

With the above method, the chart now updates much more rapidly and we are able to update as the user drags the navigator range:

Get Microsoft Silverlight

The Example Data

The data in the examples shows the daily maximum and minimum temperatures in Melbourne from 1981 to 1990. The data was supplied as two separate files, one with maximum temperatures, and the other with minimum temperatures.

These are parsed into a Visiblox DataSeries using the following code:

var assembly = this.GetType().Assembly;
var dataSeries = new DataSeries<DateTime, double>();
var date = new DateTime(1981,1,1);
var maxStream = assembly.GetManifestResourceStream("VisibloxRangeControl.melbmax.dat");
var minStream = assembly.GetManifestResourceStream("VisibloxRangeControl.melbmin.dat");
using (StreamReader minReader = new StreamReader(minStream))
using (StreamReader maxReader = new StreamReader(maxStream))
{
  while (minReader.Peek() > 0)
  {
    string minLine = minReader.ReadLine();
    string maxLine = maxReader.ReadLine();
    var yValues = new Dictionary<object, double>() {
      {BandSeries.Upper, double.Parse(maxLine)},
      {BandSeries.Lower, double.Parse(minLine)}
    };
    dataSeries.Add(new MultiValuedDataPoint<DateTime, double>(date, yValues));
    date = date.AddDays(1);
  }
}

detailChart.Series[0].DataSeries = dataSeries;

The detail chart uses this data directly, rendering the max / min values as a band series:

<vis:Chart.Series>
  <vis:BandSeries UpperLineStroke="Blue"
                  LowerLineStroke="Blue"
                  ShowArea="True">
    <vis:BandSeries.AreaFill>
      <SolidColorBrush Color="Blue"
                        Opacity="0.3"/>
    </vis:BandSeries.AreaFill>
  </vis:BandSeries>
</vis:Chart.Series>

Because there are ~3,500 datapoints in this dataset it does not make much sense for the navigator chart to render every point. The following Linq query groups the datapoints by month, then extracts the average upper temperature for each month. This data is then supplied to the lower chart:

var monthlyAverage = dataSeries.GroupBy(pt => new DateTime(pt.X.Year, pt.X.Month, 1))
                            .Select(group => new DataPoint<DateTime, double>
                            {
                              X = group.Key,
                              Y = group.Select(pt => (double)pt[BandSeries.Upper]).Average()
                            });

chartNavigator.Series[0].DataSeries = new DataSeries<DateTime, double>(monthlyAverage);

I will never grow tired of the power of Linq!

Final thoughts

The range selector control has been implemented as a UserControl, it could be made more generic by implementing it as a custom control, which would allow it to be templated. Also, it should be possible to make the Range and Bound properties use double rather than DateTime, then use a value converter in the binding to the chart. This would allow range selector to be used in context where the range being selected is not a DateTime one. I'll leave this as an exercise for the reader!

You can download the full sourcecode here: VisibloxRangeSelector.zip

You will also need to download the free Visiblox charts to compile the code.

Regards, Colin E.

MORE BY COLIN

blog comments powered by Disqus