The ChartModifier API
If you haven't read it already, please see our documentation 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.Windows; using System.Windows.Controls; using System.Windows.Shapes; using SciChart.Charting.ChartModifiers; using SciChart.Core.Utility.Mouse; using SciChart.Drawing.Utility; namespace 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:
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.
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 API, 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.Stroke
- 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.Stroke, 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 it's possible to do anything with our custom ChartModifier API!
Get the Custom ChartModifier Sandbox application
The link to the complete application with all our custom chart modifiers can be found in Part 1.
Was this article helpful?
That’s Great!
Thank you for your feedback
Sorry! We couldn't be helpful
Thank you for your feedback
Feedback sent
We appreciate your effort and will try to fix the article