Custom ChartModifiers - Part 6 - Select Data Points via Mouse-Drag
Posted by Andrew BT on 13 November 2014 01:07 PM

The ChartModifier API

If you haven't read it already, please see our article on Custom Chartmodifier API Overview. In this article, we introduce the basics of the ChartModifier API including an overview of this powerful base class. 

The ChartModifier API is by far the most powerful API in the SciChart library. Using this API you can create behaviours which you can attach to a chart to perform custom Zooming, Panning, Annotation & Markers, Legend output and much much more. Any time you want to do something in C# code to alter the behaviour of a SciChartSurface you should be thinking about creating a custom modifier to do it.

Custom ChartModifiers Part 6 - Selecting Data Points via Mouse-Drag

We often get asked how to select data-points via a rubber-band zoom drag. In this tutorial we show you how to create a custom chart modifier to select data-points when you drag a rectangle on the chart. 

Setting up the ChartModifier

We create a new ChartModifierBase derived class which overrides OnModifierMouseDown, OnModifierMouseMove and OnModifierMouseUp. The following is just boilerplate code which draws a rectangle to the screen, and calls a method when dragging completes, ready with start/end point to perform the selection. 

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Shapes;
using Abt.Controls.SciChart.ChartModifiers;
using Abt.Controls.SciChart.Utility;

namespace Abt.Controls.SciChart.Wpf.TestSuite.ExampleSandbox.CustomModifiers
{
    public class SimpleDataPointSelectionModifier : ChartModifierBase
    {
        public static readonly DependencyProperty SelectionPolygonStyleProperty = DependencyProperty.Register(
            "SelectionPolygonStyle", typeof (Style), typeof (SimpleDataPointSelectionModifier), new PropertyMetadata(default(Style)));

        public Style SelectionPolygonStyle
        {
            get { return (Style) GetValue(SelectionPolygonStyleProperty); }
            set { SetValue(SelectionPolygonStyleProperty, value); }
        }

        /// <summary>
        /// reticule
        /// </summary>
        private Rectangle _rectangle;

        private bool _isDragging;
        private Point _startPoint;
        private Point _endPoint;

        /// <summary>
        /// Gets whether the user is currently dragging the mouse
        /// </summary>
        public bool IsDragging
        {
            get { return _isDragging; }
        }

        /// <summary>
        /// Called when the Chart Modifier is attached to the Chart Surface
        /// </summary>
        /// <remarks></remarks>
        public override void OnAttached()
        {
            base.OnAttached();

            ClearReticule();
        }

        /// <summary>
        /// Called when the Chart Modifier is detached from the Chart Surface
        /// </summary>
        /// <remarks></remarks>
        public override void OnDetached()
        {
            base.OnDetached();

            ClearReticule();
        }

        public override void OnModifierMouseDown(ModifierMouseArgs e)
        {
            base.OnModifierMouseDown(e);

            // Check the ExecuteOn property and if we are already dragging. If so, exit
            if (_isDragging || !MatchesExecuteOn(e.MouseButtons, ExecuteOn))
                return;

            // Check the mouse point was inside the ModifierSurface (the central chart area). If not, exit
            var modifierSurfaceBounds = ModifierSurface.GetBoundsRelativeTo(RootGrid);
            if (!modifierSurfaceBounds.Contains(e.MousePoint))
            {
                return;
            }

            // Capture the mouse, so if mouse goes out of bounds, we retain mouse events
            if (e.IsMaster)
                ModifierSurface.CaptureMouse();

            // Translate the mouse point (which is in RootGrid coordiantes) relative to the ModifierSurface
            // This accounts for any offset due to left Y-Axis
            var ptTrans = GetPointRelativeTo(e.MousePoint, ModifierSurface);
                
            _startPoint = ptTrans;
            _rectangle = new Rectangle
            {
                Style = SelectionPolygonStyle,
            };

            // Update the zoom recticule position
            SetReticulePosition(_rectangle, _startPoint, _startPoint, e.IsMaster);

            // Add the zoom reticule to the ModifierSurface - a canvas over the chart
            ModifierSurface.Children.Add(_rectangle);

            // Set flag that a drag has begun
            _isDragging = true;
        }

        public override void OnModifierMouseMove(ModifierMouseArgs e)
        {
            if (!_isDragging)
                return;

            base.OnModifierMouseMove(e);
            e.Handled = true;

            // Translate the mouse point (which is in RootGrid coordiantes) relative to the ModifierSurface
            // This accounts for any offset due to left Y-Axis
            var ptTrans = GetPointRelativeTo(e.MousePoint, ModifierSurface);

            // Update the zoom recticule position
            SetReticulePosition(_rectangle, _startPoint, ptTrans, e.IsMaster);
        }

        public override void OnModifierMouseUp(ModifierMouseArgs e)
        {
            if (!_isDragging)
                return;

            base.OnModifierMouseUp(e);

            // Translate the mouse point (which is in RootGrid coordiantes) relative to the ModifierSurface
            // This accounts for any offset due to left Y-Axis
            var ptTrans = GetPointRelativeTo(e.MousePoint, ModifierSurface);

            _endPoint = SetReticulePosition(_rectangle, _startPoint, ptTrans, e.IsMaster);

            double distanceDragged = PointUtil.Distance(_startPoint, ptTrans);
            if (distanceDragged > 10.0)
            {
                PerformSelection(_startPoint, _endPoint);
                e.Handled = true;
            }

            ClearReticule();
            _isDragging = false;

            if (e.IsMaster)
                ModifierSurface.ReleaseMouseCapture();
        }

        private void ClearReticule()
        {
            if (ModifierSurface != null && _rectangle != null)
            {
                ModifierSurface.Children.Remove(_rectangle);
                _rectangle = null;
                _isDragging = false;
            }
        }

        private Point SetReticulePosition(Rectangle rectangle, Point startPoint, Point endPoint, bool isMaster)
        {
            var modifierRect = new Rect(0, 0, ModifierSurface.ActualWidth, ModifierSurface.ActualHeight);
            endPoint = ClipToBounds(modifierRect, endPoint);

            var rect = new Rect(startPoint, endPoint);
            Canvas.SetLeft(rectangle, rect.X);
            Canvas.SetTop(rectangle, rect.Y);

            //Debug.WriteLine("SetRect... x={0}, y={1}, w={2}, h={3}, IsMaster? {4}", rect.X, rect.Y, rect.Width, rect.Height, isMaster);

            rectangle.Width = rect.Width;
            rectangle.Height = rect.Height;

            return endPoint;
        }

        private static Point ClipToBounds(Rect rect, Point point)
        {
            double rightEdge = rect.Right;
            double leftEdge = rect.Left;
            double topEdge = rect.Top;
            double bottomEdge = rect.Bottom;

            point.X = point.X > rightEdge ? rightEdge : point.X;
            point.X = point.X < leftEdge ? leftEdge : point.X;
            point.Y = point.Y > bottomEdge ? bottomEdge : point.Y;
            point.Y = point.Y < topEdge ? topEdge : point.Y;

            return point;
        }

        private void PerformSelection(Point startPoint, Point endPoint)
        {
            Console.WriteLine("TODO: Perform Selection. StartPoint = {0},{1}. EndPoint = {2},{3}",
                startPoint.X, startPoint.Y, endPoint.X, endPoint.Y);
        }
    }
}

We can use this modifier by declaring it inside a ModifierGroup on the SciChartSurface.ChartModifier property. The style is required to style the rectangle, otherwise the rectangle will be invisible!

<!-- Ensure style is declared in UserControl.Resources, or another resource dictionary -->
<Style x:Key="SelectionStyle" TargetType="{x:Type Shape}">
	<Setter Property="Fill" Value="#33AAAAAA"/>
	<Setter Property="Stroke" Value="LightGray"/>
	<Setter Property="StrokeThickness" Value="1"/>
	<Setter Property="StrokeDashArray" Value="2, 2"/>
</Style>

<s:SciChartSurface>
	
	<!-- XAxis, YAxis, RenderableSeries omitted for brevity -->
		
	<s:SciChartSurface.ChartModifier>
		<s:ModifierGroup>                    
			<!-- Declare your new SimpleDataPointSelectionModifier -->
			<customModifiers:SimpleDataPointSelectionModifier IsEnabled="True"
					SelectionPolygonStyle="{StaticResource SelectionStyle}"/>
		</s:ModifierGroup>
	</s:SciChartSurface.ChartModifier>
	
</s:SciChartSurface>

Give it a try, so far you should see something like this:

SciChart Drag to Select Data Points

Implementing the PerformSelection() Method

If you notice above, the PerformSelection() method is called with two Points - the start and end coordinate of the rectangle. We now need to use this information to select data-points. 

SciChart PerformSelection() Breakpoint

If you're familiar with our DataSeries API, you'll know we store data in proprietary data structures in column order. This means you get an array of X, Y, (optional Z) values but no complex classes like DataPoint. So, if we want to denote data points as selected, we're going to have to create some way of storing this information. 

Let's go for the simplest solution - storing a collection of DataPoint keyed by RendearbleSeries in the SimpleDataPointSelectionModifier itself. Modify the code to declare a Dictionary dependency property and a struct called DataPoint, later we're going to add points to this collection:

public struct DataPoint
{
    public double XValue;
    public double YValue;
    public int Index;
}

public class SimpleDataPointSelectionModifier : ChartModifierBase
{        
    public static readonly DependencyProperty SelectionPolygonStyleProperty = DependencyProperty.Register(
        "SelectionPolygonStyle", typeof (Style), typeof (SimpleDataPointSelectionModifier), new PropertyMetadata(default(Style)));

    public static readonly DependencyProperty SelectedPointsProperty = DependencyProperty.Register(
        "SelectedPoints", typeof(IDictionary<IRenderableSeries, DataPoint>), typeof(SimpleDataPointSelectionModifier), new PropertyMetadata(default(IDictionary<IRenderableSeries, DataPoint>)));        
        
    private Rectangle _rectangle;
    private bool _isDragging;
    private Point _startPoint;
    private Point _endPoint;

    public IDictionary<IRenderableSeries, DataPoint> SelectedPoints
    {
        get { return (IDictionary<IRenderableSeries, DataPoint>)GetValue(SelectedPointsProperty); }
        set { SetValue(SelectedPointsProperty, value); }
    }

 Next we're going to select the points in the PerformSelection method. To do this we need to use the CoordinateCalculator API, to transform pixel to data-coordinates (and vice versa). The process is as follows:

  • Foreach RenderableSeries in ChartModifierBase.ParentSurface.RenderableSeries
  • Check if it has a DataSeries, if not, ignore
  • Get the RenderableSeries.XAxis, YAxis CoordinateCalculator
  • Compute the bounds of the selection rectangle in data-coordinates - for that XAxis YAxis
  • Iterate over all the data-points in the RenderableSeries.DataSeries and check if it is inside the selection rectangle
  • Store the selected data-points
  • Redraw the chart. 
private void PerformSelection(Point startPoint, Point endPoint)
{
    // Find all the points that are inside this bounds and store them     
    var dataPoints = new Dictionary<IRenderableSeries, List<DataPoint>>();    
    foreach (var renderSeries in base.ParentSurface.RenderableSeries)
    {
        var dataSeries = renderSeries.DataSeries;
        if (dataSeries == null) continue;

        // We're going to convert our start/end mouse points to data values using the CoordinateCalculator API
        // Note: that RenderSeries can have different XAxis, YAxis, so we use the axes from the RenderSeries not the primary axes on the chart
        var xCalc = renderSeries.XAxis.GetCurrentCoordinateCalculator();
        var yCalc = renderSeries.YAxis.GetCurrentCoordinateCalculator();

        // Find the bounds of the data inside the rectangle
        var leftXData = xCalc.GetDataValue(startPoint.X);
        var rightXData = xCalc.GetDataValue(endPoint.X);
        var topYData = yCalc.GetDataValue(startPoint.Y);
        var bottomYData = yCalc.GetDataValue(endPoint.Y);
        var dataRect = new Rect(new Point(leftXData, topYData), new Point(rightXData, bottomYData));

        dataPoints[renderSeries] = new List<DataPoint>();

        for (int i = 0; i < dataSeries.Count; i++)
        {
            var currentPoint = new Point(((DateTime)dataSeries.XValues[i]).Ticks, (double)dataSeries.YValues[i]);
            if (dataRect.Contains(currentPoint))
            {
                    dataPoints[renderSeries].Add(new DataPoint() { Index = i, XValue = currentPoint.X, YValue = currentPoint.Y});
            }
        }
    }

    this.SelectedPoints = dataPoints;

    this.ParentSurface.InvalidateElement();
}

Getting your Selected DataPoints into a ViewModel

Now that you have your selected data-points, you can actually push them into a ViewModel using a Two-Way binding. e.g. assuming you have a property in your ViewModel called SelectedDataPoints, you can do this:

<s:SciChartSurface.ChartModifier>
    <s:ModifierGroup>

        <customModifiers:SimpleDataPointSelectionModifier IsEnabled="{Binding ElementName=selectPointsEnabled, Path=IsChecked}" 
                                                        SelectionPolygonStyle="{StaticResource SelectionStyle}"
                                                        SelectedPoints="{Binding SelectedDataPoints, Mode=TwoWay}"/>


    </s:ModifierGroup>
</s:SciChartSurface.ChartModifier>

Then, the setter will be called whenever the user performs a new selection

public class CustomModifierSandboxViewModel : BindableObject
{
    private IDictionary<IRenderableSeries, List<DataPoint>> _selectedDataPoints;


    /// <summary>
    /// Gets or sets the SelectedDataPoints. This is bound to SimpleDataPointSelectionModifier.SelectedPoints
    /// </summary>
    public IDictionary<IRenderableSeries, List<DataPoint>> SelectedDataPoints
    {
        get { return _selectedDataPoints; }
        set
        {
            if (value != _selectedDataPoints)
            {
                _selectedDataPoints = value;
                OnPropertyChanged("SelectedDataPoints");

                // TODO HERE: React to Selection Changed if you want 
            }
        }
    }
}

Drawing your Selected DataPoints

So, the last step is to actually draw the selected data-points. This can be done using our RenderContext, which draws directly to the bitmap layer that series draw to. For a background in RenderContext and the CustomRenderableSeries API, please see this article. 

To get a RenderContext inside a ChartModifier, you can override OnParentSurfaceRendered. This passes in the SciChartRenderedMessage, which has a RenderContext that can be used for drawing. In the code sample below, we

  • Create a FillBrush from SimpleDataPointSelectionModifier.SelectedPointColor (new dependency property on the modifier)
  • Iterate over all RenderableSeries in the selected points dictionary
  • Create a StrokePen from RenderableSeries.SeriesColor
  • Get the RenderableSeries XAxis, YAxis CoordinateCalculator (to convert pixel to data coordinates)
  • Iterate over all selected points for that render series
  • call RenderContext.DrawEllipse, drawing an ellipse at the selected point location

Here's the code:

/// <summary>
/// Called when the parent <see cref="SciChartSurface" /> is rendered. Here is where we draw selected points
/// </summary>
/// <param name="e">The <see cref="SciChartRenderedMessage" /> which contains the event arg data</param>
public override void OnParentSurfaceRendered(SciChartRenderedMessage e)
{
    base.OnParentSurfaceRendered(e);

    var selectedPoints = this.SelectedPoints;
    if (selectedPoints == null) return;

    double size = SelectedPointSize;

    // Create Fill brush for the point marker
    using (var fill = e.RenderContext.CreateBrush(SelectedPointColor))
    {
        // Iterating over renderable series
        foreach (var renderSeries in selectedPoints.Keys)
        {
            // Create stroke pen based on r-series color
            using (var stroke = e.RenderContext.CreatePen(renderSeries.SeriesColor, true, 1.0f))
            {
                // We need XAxis/YAxis.GetCurrentCoordinateCalculator() from the current series (as they can be per-series)
                // to convert data points to pixel coords
                var xAxis = renderSeries.XAxis;
                var yAxis = renderSeries.YAxis;
                var xCalc = xAxis.GetCurrentCoordinateCalculator();
                var yCalc = yAxis.GetCurrentCoordinateCalculator();

                var pointList = selectedPoints[renderSeries];

                // Iterate over the selected points
                foreach (var point in pointList)
                {
                    // Draw the selected point marker
                    e.RenderContext.DrawEllipse(
                        stroke, 
                        fill, 
                        new Point(xCalc.GetCoordinate(point.XValue), yCalc.GetCoordinate(point.YValue)),
                        size, 
                        size);                                                        
                }
            }                    
        }
    }
}

Putting it All Together

Putting it all together, we can select data-points using the mouse, get a notification in a ViewModel of selected points, and draw selected points on the screen. Using this as a basis its possible to do anything with our custom ChartModifier API! 

SciChart Select Data Points with the Mouse

Download the code sample

To download the code sample, head over to ChartModifier API Part 1. Note this sample now requires SciChart v3.2 BETA to work.

(6 vote(s))
Helpful
Not helpful

Comments (0)

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