Introduction
I have a WPF application using OxyPlot
to display multiple line graphs with a common horizontal time axis.
I need to show a tracker consisting of a vertical cursor and a display of the values of all the variables at that time point.
Quick use of my favourite search engine revealed a number of people asking how to do that but no very helpful answers.
I was able to achieve almost what I wanted with a custom DefaultTrackerTemplate
and a converter to dig out extra data from the TrackerHitResult
that it gets as DataContext
. That worked, but it seemed a bit clunky and I was concerned about the amount of processing it required; it would probably be too slow on some machines. It also had some other disadvantages I don't need to bore you with. I decided I needed to find a better way.
Background
OxyPlot
is an excellent open-source cross-platform plotting library for .NET but the documentation is a bit sparse (to put it mildly). The capabilities of the software are some way ahead of the information about how to use them.
Using the Code
I wrote a new class derived from MouseManipulator
to use in place of the default TrackerManipulator
. In order to use it, you have to unbind the existing MouseKey
bindings in the plot controller (the ones that connect up the default TrackerManipulator
) and create a new binding:
var pc = Plot.ActualController;
pc.UnbindMouseDown(OxyMouseButton.Left);
pc.UnbindMouseDown(OxyMouseButton.Left, OxyModifierKeys.Control);
pc.UnbindMouseDown(OxyMouseButton.Left, OxyModifierKeys.Shift);
pc.BindMouseDown(OxyMouseButton.Left, new DelegatePlotCommand<OxyMouseDownEventArgs>(
(view, controller, args) =>
controller.AddMouseManipulator(view, new WpbTrackerManipulator(view), args)));
Here's the WpbTrackerManipulator
class:
public class WpbTrackerManipulator : MouseManipulator
{
private DataPointSeries currentSeries;
public WpbTrackerManipulator(IPlotView plotView)
: base(plotView)
{
}
public override void Completed(OxyMouseEventArgs e)
{
base.Completed(e);
e.Handled = true;
currentSeries = null;
PlotView.HideTracker();
}
public override void Delta(OxyMouseEventArgs e)
{
base.Delta(e);
e.Handled = true;
if (currentSeries == null)
{
PlotView.HideTracker();
return;
}
var actualModel = PlotView.ActualModel;
if (actualModel == null)
{
return;
}
if (!actualModel.PlotArea.Contains(e.Position.X, e.Position.Y))
{
return;
}
var time = currentSeries.InverseTransform(e.Position).X;
var points = currentSeries.ItemsSource as Collection<DataPoint>;
DataPoint dp = points.FirstOrDefault(d => d.X >= time);
if (dp.X != 0 || dp.Y != 0)
{
int index = points.IndexOf(dp);
var ss = PlotView.ActualModel.Series.Cast<DataPointSeries>();
double[] values = new double[6];
int i = 0;
foreach (var series in ss)
{
values[i++] = (series.ItemsSource as Collection<DataPoint>)[index].Y;
}
var position = XAxis.Transform(dp.X, dp.Y, currentSeries.YAxis);
position = new ScreenPoint(position.X, e.Position.Y);
var result = new WpbTrackerHitResult(values)
{
Series = currentSeries,
DataPoint = dp,
Index = index,
Item = dp,
Position = position,
PlotModel = PlotView.ActualModel
};
PlotView.ShowTracker(result);
}
}
public override void Started(OxyMouseEventArgs e)
{
base.Started(e);
currentSeries = PlotView?.ActualModel?.Series
.FirstOrDefault(s => s.IsVisible) as DataPointSeries;
Delta(e);
}
}
Notice that I'm also using a new WpbTrackerHitResult
class to package the values from all the series:
public class WpbTrackerHitResult : TrackerHitResult
{
public double[] Values { get; private set; }
[System.Runtime.CompilerServices.IndexerName("ValueString")]
public string this[int index]
{
get
{
return string.Format((index == 1 || index == 4) ?
"{0,7:###0 }" : "{0,7:###0.0#}", Values[index]);
}
}
public WpbTrackerHitResult(double[] values)
{
Values = values;
}
}
That makes it possible to bind to the values in the tracker template. As a bonus, you can get the values as double
s or as formatted string
s. Here's a partial example, just to show how it works:
...
<oxy:TrackerControl Position="{Binding Position}"
LineExtents="{Binding PlotModel.PlotArea}"
BorderBrush="Black" BorderThickness="1"
HorizontalLineVisibility="Hidden" >
<oxy:TrackerControl.Content>
<Grid Margin="4,0,4,7">
...
<TextBlock Grid.Column="1"
Text="{Binding ValueString[0]}" Margin="4,2" />
<TextBlock Grid.Column="2"
Text="{Binding Value[3]}" Margin="4,2" />
...
</Grid>
</oxy:TrackerControl.Content>
</oxy:TrackerControl>
Points of Interest
The code above makes several assumptions that are valid in my application. For example, there are exactly six series in the PlotModel
and they are all LineSeries
. It would be possible to make the code more generic (and I may yet do that) but I decided to leave it simple here for ease of understanding. More generic code would also be slower. I think this code is faster than the default tracker and it seems easily to keep up with fast mouse movement with six series to track.
History
- 29th January, 2016: First release
Started my career as an electronics engineer.
Started software development in 4004 assembler.
Progressed to 8080, Z80 and 6802 assembler in the days when you built your own computer from discrete components.
Dabbled in Macro-11 and Coral66 by way of a small digression.
Moved on to C, first on Z80s and then on PCs when they were invented.
Continued to C++ and then C#
Now working mostly in C# and XAML while maintaining about half a million lines of C++