Graphical display#
This tutorial demonstrates how to create a display with interactive graphics, as the saying goes "a picture is worth a thousand words".
WPF is a graphics rich user interface technology with sophisticated support for high resolution bitmap and vector graphics with complex animations, transitions and effects.
WPF provides a multitude of ways to display graphics with competing programming complexity and performance characteristics.
This tutorial demonstrates a couple of approaches at each end of the complexity/performance spectrum
- Use of Shapes that are easy to program but performance will suffer if over used
- Use of DrawingContext that is harder to program but more suitable for highly interactive complex vector graphics
Note
The code for this tutorial can be reviewed at Tutorials/DriverDisplayPlugin
Common setup#
Start the tutorial by creating a new display from scratch named DriverDisplayPlugin
.
Update the View Model class#
Derive from TemplateDisplayViewModelBase and allow parameters by specifying the DisplayPluginSettings
attribute
[DisplayPluginSettings(ParametersMaxCount = 2)]
public sealed class SampleDisplayViewModel : TemplateDisplayViewModelBase
Inject the ISignalBus
, IDataRequestSignalFactory
and ILogger
services into the View Model constructor and pass to the base constructor
public SampleDisplayViewModel(
ISignalBus signalBus,
IDataRequestSignalFactory dataRequestSignalFactory,
ILogger logger) :
base(signalBus, dataRequestSignalFactory, logger)
Using Shapes#
For the majority of use cases, WPF shapes provides an ideal compromise between programming simplicity and performance.
The shape elements can be added to the View XAML as per standard User Interface elements such as TextBlock
, and standard binding to View Model properties can be used to change attributes such as colour, position or size. WPF animations can also be used to add additional flare as required.
A perfect example of using WPF shapes is to represent bit field parameters with appropriate graphics.
This example shows how to display a dynamic representation of the PCU8D shift light LEDs.
The LEDs are represented by a single parameter (e.g. NLEDStatusAll
, verify from ATLAS settings or ask your System Administrator)
Bit positions are mapped to the following LEDs
Add LED View Model class#
Add a simple View Model class to represent an LED
Color
field namedoffColor
that is the colour displayed when the LED is off (passed to constructor)Color
field namedonColor
that is the colour displayed when the LED is on (passed to constructor)int
field namedbitIndex
that is bit position of the LED (passed to constructor)Color
field namedcolor
as backer for theColor
propertydouble
field namedshiftY
as backer for theShiftY
property (passed to constructor)Color
property namedColor
that is the current color of the LEDdouble
property namedShiftY
that is the optional distance to shift LED in the Y directionUpdateColor()
method that takes aBitArray
and sets the LED color based on the state of the bit atbitIndex
public sealed class LedViewModel : BindableBase
{
private readonly Color offColor;
private readonly Color onColor;
private readonly int bitIndex;
private Color color;
private double shiftY;
public LedViewModel(Color offColor, Color onColor, int bitIndex, double shiftY)
{
this.offColor = offColor;
this.onColor = onColor;
this.bitIndex = bitIndex;
this.shiftY = shiftY;
this.color = offColor;
}
public Color Color
{
get => this.color;
set => SetProperty(ref this.color, value);
}
public double ShiftY
{
get => this.shiftY;
set => SetProperty(ref this.shiftY, value);
}
public void UpdateColor(BitArray bits) => this.Color = bits[bitIndex] ? this.onColor : this.offColor;
}
Update the View class#
Configure the user interface as follows
- Add
ColorToSolidColorBrushValueConverter
to theResources
- Wrap a fixed size
Grid
ofHeight=1000
within aViewBox
andDockPanel
- Configure two rows of
Height=100
andHeight=*
- Configure three columns of
Width=100
,Width=*
andWidth=100
- Configure two rows of
- Use an
ItemsControl
to display the top shift lights atRow=0
andColumn=1
- Bind
ItemsSource
attribute to the View ModelTopShiftLights
property - Set the
ItemsPanel
element to aUniformGrid
withRows=1
andColumns=15
- Set the
ItemTemplate
element to aDataTemplate
containing a uniformViewBox
- Set the
RenderTransform
element to aTranslateTransform
- Set
X=0
- Bind the
Y
attribute to the LED View ModelShiftY
property
- Set
- Set the
ViewBox
contents to anEllipse
shape ofWidth=100
andHeight=100
- Bind the
Fill
attribute to the LED View ModelColor
property withConverter=ColorToSolidColorBrushValueConverter
- Bind the
- Set the
- Bind
- Use an
ItemsControl
to display the left shift lights atRow=1
andColumn=0
- Bind
ItemsSource
attribute to the View ModelLeftShiftLights
property - Set the
ItemsPanel
element to aStackPanel
withOrientation=Vertical
- Set the
ItemTemplate
element to aDataTemplate
containing a uniformViewBox
containing anEllipse
shape ofWidth=100
andHeight=100
- Bind the
Fill
attribute to the LED View ModelColor
property withConverter=ColorToSolidColorBrushValueConverter
- Bind the
- Bind
- Use an
ItemsControl
to display the right shift lights atRow=1
andColumn=2
- Bind
ItemsSource
attribute to the View ModelRightShiftLights
property - Set the
ItemsPanel
element to aStackPanel
withOrientation=Vertical
- Set the
ItemTemplate
element to aDataTemplate
containing a uniformViewBox
containingEllipse
shape ofWidth=100
andHeight=100
- Bind the
Fill
attribute to the LED View ModelColor
property withConverter=ColorToSolidColorBrushValueConverter
- Bind the
- Bind
<UserControl.Resources>
<converters:ColorToSolidColorBrushValueConverter x:Key="ColorToSolidColorBrushValueConverter" />
</UserControl.Resources>
<DockPanel>
<Viewbox>
<Grid Height="1000">
<Grid.RowDefinitions>
<RowDefinition Height="100" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="100" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="100" />
</Grid.ColumnDefinitions>
<ItemsControl Grid.Row="0"
Grid.Column="1"
ItemsSource="{Binding TopShiftLights}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<UniformGrid Rows="1" Columns="15" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Viewbox Stretch="Uniform" Margin="10">
<Viewbox.RenderTransform>
<TranslateTransform X="0" Y="{Binding ShiftY}" />
</Viewbox.RenderTransform>
<Ellipse Width="100" Height="100"
Fill="{Binding Color, Converter={StaticResource ColorToSolidColorBrushValueConverter}}" />
</Viewbox>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<ItemsControl Grid.Row="1"
Grid.Column="0"
ItemsSource="{Binding LeftShiftLights}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Vertical" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Viewbox Stretch="Uniform" Margin="10">
<Ellipse Width="100" Height="100"
Fill="{Binding Color, Converter={StaticResource ColorToSolidColorBrushValueConverter}}" />
</Viewbox>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<ItemsControl Grid.Row="1"
Grid.Column="2"
ItemsSource="{Binding RightShiftLights}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Vertical" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Viewbox Stretch="Uniform" Margin="10">
<Ellipse Width="100" Height="100"
Fill="{Binding Color, Converter={StaticResource ColorToSolidColorBrushValueConverter}}" />
</Viewbox>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Grid Grid.Row="1"
Grid.Column="1"
Margin="20">
<!-- added later -->
</Grid>
</Grid>
</Viewbox>
</DockPanel>
Update the View Model class#
Subscribe to SampleResultSignal
in the View Model constructor to handle the result of a sample data request
this.Disposables.Add(
this.SignalBus.Subscribe<SampleResultSignal>
this.HandleSampleResultSignal,
r => r.SourceId == this.ScopeIdentity.Guid));
Add an OperationTracker<SampleRequestSignal> field named sampleRequestTracker
to throttle sample data requests
private readonly OperationTracker<SampleRequestSignal> sampleRequestTracker;
Instantiate sampleRequestTracker
in the View Model constructor
this.sampleRequestTracker = new OperationTracker<SampleRequestSignal>(ThrottleInterval, signalBus.Send);
Add static fields for the various LED on and off colors
private static readonly Color BlueOff = Color.FromArgb(255, 0, 0, 40);
private static readonly Color BlueOn = Color.FromArgb(255, 0, 0, 255);
private static readonly Color GreenOff = Color.FromArgb(255, 0, 40, 0);
private static readonly Color GreenOn = Color.FromArgb(255, 0, 255, 0);
private static readonly Color RedOff = Color.FromArgb(255, 40, 0, 0);
private static readonly Color RedOn = Color.FromArgb(255, 255, 0, 0);
private static readonly Color YellowOff = Color.FromArgb(255, 40, 40, 0);
private static readonly Color YellowOn = Color.FromArgb(255, 255, 255, 0);
Add properties that represent the left, top and right LED strips
[Browsable(false)]
public List<LedViewModel> TopShiftLights { get; } = new List<LedViewModel>()
{
new LedViewModel(GreenOff, GreenOn, 14, 15),
new LedViewModel(GreenOff, GreenOn, 13, 10),
new LedViewModel(GreenOff, GreenOn, 12, 5),
new LedViewModel(GreenOff, GreenOn, 11, 0),
new LedViewModel(GreenOff, GreenOn, 10, -5),
new LedViewModel(RedOff, RedOn, 9, -10),
new LedViewModel(RedOff, RedOn, 8, -10),
new LedViewModel(RedOff, RedOn, 7, -10),
new LedViewModel(RedOff, RedOn, 6, -10),
new LedViewModel(RedOff, RedOn, 5, -10),
new LedViewModel(BlueOff, BlueOn, 4, -5),
new LedViewModel(BlueOff, BlueOn, 3, 0),
new LedViewModel(BlueOff, BlueOn, 2, 5),
new LedViewModel(BlueOff, BlueOn, 1, 10),
new LedViewModel(BlueOff, BlueOn, 0, 15)
};
[Browsable(false)]
public List<LedViewModel> LeftShiftLights { get; } = new List<LedViewModel>()
{
new LedViewModel(YellowOff, YellowOn, 20, 0),
new LedViewModel(RedOff, RedOn, 18, 0),
new LedViewModel(BlueOff, BlueOn, 16, 0)
};
[Browsable(false)]
public List<LedViewModel> RightShiftLights { get; } = new List<LedViewModel>()
{
new LedViewModel(YellowOff, YellowOn, 19, 0),
new LedViewModel(RedOff, RedOn, 17, 0),
new LedViewModel(BlueOff, BlueOn, 15, 0)
};
Override the OnMakeCursorDataRequestsAsync()
method to issue a sample data request for the first display parameter when the cursor timestamp changes
protected override Task OnMakeCursorDataRequestsAsync(ICompositeSession compositeSession)
{
if (this.DisplayParameterService.PrimaryParameters.Count < 1)
{
return Task.CompletedTask;
}
var signal = this.DataRequestSignalFactory.CreateSampleRequestSignal(
this.ScopeIdentity.Guid,
compositeSession.Key,
this.DisplayParameterService.PrimaryParameters.FirstOrDefault(),
compositeSession.CursorPoint + 1,
1,
SampleDirection.Previous);
this.sampleRequestTracker.Add(signal);
return Task.CompletedTask;
}
Note
Rather than send the signal directly to the SignalBus
, add it to sampleRequestTracker
Add the HandleSampleResultSignal()
method to update the LED colours
private void HandleSampleResultSignal(SampleResultSignal signal)
{
var result = signal.Data;
var parameterValues = result.ParameterValues;
parameterValues.Lock();
try
{
if (parameterValues.SampleCount == 1)
{
var shiftLightsValue = (uint) parameterValues.Data[0];
var bits = new BitArray(BitConverter.GetBytes(shiftLightsValue));
this.TopShiftLights.ForEach(sl => sl.UpdateColor(bits));
this.LeftShiftLights.ForEach(sl => sl.UpdateColor(bits));
this.RightShiftLights.ForEach(sl => sl.UpdateColor(bits));
}
}
finally
{
parameterValues.Unlock();
this.sampleRequestTracker.Complete();
}
}
Note
In this instance the View Model properties are being modified on the Task Pool.
WPF will handle this special case, more sophisticated updates need to be done by calling the ExecuteOnUiAsync
method.
Attention
It is important to call Complete()
on OperationTracker<T>
to allow subsequent operations to execute
Testing the display#
To view the LED graphics
- Add a session via the Session Browser to the compare set associated with the display
- Add the PCU8D LED display parameter (e.g.
NLEDStatusAll
) via the Parameter Browser - Use a Waveform display to change the cursor
Interactive vector graphics#
For complex graphics that change regularly, WPF provides the low-level and efficient DrawingContext
class.
The Display Plugin Library VisualLayer
class provides a simple way to add DrawingContext
graphics to a display.
This example shows how to display a dynamic trace of a parameter and simple cursor.
The simplest way to display a trace is to fetch all the samples of a parameter and draw lines between each.
The trouble is, when viewing multiple laps of a high frequency parameter there could be 100,000+ samples, however even at 4K resolution we couldn't possibly resolve that many samples. Therefore we should re-sample the parameter to a more manageable resolution, e.g. 1000 data points.
Therefore by making a data request instead of a sample data request it doesn't matter how many actual samples there are within the time range
- The time range is divided into an equal time interval per data point, the sample mode determines how the actual samples that fall within each interval are summarized.
The Trace
class converts the result of a data request into a collection of lines.
The Cursor
class converts the cursor timestamp into a vertical line.
Note
Since the purpose of this example is to demonstrate graphics, Trace
and Cursor
classes are provided as is and are not guaranteed to cover all edge cases.
Update the View class#
Starting with the XAML from the above example
- Add two overlapping
VisualLayer
XAML elements within theGrid
atRow=1
andColumn=1
<displayPluginLibrary:VisualLayer x:Name="TraceVisualLayer" />
<displayPluginLibrary:VisualLayer x:Name="CursorVisualLayer" />
Note
Having separate visual layers for trace and cursor allows them to be drawn independently, improving performance when only one is changing.
DrawingContext
is a retained mode graphics system, therefore the previous content persists until refreshed.
In the code behind, associate each VisualLayer
with an associated View Model property
public override void OnApplyTemplate()
{
base.OnApplyTemplate();
if (this.DataContext is SampleDisplayViewModel vm)
{
vm.TraceVisual = this.TraceVisualLayer.Visual;
vm.CursorVisual = this.CursorVisualLayer.Visual;
}
}
Update the View Model class#
Add the following backers
Pen
field namedcursorPen
that is the graphics pen used to draw the cursor- OperationTracker<(DataRequestSignal, IDisplayParameterContainer)> field named
dataRequestTracker
to throttle data requests - OperationTracker<Trace> field named
redrawTraceRequestTracker
to throttle trace redraw requests - OperationTracker<Cursor> field named
redrawCursorRequestTracker
to throttle cursor redraw requests int
field nameddataRequestSampleCount
as backer for theDataRequestSampleCount
property
private readonly Pen cursorPen = new Pen(Brushes.White, 1);
private readonly OperationTracker<(DataRequestSignal Signal, IDisplayParameterContainer ParameterContainer)> dataRequestTracker;
private readonly OperationTracker<Trace> redrawTraceRequestTracker;
private readonly OperationTracker<Cursor> redrawCursorRequestTracker;
private int dataRequestSampleCount;
Instantiate the operation trackers in the View Model constructor
this.dataRequestTracker = new OperationTracker<(DataRequestSignal Signal, IDisplayParameterContainer)>(
ThrottleInterval,
operation => signalBus.Send(operation.Signal));
this.redrawTraceRequestTracker = new OperationTracker<Trace>(
ThrottleInterval,
async trace => await this.ExecuteOnUiAsync(() => this.Redraw(trace)));
this.redrawCursorRequestTracker = new OperationTracker<Cursor>(
ThrottleInterval,
async cursor => await this.ExecuteOnUiAsync(() => this.Redraw(cursor)));
Note
Trace and Cursor graphics are drawn on the UI thread by calling via the ExecuteOnUiAsync()
method
Add the following properties
int
property namedDataRequestSampleCount
that determines the number of data points to fetch and draw (defaults to 1000)IVisual
property namedCursorVisual
used to draw the cursor (set in the View code behind)IVisual
property namedTraceVisual
used to draw the trace (set in the View code behind)
public int DataRequestSampleCount
{
get => this.dataRequestSampleCount = this.ReadProperty(1000);
set
{
if (this.SetProperty(ref this.dataRequestSampleCount, value))
{
this.SaveProperty(value);
this.MakeDataRequests(false, true);
}
}
}
[Browsable(false)]
public IVisual CursorVisual { get; set; }
[Browsable(false)]
public IVisual TraceVisual { get; set; }
Extend OnMakeCursorDataRequestsAsync()
method to instantiate a Cursor
instance and add to redrawCursorRequestTracker
to initiate a redraw
var cursor = new Cursor(compositeSession.TimebaseRange, compositeSession.CursorPoint);
this.redrawCursorRequestTracker.Add(cursor);
Subscribe to DataResultSignal
in the View Model constructor to handle the result of a data request
this.Disposables.Add(
signalBus.Subscribe<DataResultSignal>(
this.HandleDataResultSignal,
r => r.SourceId == this.ScopeIdentity.Guid));
Override the OnMakeTimebaseDataRequestsAsync()
method to issue a data request for the second parameter when the timebase timestamp range changes
protected override Task OnMakeTimebaseDataRequestsAsync(ICompositeSession compositeSession)
{
if (this.DisplayParameterService.PrimaryParameters.Count < 2)
{
return Task.CompletedTask;
}
// Second parameter is the trace line
var signal = this.DataRequestSignalFactory.CreateDataRequestSignal(
this.ScopeIdentity.Guid,
this.DisplayParameterService.PrimaryParameters.Skip(1).FirstOrDefault(),
compositeSession.TimebaseRange,
this.DataRequestSampleCount,
SampleMode.MaximumToMinimum);
this.dataRequestTracker.Add((signal, this.DisplayParameterService.ParameterContainers.Skip(1).FirstOrDefault()));
return Task.CompletedTask;
}
Note
The Trace
class needs a SampleMode
of MaximumToMinimum
in order to render correctly.
Note
Rather than send the signal directly to the SignalBus
, add it to dataRequestTracker
Add the HandleDataResultSignal()
method to instantiate a Trace
instance and add to redrawTraceRequestTracker
to initiate a redraw
private void HandleDataResultSignal(DataResultSignal signal)
{
var request = signal.Data.Request;
var result = signal.Data;
var parameterValues = result.ParameterValues;
parameterValues.Lock();
try
{
if (parameterValues.SampleCount > 0 &&
this.dataRequestTracker.GetCurrent(out var currentOperation))
{
var trace = new Trace(currentOperation.ParameterContainer, request.Parameter, parameterValues);
this.redrawTraceRequestTracker.Add(trace);
}
}
finally
{
parameterValues.Unlock();
this.dataRequestTracker.Complete();
}
}
Add the Redraw
methods for Trace
and Cursor
private void Redraw(Trace trace)
{
try
{
var extents = this.TraceVisual.Extents;
if (!this.IsDisplayVisible || extents.Width == 0 || extents.Height == 0)
{
this.TraceVisual.Draw(delegate { });
return;
}
this.TraceVisual.Draw(
dc =>
{
dc.DrawRectangle(
Brushes.Transparent,
new Pen(Brushes.White, 1),
new Rect(new Point(0, 0), extents));
var tracePen = new Pen(new SolidColorBrush(trace.TraceColor), extents.Width / trace.PointCount);
foreach (var (start, end) in trace.GetLines(extents))
{
dc.DrawLine(tracePen, start, end);
}
});
}
finally
{
redrawTraceRequestTracker.Complete();
}
}
private void Redraw(Cursor cursor)
{
try
{
var extents = this.CursorVisual.Extents;
if (!this.IsDisplayVisible ||
extents.Width == 0 ||
extents.Height == 0 ||
!cursor.GetCursorLine(extents, out var cursorLine))
{
this.CursorVisual.Draw(delegate { });
return;
}
this.CursorVisual.Draw(
dc =>
{
dc.DrawLine(this.cursorPen, cursorLine.Item1, cursorLine.Item2);
});
}
finally
{
redrawCursorRequestTracker.Complete();
}
}
Note
DrawingContext
is a retained mode graphics system, therefore to clear it, call Draw()
but draw nothing
Testing the display#
To view the trace and cursor graphics
- Add a session via the Session Browser to the compare set associated with the display
- Add a display parameter to visualize (e.g.
vCar
) via the Parameter Browser- Ensure there is already one display parameter set for the PCU8D LED visualization
- Use a Waveform display to change the cursor and timebase