Knowledgebase
Creating a Custom Spline Line Series
Posted by Andrew BT on 07 February 2019 04:40 PM

This article refers to using the CustomRenderableSeries API to create a Cubic Spline Line Series. Please read the CustomRenderableSeries API documentation for a background to this powerful API. 

Creating a Cubic Spline CustomRenderableSeries

SciChart doesn't support cubic spline series out of the box, but it has been requested a few times, so we took the opportunity to create a CustomRenderableSeries to demonstrate this API. 

Let's start off by inheriting CustomRenderableSeries and overriding Draw().

Our Custom SplineLineRenderableSeries 

/// <summary>
/// A CustomRenderableSeries example which uses a Cubic Spline algorithm to smooth the points in a FastLineRenderableSeries
/// </summary>
public class SplineLineRenderableSeries : CustomRenderableSeries
{
    public static readonly DependencyProperty IsSplineEnabledProperty = DependencyProperty.Register("IsSplineEnabled", typeof (bool), typeof (SplineLineRenderableSeries), new PropertyMetadata(default(bool)));

    public bool IsSplineEnabled
    {
        get { return (bool) GetValue(IsSplineEnabledProperty); }
        set { SetValue(IsSplineEnabledProperty, value); }
    }

    public static readonly DependencyProperty UpSampleFactorProperty = DependencyProperty.Register(
        "UpSampleFactor", typeof (int), typeof (SplineLineRenderableSeries), new PropertyMetadata(10));        

    public int UpSampleFactor
    {
        get { return (int) GetValue(UpSampleFactorProperty); }
        set { SetValue(UpSampleFactorProperty, value); }
    }

    private IList<Point> _splineSeries;

    /// <summary>
    /// Draws the series using the <see cref="IRenderContext2D" /> and the <see cref="IRenderPassData" /> passed in
    /// </summary>
    /// <param name="renderContext">The render context. This is a graphics object which has methods to draw lines, quads and polygons to the screen</param>
    /// <param name="renderPassData">The render pass data. Contains a resampled 
    /// <see cref="IPointSeries" />, the 
    /// <see cref="IndexRange" /> of points on the screen
    /// and the current YAxis and XAxis 
    /// <see cref="ICoordinateCalculator{T}" /> to convert data-points to screen points</param>
    protected override void Draw(IRenderContext2D renderContext, IRenderPassData renderPassData)
    {
        base.Draw(renderContext, renderPassData);

        // TODO: Some drawing!
    }
}

Usage XAML

<!--  Create the chart surface  -->
<s:SciChartSurface Name="sciChart"
				   ChartTitle="Cubic Spline"
				   s:ThemeManager.Theme="BrightSpark">
				   
	<!--  Declare RenderableSeries  -->
	<s:SciChartSurface.RenderableSeries>
		
		<!-- Draw the spline series - custom renderable series -->
		<splineLineSeries:SplineLineRenderableSeries x:Name="splineRenderSeries" Stroke="DarkGreen" StrokeThickness="2"
													 IsSplineEnabled="True" UpSampleFactor="10">
			
		</splineLineSeries:SplineLineRenderableSeries>
		
	</s:SciChartSurface.RenderableSeries>

	<!--  Create an X Axis -->
	<s:SciChartSurface.XAxis>
		<s:NumericAxis AxisTitle="X" TextFormatting="#.############" ScientificNotation="None"/>
	</s:SciChartSurface.XAxis>

	<!--  Create a Y Axis -->
	<s:SciChartSurface.YAxis>
		<s:NumericAxis AxisTitle="Y" AxisAlignment="Left" GrowBy="0.7, 0.7" DrawMajorBands="True"/>
	</s:SciChartSurface.YAxis>

</s:SciChartSurface>

Usage Code Behind (e.g. in a Loaded Event Handler)

private void SplineChartExampleView_Loaded(object sender, RoutedEventArgs e)
{
    // Create a DataSeries of type X=double, Y=double
    var originalData = new XyDataSeries<double, double>() {SeriesName = "Original"};
    var splineData = new XyDataSeries<double, double>() { SeriesName = "Spline" };

    lineRenderSeries.DataSeries = originalData;
    splineRenderSeries.DataSeries = splineData;

    var data = DataManager.Instance.GetSinewave(1.0, 0.0, 100, 25);

    // Append data to series. SciChart automatically redraws
    originalData.Append(data.XData, data.YData);
    splineData.Append(data.XData, data.YData);

    sciChart.ZoomExtents();
}

Run and put a breakpoint

If you include this SplineLineRenderableSeries in a SciChartSurface and assign it some data via DataSeries property, put a breakpoint on base.Draw(), you should see it hit when the chart is re-drawn. The power! Muhaha ... 

Run your application and when you hit the breakpoint at base.Draw(), we encourage you to familiarize yourself with the IRenderContext2D and IRenderPassData interfaces passed into the Draw() method. 

For instance, you can dig into RenderPassData.PointSeries and find your post-resampled X, Y data. This is what you are going to use to draw to the screen, using the RenderContext to draw (as a graphics device) and the RenderPassData.XCoordinateCalculator / YCoordinateCalculator to transform data to pixel coordinates. 

SciChart CustomRenderableSeries.Draw Breakpoint

Implementing the Draw() Method for the Spline Line

In order to implement a cubic spline, we used our good old friend Mr. Google and found this CodeProject article on Cubic Spline Interpolation in C#. The Cubic Spline implementation provided requires you input your upsampled data which is then interpolated using a cubic algorithm. Pretty simple huh?!

So let's add this in to the Draw Method and use it to draw a line series

/// <summary>
/// Draws the series using the <see cref="IRenderContext2D" /> and the <see cref="IRenderPassData" /> passed in
/// </summary>
/// <param name="renderContext">The render context. This is a graphics object which has methods to draw lines, quads and polygons to the screen</param>
/// <param name="renderPassData">The render pass data. Contains a resampled 
/// <see cref="IPointSeries" />, the 
/// <see cref="IndexRange" /> of points on the screen
/// and the current YAxis and XAxis 
/// <see cref="ICoordinateCalculator{T}" /> to convert data-points to screen points</param>
protected override void Draw(IRenderContext2D renderContext, IRenderPassData renderPassData)
{
    base.Draw(renderContext, renderPassData);

    // Get the data from RenderPassData. See CustomRenderableSeries article which describes PointSeries relationship to DataSeries
    if (renderPassData.PointSeries.Count == 0) return;

    // Convert to Spline Series
    _splineSeries = ComputeSplineSeries(renderPassData.PointSeries, IsSplineEnabled, UpSampleFactor);

    // Get the coordinateCalculators. See 'Converting Pixel Coordinates to Data Coordinates' documentation for coordinate transforms
    var xCalc = renderPassData.XCoordinateCalculator;
    var yCalc = renderPassData.YCoordinateCalculator;

    // Create a pen to draw the spline line. Make sure you dispose it!             
    using (var linePen = renderContext.CreatePen(this.Stroke, this.AntiAliasing, this.StrokeThickness))
    {
        Point pt = _splineSeries[0];

        // Create a line drawing context. Make sure you dispose it!
        // NOTE: You can create mutliple line drawing contexts to draw segments if you want
        //       You can also call renderContext.DrawLine() and renderContext.DrawLines(), but the lineDrawingContext is higher performance
        using (var lineDrawingContext = renderContext.BeginLine(linePen, xCalc.GetCoordinate(pt.X), yCalc.GetCoordinate(pt.Y)))
        {
            for (int i = 1; i < _splineSeries.Count; i++)
            {
                pt = _splineSeries[i];
                lineDrawingContext.MoveTo(xCalc.GetCoordinate(pt.X), yCalc.GetCoordinate(pt.Y));
            }
        }
    }
}

// Cubic Spline interpolation: http://www.codeproject.com/Articles/560163/Csharp-Cubic-Spline-Interpolation
private IList<Point> ComputeSplineSeries(IPointSeries inputPointSeries, bool isSplineEnabled, int upsampleBy)
{
    // We've omitted this for brevity, but its included in the Create A Custom Chart -> Spline Line Series example
}

Breaking the Draw Method Down

Breaking it down, what do we do here? 

  1. We check if there is any data. If not, don't draw! 
  2. We compute the spline interpolated series, using the input RenderPassData.PointSeries. More on the spline calculation below. The return type of _splineSeries is List<Point>
  3. We create a line pen using RenderContext.CreatePen() method. Pens can be cached or re-used between render-passes, but note that pens must be disposed or risk a memory leak.
  4. We use the RenderContext.BeginLine() method to get a line drawing context. Now every time we call lineDrawingContext.MoveTo() we are extending a polyline on the SciChart RenderSurface.
  5. Finally, every data-point in SplineSeries is transformed to pixels using xCalc.GetCoordinate() and yCalc.GetCoordinate(). For background, see Converting Data to Pixel Coordinates.

Reading the PointSeries Data to create Spline Points

Below you can find the ComputeSplineSeries() method. This takes the IPointSeries input from RenderPassData.PointSeries, and transforms into a List<Point> which we use to draw. 

You can find more info about the PointSeries types, and relationship to DataSeries at the CustomRenderableSeries API Overview article.

// Cubic Spline interpolation: http://www.codeproject.com/Articles/560163/Csharp-Cubic-Spline-Interpolation
private IList<Point> ComputeSplineSeries(IPointSeries inputPointSeries, bool isSplineEnabled, int upsampleBy)
{
    IList<Point> result = null;

    if (!isSplineEnabled)
    {
        // No spline, just return points. Note: for large datasets, even the copy here causes performance problems!                 
        result = new List<Point>(inputPointSeries.Count);
        for (int i = 0; i < inputPointSeries.Count; i++)
        {
            result.Add(new Point(inputPointSeries.XValues[i], inputPointSeries.YValues[i]));
        }
        return result;
    }

    // Spline enabled
    int n = inputPointSeries.Count * upsampleBy;
    var x = inputPointSeries.XValues.ToArray();
    var y = inputPointSeries.YValues.ToArray();
    double[] xs = new double[n];
    double stepSize = (x[x.Length - 1] - x[0]) / (n - 1);

    for (int i = 0; i < n; i++)
    {
        xs[i] = x[0] + i * stepSize;
    }
            
    var cubicSpline = new CubicSpline();
    double[] ys = cubicSpline.FitAndEval(x, y, xs);

    result = new List<Point>(n);
    for (int i = 0; i < xs.Length; i++)
    {
        result.Add(new Point(xs[i], ys[i]));
    }
    return result;
}

Breaking this Down

  1. The inputPointSeries has properties Count, XValues, YValues, which are the resampled data-points after conversion to double. We convert these ToArray() to get the double values out directly. 
  2. Next, we use the CubicSpline class from this codeproject article to upsample and interpolate the data. 
  3. Finally, we return as a List<Point>. 

NOTE: For TX=DateTime or TimeSpan, conversion to double in the IPointSeries involves taking the DateTime.Ticks property. For integral or floating point types, its simply a cast. 

We are still in data-space here - we have not yet converted to pixel coordinates. This is done in the Draw() method using the XCoordinateCalculator / YCoordinateCalculator. If you run the example now, you should see something like this:

SciChart Spline Line Series

Drawing Point Markers

So we have a line, that's great! What about Point Markers? Adding Point-markers is quite simple. We just need to have the PointMarker  set on our series, and call the DrawPointMarkers() method, passing in the render context and a point series: 

// INSIDE DRAW() METHOD, AFTER LINE IS DRAWN
// Draws PointMarkers at every point from the PointSeries
DrawPointMarkers(renderContext, renderPassData.PointSeries);

SciChart Spline Series with Markers

You can draw anything you like using the RenderContext API. Just make sure that data-points are converted to pixel coordinates using GetCoordinate() and you dispose your pens and brushes.

Implementing Hit Test

All the SciChart tooltips such as the RolloverModifier  and TooltipModifier rely on the Hit-Test API. If you've changed the shape of a series, we recommend overriding Hit-Test. 

The BaseRenderableSeries.HitTest method accepts a Point (mouse-coordinates on the main RenderSurface) and returns a HitTestInfo struct. It's important to populate the following properties before returning:

Here's our code. We search the cached _splinePointSeries rather than search the original data so that our HitTest implementation returns cubic spline interpolated data. If you just wanted to get a HitTest on the original data, you can do this too. Just call the base.HitTest() method instead!

public override HitTestInfo HitTest(Point rawPoint, double hitTestRadius, bool interpolate = false)
{
    // No spline? Fine - return base implementation
    if (!IsSplineEnabled || _splineSeries == null || CurrentRenderPassData == null)
        return base.HitTest(rawPoint, hitTestRadius, interpolate);

    var nearestHitResult = new HitTestInfo();
   
    // Get the coordinateCalculators. See 'Converting Pixel Coordinates to Data Coordinates' documentation for coordinate transforms
    var xCalc = CurrentRenderPassData.XCoordinateCalculator;
    var yCalc = CurrentRenderPassData.YCoordinateCalculator;

    // Compute the X,Y data value at the mouse location
    var xDataPointAtMouse = xCalc.GetDataValue(rawPoint.X);

    // Find the index in the spline interpolated data that is nearest to the X-Data point at mouse
    // NOTE: This assumes the data is sorted in ascending direction and a binary search would be faster ... 
    int foundIndex = FindIndex(_splineSeries, xDataPointAtMouse);

    if (foundIndex != -1)
    {
        nearestHitResult.IsWithinDataBounds = true;

        // Find the nearest data point to the mouse 
        var xDataPointNearest = _splineSeries[foundIndex].X;
        var yDataPointNearest = _splineSeries[foundIndex].Y;
        nearestHitResult.XValue = xDataPointNearest;
        nearestHitResult.YValue = yDataPointNearest;

        // Compute the X,Y coordinates (pixel coords) of the nearest data point to the mouse
        nearestHitResult.HitTestPoint = new Point(
            xCalc.GetCoordinate(xDataPointNearest),
            yCalc.GetCoordinate(yDataPointNearest));

        // Determine if mouse-location is within 7.07 pixels of the nearest data point
        // using X-Value only
        nearestHitResult.IsHit = Math.Abs(rawPoint.X - nearestHitResult.HitTestPoint.X) <= 7.07;
        nearestHitResult.IsVerticalHit = true;

        // Returning a HitTestResult with IsHit = true / IsVerticalHit signifies to the Rollovermodifier & TooltipModifier to show a tooltip at this location
        return nearestHitResult;
    }
    else
    {
        // Returning HitTestInfo.Empty signifies to the RolloverModifier & TooltipModifier there is nothing to show here
        return HitTestInfo.Empty;
    }
}

With HitTest implemented, you can now add a RolloverModifier to the chart and see the rollover feedback. Note that the base HitTest implementation works perfectly well for X Y data. The only reason we have implemented HitTest here is to return spline interpolated values. 

The Full Cubic Spline Example

The example can be found in the SciChart Examples Suite at Create Custom Charts -> Spline Scatter Line Chart. The full source code is available online at this link.

Further Reading

Please find more detailed info on related topics in our documentation at the links below:

Full example source code:

 

 

 

(6 vote(s))
Helpful
Not helpful

Comments (1)
Dan Woerner
15 June 2016 01:24 PM
For anyone trying to get this working in v4.0+, here is the updated Draw() function from their Examples Suite:

protected override void Draw(IRenderContext2D renderContext, IRenderPassData renderPassData)
{
base.Draw(renderContext, renderPassData);

// Get the data from RenderPassData. See CustomRenderableSeries article which describes PointSeries relationship to DataSeries
if (renderPassData.PointSeries.Count == 0)
return;

// Convert to Spline Series
_splineSeries = ComputeSplineSeries(renderPassData.PointSeries, IsSplineEnabled, UpSampleFactor);

// Get the coordinates of the first dataPoint
var point = GetCoordinatesFor(_splineSeries[0].X, _splineSeries[0].Y);

// Create a pen to draw the spline line. Make sure you dispose it!
using (var linePen = renderContext.CreatePen(this.Stroke, this.AntiAliasing, this.StrokeThickness))
{
// Create a line drawing context. Make sure you dispose it!
// NOTE: You can create mutliple line drawing contexts to draw segments if you want
// You can also call renderContext.DrawLine() and renderContext.DrawLines(), but the lineDrawingContext is higher performance
using (var lineDrawingContext = renderContext.BeginLine(linePen, point.X, point.Y))
{
for (int i = 1; i < _splineSeries.Count; i++)
{
point = GetCoordinatesFor(_splineSeries[i].X, _splineSeries[i].Y);

lineDrawingContext.MoveTo(point.X, point.Y);
}
}
}

// Get the optional PointMarker to draw at original points
var pointMarker = this.GetPointMarker();
if (pointMarker != null)
{
var originalPointSeries = renderPassData.PointSeries;

pointMarker.BeginBatch(renderContext, pointMarker.Stroke, pointMarker.Fill);

// Iterate over points and draw the point marker
for (int i = 0; i < originalPointSeries.Count; i++)
{
point = GetCoordinatesFor(originalPointSeries[i].X, originalPointSeries[i].Y);

pointMarker.MoveTo(renderContext, point.X, point.Y, originalPointSeries.Indexes[i]);
}

pointMarker.EndBatch(renderContext);
}
}

CONTACT US

Not sure where to start? Contact us, we are happy to help!


CONTACT US

SciChart Ltd, 16 Beaufort Court, Admirals Way, Docklands, London, E14 9XL. Email: Legal Company Number: 07430048, VAT Number: 101957725