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 CustomRenderableSeriesSciChart 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. Implementing the Draw() Method for the Spline LineIn 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?
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
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: Drawing Point MarkersSo 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); 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 TestAll 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 ExampleThe 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 ReadingPlease find more detailed info on related topics in our documentation at the links below: Full example source code:
| |
|
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);
}
}