Custom ChartModifiers - Part 6 - Select Data Points via Mouse-Drag
Posted by Andrew BT on 04 February 2019 02:00 PM
|
|
The ChartModifier APIIf 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.
Custom ChartModifiers Part 6 - Selecting Data Points via Mouse-DragWe 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() MethodIf 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:
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 ViewModelNow 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 DataPointsSo, 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
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 TogetherPutting 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! Download the code sampleTo download the code sample, head over to ChartModifier API Part 1. Note this sample now requires SciChart v3.2 BETA to work. | |
|