Wednesday, March 25, 2009

Writing Your Own Silverlight Chart Series (Part 2): Implementing the Series

This is the second part in a series of three that explains how you can add series to Silverlight Charts.   We'll look at how Silverlight Chart's rich hierarchy of interfaces allow developers to create series that can play well with all the built-in series and axes, as well as custom series and axes written by other parties.  Once again...

BewareSilverlight charts is still in preview mode and there may be breaking changes in the future despite our best efforts to avoid them.

Last time we learn how to structure our series in such a way that designers can customize its appearance.  Now we can get down to the business of implementing our stock series.  The StockSeries class is responsible for the following:

  • Acquiring a data point style
  • Adding a legend item
  • Binding data points to a data source
  • Acquiring axes
  • Plotting the Data Points

Acquiring a Data Point Style

When we plot multiple series in the same Chart we want each of them to have different colors so that we can tell them apart.

ChartingIntroduction-Mar09-LineSeriesOnCategoryAxis

To ensure that our StockSeries uses a different color than the other series in the chart it must retrieve the next available style from its series host.  A series host contains series and provides them with various services.  Currently the only object that implements the ISeriesHost interface in Silverlight Charts is the Chart object.  Let's take a look at the Chart hierarchy.

blogimage12

When a series is inserted into a Chart the SeriesHost property of the series is set to that Chart.  Conversely when a series is removed from a chart its SeriesHost property is set to null.  When the SeriesHost property changes a protected method OnSeriesHostPropertyChanged is invoked.  We can override this method in our StockSeries class if we want to do something when the series is added or removed from a Chart. 

We should acquire a style from our series host when...

1.  The series host changes

2.  The RefreshStyles method of the base "Series" class is called

public class StockSeries : Series, IRequireGlobalSeriesIndex { private Style DataPointStyle { get; set; } protected override void OnSeriesHostPropertyChanged(ISeriesHost oldValue, ISeriesHost newValue) { AcquireDataPointStyle(); base.OnSeriesHostPropertyChanged(oldValue, newValue); } public override void RefreshStyles() { AcquireDataPointStyle(); Refresh(); } private void AcquireDataPointStyle() { if (SeriesHost != null) { using (IEnumerator<Style> styleEnumerator = SeriesHost.GetStylesWithTargetType(typeof(StockDataPoint), true)) { if (styleEnumerator.MoveNext()) { DataPointStyle = styleEnumerator.Current; } } } } // snip... }

In the example above we're using the IStyleDispenser's extension method GetStylesWithTargetType (in System.Windows.Controls.DataVisualization) to retrieve an IEnumerator of styles appropriate for our data point type. 

newValue.GetStylesWithTargetType(typeof(StockDataPoint), true))

The "true" indicates that we are willing to accept styles with a target type that is an ancestor of our data point's type.  Due to the fact that all of the styles in the Chart's default style palette target Control - which StockDataPoint inherits from - we can be sure our series will get a style when we insert it into a Chart.

Adding a Legend Item

Now that we've acquired a style we can use for our data points the time is right to insert a legend item into the Legend.  We'll add a private property to store an instance of the LegendItem class and initialize it in the constructor.  In order to ensure that it is inserted into the Legend we'll add it to the series LegendItems collection (inherited from base class Series). UI elements inserted into the legend items collection of a series are automatically inserted into the legend by the series host.

public class StockSeries : Series { // snip... private LegendItem LegendItem { get; set; } public StockSeries() { this.DefaultStyleKey = typeof(StockSeries); this.LegendItem = new LegendItem(); this.LegendItems.Add(LegendItem); } }

A well-behaved series ensures the following:

  • If it does not have a title its legend item should display the series index
  • If it has a title the legend item should display it
  • Its legend item should have a visual appearance similar to its data points

Displaying the Series Index

If the series does not have a title it should display its series index in the LegendItem. Therefore we must determine what the index of our series is.  We can try and figure out our index by counting the series in our series host collection, but this approach is complicated because we would also have to monitor the series collection of our series host for any changes.

Thankfully we can let the series host do the work for us by implementing IRequireGlobalSeriesIndex.  This is our way of signaling to the series host that this series uses an index and needs to be informed when that index changes. In addition to implementing this interface we'll also add a property to store the global series index value.

public class StockSeries : Series, IRequireGlobalSeriesIndex { private int GlobalSeriesIndex { get; set; } private LegendItem LegendItem { get; set; } public void GlobalSeriesIndexChanged(int globalIndex) { GlobalSeriesIndex = globalIndex; UpdateLegendItem(); } private void UpdateLegendItem() { if (GlobalSeriesIndex != -1) { LegendItem.Content = string.Format("Series {0}", GlobalSeriesIndex + 1); } } // snip... }

Now if we add our series to a chart we should be able to see our legend item!

blogimage15

Displaying the Series Title in the Legend Item

If a series title is set it's good practice to display it in the legend item instead of the index.  We could set the content of our legend item to the series Title property when we create the legend item, but we also need to update our legend item if the title changes.  We can listen for changes to the title property by overriding the protected OnTitleChanged method.  Let's also modify our private UpdateLegendItem method to check if the Title property is set before resorting to using the series index.

public class StockSeries : Series, IRequireGlobalSeriesIndex { private void UpdateLegendItem() { if (Title != null) { LegendItem.Content = this.Title; } else if (GlobalSeriesIndex != -1) { LegendItem.Content = string.Format("Series {0}", GlobalSeriesIndex+1); } } protected override void OnTitleChanged(object oldValue, object newValue) { UpdateLegendItem(); base.OnTitleChanged(oldValue, newValue); } // snip... }

Now if we specify a custom series title we can see it in the Legend!

<charting:Chart> <local:StockSeries Title="My First Series" /> </charting:Chart>

my first series

Styling the Legend Item

Ideally we'd like our legend item to be the same color as our data points.  We can accomplish this by creating an instance of StockSeriesDataPoint, applying the data point style we retrieved from the series host, and setting the data point to be the data context of our legend item.  The best place to do this is in our AcquireDataPointStyle method.

private void AcquireDataPointStyle() { if (SeriesHost != null) { using (IEnumerator<Style> styleEnumerator = SeriesHost.GetStylesWithTargetType(typeof(StockDataPoint), true)) { if (styleEnumerator.MoveNext()) { DataPointStyle = styleEnumerator.Current; this.LegendItem.DataContext = new StockDataPoint { Style = DataPointStyle }; } } } }

Now we can see that the legend item has a color.

my first series in color

So how does this work?  The default template for the LegendItem binds to the background color and border brush properties of its DataContext.  This approach means that we can expose a LegendItemStyle property and give designers the ability to replace our legend item's template while still retaining certain aspects of the data point style (such as the color).  I'll leave that as an exercise for the reader.

Binding Data Points to a Data Source

We'd like to be able to bind our series directly to objects in our data source.  Let's add string properties for the DatePath, HighPath, LowPath, and ClosePath to our series.  We'll store the path's in binding objects which we'll use to bind the properties in our data points to the properties in our data source objects.  We expose string properties for our paths instead of bindings because strings are easier to work with in Blend.

private Binding dateBinding = new Binding(); public string DateBinding { get { return dateBinding.Path.Path; } set { dateBinding.Path = new PropertyPath(value); } } private Binding lowBinding = new Binding(); public string LowPath { get { return lowBinding.Path.Path; } set { lowBinding.Path = new PropertyPath(value); } } private Binding highBinding = new Binding(); public string HighPath { get { return highBinding.Path.Path; } set { highBinding.Path = new PropertyPath(value); } } private Binding closeBinding = new Binding(); public string ClosePath { get { return closeBinding.Path.Path; } set { closeBinding.Path = new PropertyPath(value); } }

Let's follow the ItemsControl model and add an ItemsSource dependency property of type IEnumerable.  When the ItemsSource property changes we'll create a data point for each object and add the data points to a dictionary using the object as the key.  We'll use a helper method called CreateStockDataPoint to create the data point, apply our data point style, and add the bindings to it.

private StockDataPoint CreateStockDataPoint(object value) { var stockDataPoint = new StockDataPoint { DataContext = value }; stockDataPoint.Style = DataPointStyle; stockDataPoint.SetBinding(StockDataPoint.DateProperty, dateBinding); stockDataPoint.SetBinding(StockDataPoint.HighProperty, highBinding); stockDataPoint.SetBinding(StockDataPoint.LowProperty, lowBinding); stockDataPoint.SetBinding(StockDataPoint.CloseProperty, closeBinding); return stockDataPoint; } private Dictionary<object, StockDataPoint> _dataPoints = new Dictionary<object, StockDataPoint>(); public IEnumerable ItemsSource { get { return GetValue(ItemsSourceProperty) as IEnumerable; } set { SetValue(ItemsSourceProperty, value); } } public static readonly DependencyProperty ItemsSourceProperty = DependencyProperty.Register( "ItemsSource", typeof(IEnumerable), typeof(StockSeries), new PropertyMetadata(null, OnItemsSourcePropertyChanged)); private static void OnItemsSourcePropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { StockSeries source = (StockSeries)d; IEnumerable oldValue = (IEnumerable)e.OldValue; IEnumerable newValue = (IEnumerable)e.NewValue; source.OnItemsSourcePropertyChanged(oldValue, newValue); } protected virtual void OnItemsSourcePropertyChanged(IEnumerable oldValue, IEnumerable newValue) { Refresh(); } public override void Refresh() { if (ItemsSource != null) { this.PlotArea.Children.Clear(); _dataPoints = ItemsSource .Cast<object>() .Select(obj => CreateStockDataPoint(obj)) .ToDictionary(dataPoint => dataPoint.DataContext); foreach (StockDataPoint dataPoint in _dataPoints.Values) { this.PlotArea.Children.Add(dataPoint); } } }

That's all it takes to create our data points and bind them to an underlying data source!

Creating Some Sample Data To Plot

We'll need some sample data if we want to see our series in action.  First we'll need a class to hold our sample data.

public class HighLowClose { public DateTime Date { get; set; } public double High { get; set; } public double Low { get; set; } public double Close { get; set; } }

That was easy.  Thanks automatic properties!  Now let's create a set of data and set it to the ItemsSource property of our series.

var date = new DateTime(2008, 1, 1); StockSeries stockSeries = chart.Series[0] as StockSeries; stockSeries.DatePath = "Date"; stockSeries.HighPath = "High"; stockSeries.LowPath = "Low"; stockSeries.ClosePath = "Close"; stockSeries.ItemsSource = new[] { new HighLowClose { Date = date, High = 23.0, Low = 18.0, Close = 22.0 }, new HighLowClose { Date = date.AddDays(1), High = 26.0, Low = 22.0, Close = 22.0 }, new HighLowClose { Date = date.AddDays(2), High = 33.0, Low = 30.0, Close = 32.0 }, new HighLowClose { Date = date.AddDays(3), High = 44.0, Low = 42.0, Close = 43.0 }, new HighLowClose { Date = date.AddDays(4), High = 37.0, Low = 35.0, Close = 36.0 }, new HighLowClose { Date = date.AddDays(5), High = 49.0, Low = 43.0, Close = 48.0 }, };

Now we're ready to starting plotting our data points.

Acquiring Axes

Listening for Changes

To plot our data points we'll need axes, but first we need to ensure that our series can receive messages from them.  The series needs to be able to listen for changes in the axes such as a change in the range or size.  This way it can respond to these changes by updating the positions of its data points accordingly.  A series can listen for axis changes by implementing the IAxisListener interface.

[TemplatePart(Name = "PlotArea", Type = typeof(Canvas))] public class StockSeries : Series, IAxisListener, IRequireGlobalSeriesIndex { // snip... public void AxisInvalidated(IAxis axis) { } }

The AxisInvalidated method is called by an axis when it changes.  Eventually we will add some code to update our data points to this method.  For the time being we'll leave it empty though.

Acquiring Axes

Now that we can listen for changes in an axis we want to acquire axes that we can use.  Where do get them from though? 

A well-behaved series does the following when it is added to a series host:

  • Looks for suitable axes in its series host's axes collection
  • If no axes are found it creates the axes it needs and adds them to its series host's collection of axes
  • Adds itself to each axes' collection of registered listeners

A well-behaved series does the following when it is removed from a series host:

  • Removes itself from each axes' collection of registered listeners

If we follow the steps above our stock series can share the same axes with other series.  We will also ensure that developers won't have to explicitly add appropriate axes to the chart's axes collection before they can use our series. 

There are three axes available in Silverlight Charts:

  • LinearAxis
  • DateTimeAxis
  • CategoryAxis

One approach would be too look for a horizontal DateTimeAxis and a vertical LinearAxis in the series host's axes collection when the SeriesHost property changes.  While this approach would work it would be sub-optimal.  In addition to allowing custom series, Silverlight Charts also allows custom axes.  We'd like our series to be able to use any axes added in the future (ex. LogarithmicAxis) as well as axes added by third-parties.  Let's take a look at the axis hierarchy.

blogimage10

You might be feeling a little overwhelmed but don't worry :-).  We're primarily interested in the interfaces and I'll explain those one by one. 

Let's take a look at the axes acquisition code in our StockSeries class.

private IAxis IndependentAxis { get; set; } private IRangeAxis DependentAxis { get; set; } protected override void OnSeriesHostPropertyChanged(ISeriesHost oldValue, ISeriesHost newValue) { if (newValue != null) { this.IndependentAxis = newValue.Axes .OfType<IAxis>() .Where(axis => axis.CanPlot(DateTime.Now) && axis.Orientation == AxisOrientation.X) .FirstOrDefault(); if (this.IndependentAxis == null) { IndependentAxis = new DateTimeAxis { Orientation = AxisOrientation.X }; newValue.Axes.Add(IndependentAxis); } this.IndependentAxis.RegisteredListeners.Add(this); this.DependentAxis = newValue.Axes .OfType<IRangeAxis>() .Where(rangeAxis => rangeAxis.CanPlot(0.0) && rangeAxis.Orientation == AxisOrientation.Y) .FirstOrDefault(); if (this.DependentAxis == null) { DependentAxis = new LinearAxis { Orientation = AxisOrientation.Y }; newValue.Axes.Add(DependentAxis); } this.DependentAxis.RegisteredListeners.Add(this); } else { if (this.IndependentAxis != null) { this.IndependentAxis.RegisteredListeners.Remove(this); } if (this.DependentAxis != null) { this.DependentAxis.RegisteredListeners.Remove(this); } } AcquireDataPointStyle(); base.OnSeriesHostPropertyChanged(oldValue, newValue); }

One immediate side-effect of this general approach is that we can use a CategoryAxis on the horizontal in addition to a DateTimeAxis because category axes can plot dates as well.

When we acquire an axis we add ourselves to its registered listeners collection.  When the axis changes it will call the AxisInvalidated method on all its registered and we'll get the opportunity to update our data points.  Now we're ready for the main event...

Plotting the Data Points

When the "Refresh" method of the base Series class is called our series is expected to retrieve data from its data source and plot its data points.  Let's create an "UpdateDataPoints" method which will plot a sequence of data points and invoke it in the "Refresh" method. 

public override void Refresh() { if (IndependentAxis != null && DependentAxis != null && PlotArea != null && ItemsSource != null) { this.PlotArea.Children.Clear(); _dataPoints = ItemsSource .Cast<object>() .Select(obj => CreateStockDataPoint(obj)) .ToDictionary(dataPoint => dataPoint.DataContext); foreach (StockDataPoint dataPoint in _dataPoints.Values) { this.PlotArea.Children.Add(dataPoint); } this.Dispatcher.BeginInvoke(() => UpdateDataPoints(_dataPoints.Values)); } }

Notice that instead of updating the data points synchronously we use BeginInvoke.  This will delay the process until after a layout pass has occurred.  This gives Silverlight/WPF the opportunity to apply the template to our data point controls.  This is important as we will need to know the final width of the data points in order to center them along the X axis.  Now let's write the methods that do the important work of positioning the data points. 

private void UpdateDataPoints(IEnumerable<StockDataPoint> dataPoints) { foreach (var dataPoint in dataPoints) { UpdateDataPoint(dataPoint); } } private void UpdateDataPoint(StockDataPoint dataPoint) { // GetPlotAreaCoordinate returns a nullable value because the value // may or may not be present on the axis. var dateCoordinateUnitValue = IndependentAxis.GetPlotAreaCoordinate(dataPoint.Date); if (dateCoordinateUnitValue != null && dateCoordinateUnitValue.Value.Unit == Unit.Pixels) { var nullableHighCoordinateUnitValue = DependentAxis.GetPlotAreaCoordinate(dataPoint.High); var nullableLowCoordinateUnitValue = DependentAxis.GetPlotAreaCoordinate(dataPoint.Low); var nullableCloseCoordinateUnitValue = DependentAxis.GetPlotAreaCoordinate(dataPoint.Close); if (nullableHighCoordinateUnitValue != null && nullableHighCoordinateUnitValue.Value.Unit == Unit.Pixels && nullableLowCoordinateUnitValue != null && nullableLowCoordinateUnitValue.Value.Unit == Unit.Pixels && nullableCloseCoordinateUnitValue != null && nullableCloseCoordinateUnitValue.Value.Unit == Unit.Pixels) { // Subtract all Y coordinates from the height of the plot // area canvas to invert the Y axis. The ensures that // larger values appear higher than smaller ones. var highPixelValue = PlotArea.ActualHeight - nullableHighCoordinateUnitValue.Value.Value; var lowPixelValue = PlotArea.ActualHeight - nullableLowCoordinateUnitValue.Value.Value; var closePixelValue = PlotArea.ActualHeight - nullableCloseCoordinateUnitValue.Value.Value; var dataPointHeight = lowPixelValue - highPixelValue; var closeCoordinate = closePixelValue - highPixelValue; dataPoint.CloseCoordinate = closeCoordinate; dataPoint.Height = dataPointHeight; // center our stock data point on the X point Canvas.SetLeft(dataPoint, dateCoordinateUnitValue.Value.Value - (dataPoint.ActualWidth / 2.0)); Canvas.SetTop(dataPoint, highPixelValue); } }

The GetPlotAreaCoordinate method of the IAxis type is important to understand.  Rather than a double this method returns a nullable UnitValue object.  This value is nullable because a given value may not exist on an axis.  For example negative values cannot exist on a logarithmic axis.  Also certain values may or may not be present on a category axis.

The UnitValue object is composed of a double Value property and an enum Unit property value which can be either Pixels or Degrees.  Although there aren't current any axes in Silverlight Charts that return Degree values this may change in the future to support radial axes.  For the time being our series will only render pixel values.

Now we're ready to implement our AxisInvalidated method.  All we need to do is call UpdateDataPoints!

public void AxisInvalidated(IAxis axis) { //Refresh(); UpdateDataPoints(_dataPoints.Values); }

Given the following XAML...

<charting:Chart x:Name="chart"> <charting:Chart.Axes> <charting:DateTimeAxis Orientation="X" /> <charting:LinearAxis Orientation="Y" /> </charting:Chart.Axes> <local:StockSeries Title="My First Series" /> </charting:Chart>

...we should be able to see our series.  Drumroll please...

emptyseries

Oops.  So what went wrong?  Why didn't our data points get plotted?

In fact our data points were plotted, it's just that they are outside of the visible range of the Y axis.  Notice that the range on the Y axis is 0 to 1.  Turns out that just as an axis needs to communicate with a series when it changes, a series must also communicate information to its axes.  In this case the series must communicate its data range to the axes so that the axes can select an appropriate range.

Helping the Axis Pick a Better Range

In order to get the axis to choose a range that is appropriate for our data we'll implement IRangeProvider.  Let's take a look at how an IRangeProvider interacts with an IRangeAxis.

rangeaxis

We implement the IRangeProvider.GetRange method so the axis can query the series to determine an appropriate range to display.  When a range consumer requests a range it passes itself to the GetRange method.  This allows us to determine which axis is requesting the range, the dependent axis or the independent axis.

public class StockSeries : Series, IRequireGlobalSeriesIndex, IAxisListener, IRangeProvider { //snip... public Range<IComparable> GetRange(IRangeConsumer rangeConsumer) { if (_dataPoints.Any()) { if (rangeConsumer == IndependentAxis) { DateTime minimum = _dataPoints.Values.Select(dataPoint => dataPoint.Date).Min(); DateTime maximum = _dataPoints.Values.Select(dataPoint => dataPoint.Date).Max(); return new Range<IComparable>(minimum, maximum); } else if (rangeConsumer == DependentAxis) { double minimum = _dataPoints.Values.Select(dataPoint => dataPoint.Low).Min(); double maximum = _dataPoints.Values.Select(dataPoint => dataPoint.High).Max(); return new Range<IComparable>(minimum, maximum); } } return new Range<IComparable>(); } }

Now the range axis can determine the best range to use to display our series data.  That's only half of the equation though.  The series also needs to be able to inform the axis that its range has changed.  The best time to do this is in the Refresh method after we load our data.

public override void Refresh() { if (IndependentAxis != null && DependentAxis != null && PlotArea != null && ItemsSource != null) { // snip... this.Dispatcher.BeginInvoke( () => { UpdateDataPoints(_dataPoints.Values); { { IRangeConsumer rangeConsumer = IndependentAxis as IRangeConsumer; if (rangeConsumer != null) { rangeConsumer.RangeChanged(this, GetRange(rangeConsumer)); } } { IRangeConsumer rangeConsumer = DependentAxis as IRangeConsumer; if (rangeConsumer != null) { rangeConsumer.RangeChanged(this, GetRange(rangeConsumer)); } } } }); } }

Now we should be able to see our series in action...

seriesinaction

Hmmm, looks okay.  Our data points have got a nice looking gradient and they've been plotted properly. 

The problem is that our far left and far right data points stretch outside of the chart.  The axis is doing its job and picking a range that encompasses all our data.  The problem is that our data points have a display width in addition to a data value. 

What we'd really like is for the axis to pick a range large enough so that both our data and our visual objects will fit inside of the series.  So how do we do that?

Making Room With ValueMargins

In order to help an axis balance display concerns and data concerns Silverlight Charts provides a ValueMargin object.  A value margin is a data value with a high and low margin value in pixels.  When an axis is provided with value margins in addition to a range it will try and find a range large enough such that a series data and its graphical objects can be displayed inside of the Chart.

The IValueMarginProvider and IValueMarginConsumer interfaces work in a way very similar to the IRangeProvider and IRangeConsumer interfaces:

valuemargins

Let's implement IValueMarginProvider.

public class StockSeries : Series, IRequireGlobalSeriesIndex, IAxisListener, IRangeProvider, IValueMarginProvider { //snip... public IEnumerable<ValueMargin> GetValueMargins(IValueMarginConsumer consumer) { // We only need to worry about value margins on the X axis. // On the Y axis the top and bottom of the data point always // correspond to the value location on the axis. if (consumer == IndependentAxis && consumer is IRangeAxis) { if (_dataPoints.Values.Any()) { // We return the width of only minimum and maximum data // points. The other information is unnecessary. StockDataPoint minimumStockDataPoint = EnumerableFunctions.Min(_dataPoints.Values, dataPoint => dataPoint.Date); StockDataPoint maximumStockDataPoint = EnumerableFunctions.Max(_dataPoints.Values, dataPoint => dataPoint.Date); var halfWidth = minimumStockDataPoint.Width / 2.0; return new[] { new ValueMargin(minimumStockDataPoint.Date, halfWidth, halfWidth), new ValueMargin(maximumStockDataPoint.Date, halfWidth, halfWidth), }; } } return new ValueMargin[] { }; } }

Notice that we're only worried about returning value margins to the independent (X) axis.  That's because the top and bottom of a data point on a stock chart always line up with a value on the axis (lest they be misleading).

To make life easier I've written some helper functions: Min and Max.  These functions work by accepting a function that they apply to each data point.  The function returns a value which is used to compare the data points.  The data point with the largest or smallest computed value is returned.

That wasn't too tough.  Now let's take a look at our series...

finished

Success!  There's only one more thing to do...

Supporting a Category Axis

Remember that we accept any axis on the horizontal, not just a DateTimeAxis.  This means that we can use an instance of the CategoryAxis class for our independent axis.  A category axis is similar to a range axis in that it needs to know what values to include.  As a result our series must inform the category axis which discrete values it intends to plot.  Predictably this is accomplished using the familiar Provider/Consumer model.

dataconsumer

Let's implement IDataProvider...

public class StockSeries : Series, IRequireGlobalSeriesIndex, IAxisListener, IRangeProvider, IValueMarginProvider, IDataProvider { public IEnumerable<object> GetData(IDataConsumer axis) { if (axis == IndependentAxis) { return _dataPoints.Values.Select(dataPoint => dataPoint.Date).Cast<object>(); } else if (axis == DependentAxis) { return _dataPoints.Values .SelectMany(dataPoint => new[] { dataPoint.Close, dataPoint.High, dataPoint.Low }).Cast<object>(); } return new object[] { }; } //snip... }

Just as we must inform the range axes we're using that the range has changed after loading new data we must also inform any axes that implement IDataConsumer (such as axes that implement ICategoryAxis) that our data has changed.  Let's revisit our Refresh method one last time.

public override void Refresh() { // snip... this.Dispatcher.BeginInvoke( () => { UpdateDataPoints(_dataPoints.Values); { { IRangeConsumer rangeConsumer = IndependentAxis as IRangeConsumer; if (rangeConsumer != null) { rangeConsumer.RangeChanged(this, GetRange(rangeConsumer)); } IDataConsumer dataConsumer = IndependentAxis as IDataConsumer; if (dataConsumer != null) { dataConsumer.DataChanged(this, GetData(dataConsumer)); } } { IRangeConsumer rangeConsumer = DependentAxis as IRangeConsumer; if (rangeConsumer != null) { rangeConsumer.RangeChanged(this, GetRange(rangeConsumer)); } IDataConsumer dataConsumer = DependentAxis as IDataConsumer; if (dataConsumer != null) { dataConsumer.DataChanged(this, GetData(dataConsumer)); } } } }); }

Now let's try and use a category axis with our stock series.  To make things interesting let's reverse the order of the dates on the axis (something a DateTimeAxis doesn't support).

<charting:Chart x:Name="chart"> <charting:Chart.Axes> <charting:CategoryAxis Orientation="X" SortOrder="Descending"> <charting:CategoryAxis.AxisLabelStyle> <Style TargetType="charting:AxisLabel"> <Setter Property="StringFormat" Value="{}{0:MM/dd/yy}" /> </Style> </charting:CategoryAxis.AxisLabelStyle> </charting:CategoryAxis> <charting:LinearAxis Orientation="Y" /> </charting:Chart.Axes> <local:StockSeries Title="My First Series" /> </charting:Chart>

Now let's take a look at our Series...

categoryaxis

A New Series For Silverlight Charts

As you can see Silverlight Charts has a very rich set of interfaces you can use to smoothly integrate custom series and axes.  With roughly 450 lines of code we just added the last series missing from Excel to Silverlight Charts.  The ball's in your court.  We're eager to see what the community comes up with.

Next Time: Making our Stock Series dynamic!

29 comments:

Anonymous said...

very nice...

expecting part 3!!!

Thank you very much

Simon said...

Great article!

One other chart type that you can find in Excel but is missing from the Silverlight toolkit is the stacked column chart as discussed here.

Although I'm pretty new to Silverlight (I've plenty of .Net experience generally) I think your article gives me pretty much everything I need to go ahead and implement the stacked column chart for myself. As far as I can tell, the only thing that I would need to do that is not covered in your post is to implement the methods that plot the series and recalculate the axis range, to take into account other series. For example, in the first column series 1 may have a value of 5 and therefore will be displayed as a rectangle who's start point is 0 and end point is 5 (in respect to the y axis). The first column of the second series will need to start at 5 rather 0 however - to make it appear stacked on top of the first. The axis range will need to determine the max and minimum values in a similar way so that all series are visible.

Is it possible to get a handle on the collection of series and determine the index of the current series in order to perform these calculations? Or do you think I'm completely off track with this idea?

Jafar Husain said...

Hey Simon,

The good news is that we've put a lot of thought into enabling stacked series and all the infrastructure you need is already in the current version. Here's how I would go about creating one...

First create a StackedColumnSeries which inherits from Series, ISeriesHost, and a new interface IGroupedColumnSeries. IGroupedColumnSeries should contain an UpdateDataPoint method that accepts a ColumnDataPoint class of your own.

Next create a column series of your own. Feel free to download the source and use it as a guide (or copy it wholesale). You will add instances of this new column series to the Series collection of your StackedColumnSeries.

Your column series will be the same as ours except it will never create axes of its own - it will always look for them in its series host. If it can't find suitable axes it simply wont doesn't render itself and the parent will ignore it. This will ensure that all column series in a stacked series are using the same axes.

Your StackedColumnSeries will be responsible for acquring or creating axes which are suitable for all its child series will use. Failing that it will create axes that all the children can use. You will have to add a method to your child series (preferably internal) that your StackedColumnSeries can query to determine whether a given axis is suitable for the child series.

Once the StackedColumnSeries acquires the axes from its series host (presumably the Chart) its simply a matter of adding them to its own Axes collection, thus making them available to its child column series. Note that the Axes collection of the ISeriesHost is an observable collection so the child series can listen for changes.

Your child column series will behave the same way as any other well-behaved series. If placed in a chart they should function just fine and render their own data points as if they weren't stacked. They can and should check the Series collection of their series host, determine their index relative to other column series (using the same axes), and ensure they don't overlap each other. FYI this is what the default column series does.

However the child series will also test to see if their ISeriesHost is an IGroupedColumnSeries. If so, rather than plot their own data points they will pass them to their IGroupedColumnSeries's UpdateDataPoint method. The IGroupedColumnSeries will have enough context to stack each data point properly. The advantage of using an IGroupedColumnSeries interface is that you can design different parent series that use different strategies to group the columns without having to modify the child series.

Hope that helps,

Good luck.

Anonymous said...
This comment has been removed by a blog administrator.
Drazen Dotlic said...

Hi,

great series of posts about custom stock charts! Anxiously waiting for the third part.

While I personally appreciate the completely custom code for all the aspects of the chart you've built, I can't but notice how much code was necessary for relatively simple example.

I see that in the comments to the first part of this series you say how the article on the codeproject.com site that basically does very similar thing (the other guy builds candlestick chart) can't be used without recompiling the source because the DataPointXXSeries and such are sealed?

Looking at the source of the Silverlight toolkit (March 2009) I don't see (almost) anything sealed.

Since you appear to be working on the SL toolkit team, are you hinting that these classes might become sealed in the next public release of the toolkit?

Jafar Husain said...

Drazen:

I should've been clearer. There are very few classes which are sealed. Many are only sealed in practice because their constructors are internal. We've taken steps to assure you cannot get very far by inheriting from the DataPointSeries hierarchy because we anticipate that it will change in the future.

If I were to use the DataPointSeries hierarchy to create the stock chart the code would be significantly smaller. It is our goal to open up the series eventually although I can't promise it will happen next release.

Simon said...

Hi Jafar,

Thanks for your help, I have now implemented the stacked bar chart. I have blogged about it at http://www.sharpcoder.co.uk/post/2009/05/03/A-Stacked-Bar-Chart-Silverlight-control.aspx if you are interested.

Although I posted my original question a few weeks ago I've actually only spent about 4 hours in total implementing this, which shows how straight forward it was.

Thanks again for your help.

Affordable Luxurious Wedding Dress Blog said...
This comment has been removed by a blog administrator.
prnat1 said...
This comment has been removed by the author.
prnat1 said...

Hi,

This is a great article, and this is what I am looking to do not with stock, but with some clinical data, I am quite new to Silverlight, having a bit of trouble getting this working. Is there a chance that a demo/source can be downloaded?

Also I am trying to find out bit more about 'GetStylesWithTargetType'.. I am getting an error with this

cheers

Anonymous said...
This comment has been removed by a blog administrator.
Anonymous said...
This comment has been removed by a blog administrator.
Anonymous said...
This comment has been removed by a blog administrator.
Unknown said...
This comment has been removed by a blog administrator.
Anonymous said...
This comment has been removed by a blog administrator.
商標註冊/專利申請達人 said...
This comment has been removed by a blog administrator.
Marc Hermann said...

Hi Jafar,

I have the same problem like cplotts - I cannot see any data points.

@prnat1: If you did not already find out yourself:
I fixed the GetStylesWithTargetType-Error by adding "this." before the "SeriesHost.GetStylesWithTargetType(" ...

Dolphin said...
This comment has been removed by a blog administrator.
Hotel, Motel, And Resort Reservations In Usa said...
This comment has been removed by a blog administrator.
Party Favors Yellowpages said...

I forgot about those! Maybe they look good w/ the right outfit? I didn't like skinny jeans when I first saw them. lol. Please come visit my site
http://www.partyrockstars.com
Chicago Business Directory
when you got time.

Ice Cream Recipes, Soft Serve Cream said...
This comment has been removed by a blog administrator.
Anonymous said...
This comment has been removed by a blog administrator.
Cengiz Ilerler said...

Hi Jafar,

Thank you for the great articles.
I have some difficulties to follow your articles. Like:

- Part 1: There is no StockSeriesDataPoint declaration so it should be either StockSeries or StockDataPoint and I do believe it is StockSeries but I need confirmation

- Part 2: I couldn’t be able to implement IRequireGlobalSeriesIndex. Also couldn’t be sure what to do with Part2’s StockSeries class. (Do I have to replace it with Part1’s or just append it?) etc.

Actually it would be perfect if you could provide downloadable source code so I can figure it out from there.

Thanks you very much.

Cheers

Anonymous said...

Hi Jafar,

Your articles look like amazing but they are pretty complex to follow up. Please provide the source.

Best

Anonymous said...

Hi Jafar,

Awesome articles. Thanks for taking the time to put these together.

One question. How does this change with the October release of the toolkit? I see the Refresh method on Series for example is not available for override anymore?

Appreciate any help.

Thanks,
John

Jafar Husain said...

I plan to update these articles to reflect the changes in the architecture soon. Hang in there.

Unknown said...

this is fascinating stuff. however, we can't follow it through with the new version of the toolkit. could you please update the article? thanks.

Jandersen said...

Jafar,
These are great articles but I'm also anxious to see an update reflecting recent changes in the charting API. Also the promised part 3.
Thanks,
James

PG said...

Great Article!!!

Can we do the same for stackbar or stackcolumn charts. I tried but failed can you give me any idea how to do that...

Apart from this I tried to copy and run the whole code but I found a few flaws like method "SeriesHost.GetStylesWithTargetType" was not found and for methods Refresh() and RefreshStyle() it says no method available to override.

About Me

My photo
I'm a software developer who started programming at age 16 and never saw any reason to stop. I'm working on the Presentation Platform Controls team at Microsoft. My primary interests are functional programming, and Rich Internet Applications.